diff --git a/app/src/debug/assets/wolfskin-requests.json b/app/src/debug/assets/wolfskin-requests.json new file mode 100644 index 00000000..507ebd65 --- /dev/null +++ b/app/src/debug/assets/wolfskin-requests.json @@ -0,0 +1,217 @@ +{ + "source": "Locked Opus fixture/fixture.json; index.html EXAMPLES() pool order", + "items": [ + { + "id": "x1", + "mediaType": "tv", + "tmdbId": 11821, + "title": "The Abbott and Costello Show", + "requesterKey": "justin", + "requesterName": "Justin", + "actorKey": "justin", + "actorName": "Justin", + "stage": "Downloading", + "poster": "tv-the-abbott-and-costello-show-1952-poster.jpg", + "ratings": [ + { + "mark": "tmdb", + "score": 7.2, + "percentage": false + } + ] + }, + { + "id": "x2", + "mediaType": "movie", + "tmdbId": 13483, + "title": "Awake", + "requesterKey": "mindel", + "requesterName": "Mindel", + "actorKey": "justin", + "actorName": "Justin", + "stage": "AlmostReady", + "poster": "movie-awake-2007-poster.jpg", + "ratings": [ + { + "mark": "rotten", + "score": 23, + "percentage": true + }, + { + "mark": "spilled", + "score": 55, + "percentage": true + }, + { + "mark": "imdb", + "score": 6.5, + "percentage": false + } + ] + }, + { + "id": "x3", + "mediaType": "tv", + "tmdbId": 70785, + "title": "Anne with an E", + "requesterKey": "shared", + "requesterName": "Shared", + "actorKey": "mindel", + "actorName": "Mindel", + "stage": "Searching", + "poster": "tv-anne-with-an-e-2017-poster.jpg", + "ratings": [ + { + "mark": "fresh", + "score": 63, + "percentage": true + } + ] + }, + { + "id": "x4", + "mediaType": "movie", + "tmdbId": 99861, + "title": "Avengers: Age of Ultron", + "requesterKey": "justin", + "requesterName": "Justin", + "actorKey": "justin", + "actorName": "Justin", + "stage": "Waiting", + "poster": "movie-avengers-age-of-ultron-2015-poster.jpg", + "ratings": [ + { + "mark": "fresh", + "score": 75, + "percentage": true + }, + { + "mark": "upright", + "score": 82, + "percentage": true + }, + { + "mark": "imdb", + "score": 7.3, + "percentage": false + } + ] + }, + { + "id": "x5", + "mediaType": "movie", + "tmdbId": 1033148, + "title": "Die My Love", + "requesterKey": "mindel", + "requesterName": "Mindel", + "actorKey": "mindel", + "actorName": "Mindel", + "stage": "Failed", + "poster": "movie-die-my-love-2025-poster.jpg", + "ratings": [ + { + "mark": "fresh", + "score": 74, + "percentage": true + }, + { + "mark": "spilled", + "score": 45, + "percentage": true + }, + { + "mark": "imdb", + "score": 6, + "percentage": false + } + ] + }, + { + "id": "x6", + "mediaType": "movie", + "tmdbId": 416153, + "title": "Arctic Dogs", + "requesterKey": "shared", + "requesterName": "Shared", + "actorKey": "justin", + "actorName": "Justin", + "stage": "Ready", + "poster": "movie-arctic-dogs-2019-poster.jpg", + "ratings": [ + { + "mark": "rotten", + "score": 13, + "percentage": true + }, + { + "mark": "upright", + "score": 60, + "percentage": true + }, + { + "mark": "imdb", + "score": 4.9, + "percentage": false + } + ] + }, + { + "id": "x7", + "mediaType": "movie", + "tmdbId": 76203, + "title": "12 Years a Slave", + "requesterKey": "justin", + "requesterName": "Justin", + "actorKey": "shared", + "actorName": "Shared", + "stage": "Waiting", + "poster": "movie-12-years-a-slave-2013-poster.jpg", + "ratings": [ + { + "mark": "fresh", + "score": 95, + "percentage": true + }, + { + "mark": "upright", + "score": 90, + "percentage": true + }, + { + "mark": "imdb", + "score": 8.1, + "percentage": false + } + ] + }, + { + "id": "x8", + "mediaType": "movie", + "tmdbId": 14069, + "title": "The Girl Who Leapt Through Time", + "requesterKey": "mindel", + "requesterName": "Mindel", + "actorKey": "justin", + "actorName": "Justin", + "stage": "Waiting", + "poster": "movie-the-girl-who-leapt-through-time-2006-poster.jpg", + "ratings": [ + { + "mark": "fresh", + "score": 87, + "percentage": true + }, + { + "mark": "upright", + "score": 90, + "percentage": true + }, + { + "mark": "imdb", + "score": 7.6, + "percentage": false + } + ] + } + ] +} diff --git a/app/src/debug/assets/wolfskin-requests/movie-12-years-a-slave-2013-poster.jpg b/app/src/debug/assets/wolfskin-requests/movie-12-years-a-slave-2013-poster.jpg new file mode 100644 index 00000000..ff1f55e6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-requests/movie-12-years-a-slave-2013-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-requests/movie-arctic-dogs-2019-poster.jpg b/app/src/debug/assets/wolfskin-requests/movie-arctic-dogs-2019-poster.jpg new file mode 100644 index 00000000..07552443 Binary files /dev/null and b/app/src/debug/assets/wolfskin-requests/movie-arctic-dogs-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-requests/movie-avengers-age-of-ultron-2015-poster.jpg b/app/src/debug/assets/wolfskin-requests/movie-avengers-age-of-ultron-2015-poster.jpg new file mode 100644 index 00000000..300840ff Binary files /dev/null and b/app/src/debug/assets/wolfskin-requests/movie-avengers-age-of-ultron-2015-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-requests/movie-awake-2007-poster.jpg b/app/src/debug/assets/wolfskin-requests/movie-awake-2007-poster.jpg new file mode 100644 index 00000000..32595897 Binary files /dev/null and b/app/src/debug/assets/wolfskin-requests/movie-awake-2007-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-requests/movie-die-my-love-2025-poster.jpg b/app/src/debug/assets/wolfskin-requests/movie-die-my-love-2025-poster.jpg new file mode 100644 index 00000000..f6eca818 Binary files /dev/null and b/app/src/debug/assets/wolfskin-requests/movie-die-my-love-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-requests/movie-the-girl-who-leapt-through-time-2006-poster.jpg b/app/src/debug/assets/wolfskin-requests/movie-the-girl-who-leapt-through-time-2006-poster.jpg new file mode 100644 index 00000000..ded6cd2a Binary files /dev/null and b/app/src/debug/assets/wolfskin-requests/movie-the-girl-who-leapt-through-time-2006-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-requests/tv-anne-with-an-e-2017-poster.jpg b/app/src/debug/assets/wolfskin-requests/tv-anne-with-an-e-2017-poster.jpg new file mode 100644 index 00000000..55d443b4 Binary files /dev/null and b/app/src/debug/assets/wolfskin-requests/tv-anne-with-an-e-2017-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-requests/tv-the-abbott-and-costello-show-1952-poster.jpg b/app/src/debug/assets/wolfskin-requests/tv-the-abbott-and-costello-show-1952-poster.jpg new file mode 100644 index 00000000..7d6768ea Binary files /dev/null and b/app/src/debug/assets/wolfskin-requests/tv-the-abbott-and-costello-show-1952-poster.jpg differ diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/RequestsRefreshTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/RequestsRefreshTest.kt new file mode 100644 index 00000000..a90bb4b1 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/RequestsRefreshTest.kt @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.justinvisser.wolfskin.design.ComingInRequest +import org.justinvisser.wolfskin.design.ComingInSnapshot +import org.justinvisser.wolfskin.design.ComingInStage +import org.justinvisser.wolfskin.design.ComingInStatus +import org.justinvisser.wolfskin.design.ShellPerson +import org.justinvisser.wolfskin.design.ShellState + +/** The fake clock checks refresh boundaries and cancellation without a server or a real wait. */ +@OptIn(ExperimentalCoroutinesApi::class) +class RequestsRefreshTest { + @Test fun foregroundOpenUnsettledSettledAndStoppedTransitions() = runTest { + val state = ShellState(listOf(ShellPerson("justin", "Justin", "J", false)), "justin") + val request = ComingInRequest("one", "movie", 1, "One", "justin", "Justin", "shared", "Shared", ComingInStage.Waiting) + state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready, signedIn = true)) + var reads = 0 + val active = launch { refreshRequestsWhileStarted(state) { reads++ } } + runCurrent() + assertEquals(1, reads) + advanceTimeBy(30_000); runCurrent() + assertEquals("Closed and settled does not poll", 1, reads) + state.openRequests() + advanceTimeBy(29_999); runCurrent() + assertEquals(1, reads) + advanceTimeBy(1); runCurrent() + assertEquals("An open empty sheet still refreshes at 30 seconds", 2, reads) + state.closeRequests() + state.requests.update(ComingInSnapshot(listOf(request), ComingInStatus.Ready, signedIn = true)) + advanceTimeBy(30_000); runCurrent() + assertEquals("An unsettled request refreshes after the sheet closes", 3, reads) + state.requests.update(ComingInSnapshot(listOf(request.copy(stage = ComingInStage.Ready)), ComingInStatus.Ready, signedIn = true)) + advanceTimeBy(30_000); runCurrent() + assertEquals("Ready requests stop polling", 3, reads) + active.cancel(); runCurrent() + state.openRequests() + advanceTimeBy(60_000); runCurrent() + assertEquals("A stopped lifecycle cannot poll", 3, reads) + val resumed = launch { refreshRequestsWhileStarted(state) { reads++ } } + runCurrent() + assertEquals("Returning to the foreground refreshes immediately", 4, reads) + resumed.cancel() + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/RequestsRefresh.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/RequestsRefresh.kt new file mode 100644 index 00000000..50397a67 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/RequestsRefresh.kt @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import org.justinvisser.wolfskin.design.ShellState +import org.justinvisser.wolfskin.design.WolfskinTokens + +/** Foreground refreshes once; only an open sheet or an unsettled request keeps polling. */ +@Composable +internal fun RequestsRefresh(state: ShellState, refresh: () -> Unit) { + val owner = LocalLifecycleOwner.current + LaunchedEffect(state, owner) { + owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + refreshRequestsWhileStarted(state, refresh) + } + } +} + +/** Cancellation on leaving STARTED stops polling; reentry performs a fresh foreground read. */ +internal suspend fun refreshRequestsWhileStarted(state: ShellState, refresh: () -> Unit) { + refresh() + while (currentCoroutineContext().isActive) { + delay(WolfskinTokens.Behaviour.requestsPollSeconds.toLong() * 1000L) + if (state.requestsVisible || state.requests.snapshot.openCount > 0) refresh() + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRequests.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRequests.kt new file mode 100644 index 00000000..a5b32953 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRequests.kt @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.content.Context +import com.github.damontecres.wholphin.BuildConfig +import org.justinvisser.wolfskin.design.ComingInSnapshot +import org.justinvisser.wolfskin.design.ComingInStatus +import org.justinvisser.wolfskin.design.readComingInFixture + +/** Release stays unavailable until Conductarr sign-in supplies an authenticated generated client. */ +fun readShellRequests(context: Context): ComingInSnapshot { + if (!BuildConfig.DEBUG) return ComingInSnapshot() + val json = context.assets.open("wolfskin-requests.json").bufferedReader().use { it.readText() } + val items = readComingInFixture(json) { name -> + context.assets.open("wolfskin-requests/$name").use { it.readBytes() } + } + return ComingInSnapshot(items.take(6), ComingInStatus.Ready, signedIn = true) +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index f8b9881a..d8cba241 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -73,6 +73,7 @@ class WolfskinShellViewModel @Inject constructor( val seeAll: WolfskinSeeAllLists, ) : ViewModel() { val backdropFlow: StateFlow = backdrop.backdropFlow + private val requestsSnapshot by lazy { readShellRequests(context) } var libraryDestinations by mutableStateOf>(emptyMap()) private set var signedInPerson by mutableStateOf("") @@ -98,6 +99,14 @@ class WolfskinShellViewModel @Inject constructor( fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } } + /** The debug fixture is the only Requests supplier until TV sign-in is enabled. */ + fun refreshRequests() { + viewModelScope.launch { + val snapshot = withContext(Dispatchers.IO) { requestsSnapshot } + session.state?.requests?.update(snapshot) + } + } + private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) { when (action) { is ShellAction.Destination -> if (action.route == "settings") navigation.navigateTo(Destination.Wolfskin("settings")) else navigation.replace(if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route)) @@ -105,6 +114,8 @@ class WolfskinShellViewModel @Inject constructor( is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group)) is ShellAction.Person -> selectPerson(action.person, server, user) ShellAction.RetryPeople -> viewModelScope.launch { load(server, user) } + ShellAction.RetryRequests -> refreshRequests() + is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin("request-title/${action.request.mediaType}/${action.request.tmdbId}")) } } @@ -155,6 +166,7 @@ fun WolfskinApp( ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()).apply { peopleStatus = PeopleStatus.Loading } } val state = viewModel.session.state ?: loading + RequestsRefresh(state, viewModel::refreshRequests) WolfskinTheme { CompositionLocalProvider(LocalShellState provides state) { Box(modifier) { @@ -210,6 +222,7 @@ fun WolfskinDestinationContent(route: String, modifier: Modifier = Modifier) { when { route == "settings" -> SettingsList(LocalShellState.current, modifier) route == "discover" -> HomeDiscoverTarget(modifier) + route.startsWith("request-title/") -> ShellPlaceholder("Title: ${LocalShellState.current.requests.focused?.title.orEmpty()} (title slice integration pending)", modifier) else -> ShellPlaceholder(route, modifier) } } diff --git a/docker/README.md b/docker/README.md index f32c52a1..06eaf0c1 100644 --- a/docker/README.md +++ b/docker/README.md @@ -59,9 +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/grid measurements and -`home-tokens.json` adds Home geometry from that same prototype. Generate the -committed Kotlin, CSS and checksum manifest together: +SHA and provenance. `shell-tokens.json` adds shell, grid and sheet measurements; +`home-tokens.json` adds Home geometry from that same locked prototype. Generate +the committed Kotlin, CSS and checksum manifest together: ```sh python3 wolfskin-design/generate_tokens.py @@ -84,8 +84,8 @@ padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-w ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%. The small gear under the final person opens Settings with Requests focused. -Settings groups hand off to existing upstream pages; the Requests entry explicitly -has no request data yet. **Advanced settings → Wolfskin screens** is the persistent +Settings groups hand off to existing upstream pages; Requests opens the household +Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent rollout switch: turning it off renders upstream Home and library destinations with the upstream Backdrop and top scrim, while retaining Wolfskin's nav, picker and Settings list. Native Up traversal stays in content until the first focusable row, @@ -192,6 +192,68 @@ side by side with the frozen spec captures; Compose Roboto/JPEG rasterisation, the approved button-scale consolidation and removal of prototype sample pills are recorded differences, not a claim of browser pixel equality. +## Requests under Settings + +The Requests row and Coming-in sheet share one immutable household snapshot. +Ready requests do not count as open. Rows sort Failed, Downloading/Almost ready, +Checking/Searching, Waiting, Ready. The bounded window shows five rows, or four +with readiness expanded. Right expands Request/Download/Library/Playback; Left +collapses; Up/Down collapse while moving. Opening a title preserves the request +for Back, and closing the sheet restores its opener. Selecting a root or changing +person clears the title-return link. Shared's authenticated actor always reads +“by Shared”, never “by you”. + +The debug-only provider loads `app/src/debug/assets/wolfskin-requests.json` and +its eight local posters, projected from the locked prototype fixture. Six stage +rows feed the running debug shell; the Shared and focused-rating records feed +native acceptance states. The identical projection is in design-test resources. +No “sample” or “example stages” pill is drawn. Release builds package none of +these assets and show Requests unavailable until Conductarr sign-in supplies +the generated-client data path. There is no Seerr fallback or handwritten HTTP. +Foreground refreshes once; an open sheet or unsettled requests refresh every +30 seconds while started. The immutable debug projection is loaded once, not +decoded again on every poll. +Thumbnails reuse `WolfskinImages` with a 96×144-pixel request, RGB565, no crossfade, +the shared 64 MB memory/128 MB disk cache and four-read limit. Actor separator +dots use the surrounding text colour; the 40% rule applies only to poster captions. + +Uncached focused ratings wait 350 ms; moving away cancels the read. Read ratings +show immediately on return; sign-out clears visibility. This follows the frozen +page and spec 6.12, resolving the contradictory no-dwell sentence in spec 7.8. +Deterministic coroutine-clock tests cover cancellation, reentry, cached return, +sign-out and refreshed locale scores; a thin Compose test verifies actual Dutch +rendering and sign-out suppression. The approved nine-key Requests path is JSON +data replayed through the real shell. The Requests-only harness starts at the +Home-to-nav boundary; Home's actual hero and asynchronous entry have separate key replays. + +Sheet dimensions, padding, glyphs, scales, colours and durations are generated +tokens. Scoped sheet line boxes retain CSS heights and baselines independently +of native leading rounding; shared shell/library typography is unchanged. Platform +Roboto remains required: the host browser used DejaVu Sans, so its width, +wrapping, ellipsis and text-block positions are not a wrapping-parity target. +For example, the 44-pixel “Coming in” ink is 239 pixels wide in the reference +and 194 pixels in native captures. No per-title compensation or font bundle is +used. Native comparisons must report those structural differences explicitly. +Readiness glyphs sit inline on the text baseline with no flex gap, matching the +frozen block-span cascade. Items wrap intrinsically: Roboto fits the expanded +readiness into two lines where the reference uses three; no grouping is forced. + +The six rating marks retain the exact locked SVG source in +`wolfskin-design/request-marks/`; native-size PNGs and their hashes are generated +from the JSON dimensions with the locked contain fit (`.rr .rb` outranks `.crow img`). +Inside Coming-in rows they retain the prototype's dark `surface3` chips with 12-pixel rounded corners. +Loaded poster thumbnails have no placeholder fill beneath their clipped edge. + +```sh +uv run --with cairosvg==2.9.1 python wolfskin-design/generate_request_marks.py +``` + +`RequestsCaptureTest` writes `st-081`…`st-086`, `st-088` and `st-111` at 1920×1080, +density 2.0. Title selection currently uses an explicitly labelled +`request-title/...` destination until the independent title screen is integrated; +this slice does not claim to implement title pages, TV sign-in, release or device +deployment. + ## Libraries and See all Movies, Shows and Anime use the shell's retained view and a continuous six-column @@ -206,8 +268,8 @@ its unknown item count. Caption separators use 40% of the existing muted colour's alpha. The rail follows the locked prototype CSS: bold 24 px letters, with the focused letter bold 28 px, overriding the spec table's generic caption style for this component. -Library header initials use the muted colour over the person's fill; navigation, -picker and Settings avatars retain their dark initials. +Library and Settings header initials use the muted colour over the person's fill; +navigation and picker avatars retain their dark initials. The debug-only `wolfskin-library.json` and `library/assets/` contain the locked prototype's titles and local poster art. Release libraries read through diff --git a/wolfskin-design/build.gradle.kts b/wolfskin-design/build.gradle.kts index 222b0dac..c5c07e17 100644 --- a/wolfskin-design/build.gradle.kts +++ b/wolfskin-design/build.gradle.kts @@ -59,6 +59,7 @@ dependencies { implementation(libs.coil.compose) debugImplementation(libs.androidx.compose.ui.test.manifest) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.robolectric) implementation(libs.androidx.compose.material3) testImplementation(libs.androidx.compose.ui.test.junit4) diff --git a/wolfskin-design/generate_request_marks.py b/wolfskin-design/generate_request_marks.py new file mode 100755 index 00000000..bba6a960 --- /dev/null +++ b/wolfskin-design/generate_request_marks.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Rasterize locked prototype rating marks at the Coming-in row's native pixel size.""" +from pathlib import Path +import hashlib +import json +import cairosvg + +ROOT = Path(__file__).resolve().parent +SOURCE = ROOT / "request-marks" +OUTPUT = ROOT / "src/main/res/drawable-nodpi" + + +def main() -> None: + """Keep the original SVGs and record hashes alongside generated television-size PNGs.""" + OUTPUT.mkdir(parents=True, exist_ok=True) + tokens = json.loads((ROOT / "tokens.json").read_text()) + supplement = json.loads((ROOT / "shell-tokens.json").read_text()) + height = int(tokens["type"]["label"]["px"]) + wordmark_ratio = supplement["requests"]["ratingsWordmarkRatio"]["$value"] + manifest = [] + for source in sorted(SOURCE.glob("*.svg")): + target = OUTPUT / f"coming_in_rating_{source.stem}.png" + width = round(height * wordmark_ratio) if source.stem in ("imdb", "tmdb") else height + cairosvg.svg2png(url=str(source), write_to=str(target), output_width=width, output_height=height) + for path in (source, target): + manifest.append(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.relative_to(ROOT)}") + (ROOT / "request-marks.sha256").write_text("\n".join(manifest) + "\n") + + +if __name__ == "__main__": + main() diff --git a/wolfskin-design/request-marks.sha256 b/wolfskin-design/request-marks.sha256 new file mode 100644 index 00000000..c29ddefc --- /dev/null +++ b/wolfskin-design/request-marks.sha256 @@ -0,0 +1,12 @@ +01aa8a3c3e9db48f0e1c6af25056940986784315bf66f48a8120a7317bf6d75a request-marks/fresh.svg +743b6139dc12019d0940568869065722593f44ce416a7e9c7b8d9ef92942bb3f src/main/res/drawable-nodpi/coming_in_rating_fresh.png +27f93b15a075b015e2448eca7949f7a3da03c37fbf26cecf6df3bdab45fda993 request-marks/imdb.svg +a738afd57519b1b4a38192927b48bd45755ce75e4673c6b12b9b3e0c2e388372 src/main/res/drawable-nodpi/coming_in_rating_imdb.png +510d0f9fb77d72190ebf03208f04215a4e86f71e165488518ac75aa49cf4a79d request-marks/rotten.svg +0aade711ba57b6b201c7395ece97108df928b6076d8120404cdf4155a4242632 src/main/res/drawable-nodpi/coming_in_rating_rotten.png +b8dcf4d27eba2446380980c96d82b1f64aa6859fa2ea1cdab867f849f9fec4fd request-marks/spilled.svg +fa4f50258009cd1d16f0d0f5a3c216dc88a57409b1bccc379512ae914cc9d714 src/main/res/drawable-nodpi/coming_in_rating_spilled.png +d537fb228cf3ded904ef09b136fe3fec72548ebc1fea3fbbd1ad9e36364db38b request-marks/tmdb.svg +8e1cb507f876f324bb5c53bf4e60775712f1fdddbffe94fd249e6e74099e045e src/main/res/drawable-nodpi/coming_in_rating_tmdb.png +2ad511c7a101a7f2f8810434911df8c13cc126fa691272242037237172600bc3 request-marks/upright.svg +6ff8a05501bc1c15b96c0130271c41baaa2ddb74f64916f58b9e7f918931d789 src/main/res/drawable-nodpi/coming_in_rating_upright.png diff --git a/wolfskin-design/request-marks/fresh.svg b/wolfskin-design/request-marks/fresh.svg new file mode 100644 index 00000000..977253a9 --- /dev/null +++ b/wolfskin-design/request-marks/fresh.svg @@ -0,0 +1,20 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/wolfskin-design/request-marks/imdb.svg b/wolfskin-design/request-marks/imdb.svg new file mode 100644 index 00000000..b2a908bc --- /dev/null +++ b/wolfskin-design/request-marks/imdb.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/wolfskin-design/request-marks/rotten.svg b/wolfskin-design/request-marks/rotten.svg new file mode 100644 index 00000000..04bef05b --- /dev/null +++ b/wolfskin-design/request-marks/rotten.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/wolfskin-design/request-marks/spilled.svg b/wolfskin-design/request-marks/spilled.svg new file mode 100644 index 00000000..af36063b --- /dev/null +++ b/wolfskin-design/request-marks/spilled.svg @@ -0,0 +1,46 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wolfskin-design/request-marks/tmdb.svg b/wolfskin-design/request-marks/tmdb.svg new file mode 100644 index 00000000..62a66055 --- /dev/null +++ b/wolfskin-design/request-marks/tmdb.svg @@ -0,0 +1 @@ +Asset 4 \ No newline at end of file diff --git a/wolfskin-design/request-marks/upright.svg b/wolfskin-design/request-marks/upright.svg new file mode 100644 index 00000000..bf1cc85a --- /dev/null +++ b/wolfskin-design/request-marks/upright.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/wolfskin-design/shell-tokens.json b/wolfskin-design/shell-tokens.json index 3c605746..8bd41896 100644 --- a/wolfskin-design/shell-tokens.json +++ b/wolfskin-design/shell-tokens.json @@ -1,4 +1,48 @@ { + "requests": { + "sheetPaddingY": { "$type": "dimension", "px": 80, "dp": 40, "source": "index.html:227" }, + "sheetPaddingX": { "$type": "dimension", "px": 64, "dp": 32, "source": "index.html:227" }, + "sheetGap": { "$type": "dimension", "px": 18, "dp": 9, "source": "index.html:227" }, + "sheetTitleLineHeight": { "$type": "dimension", "px": 56, "dp": 28, "source": "index.html:228; locked st-083/st-086: content begins at 154px after 80px padding and 18px gap" }, + "sheetTitleBaseline": { "$type": "dimension", "px": 44, "dp": 22, "source": "locked st-083: modal ink-bottom 123px, native baseline boundary 124px minus 80px padding" }, + "titleLineHeight": { "$type": "dimension", "px": 39, "dp": 19.5, "source": "index.html:250; 30px font with 1.3 line box" }, + "titleBaseline": { "$type": "dimension", "px": 31, "dp": 15.5, "source": "locked st-082/st-086: ink-bottom 45px, 15px text-block inset, baseline boundary 1px below ink" }, + "factsLineHeight": { "$type": "dimension", "px": 31.2, "dp": 15.6, "source": "index.html:251; 24px font with 1.3 line box" }, + "factsBaseline": { "$type": "dimension", "px": 24, "dp": 12, "source": "locked st-082/st-086: ink-bottom 77px, 15px block inset, 39px title box and 1px baseline boundary" }, + "stageLineHeight": { "$type": "dimension", "px": 39.6, "dp": 19.8, "source": "index.html:251-252; inline-flex stage within the 28px/1.3 parent strut" }, + "stageLabelLineHeight": { "$type": "dimension", "px": 33.8, "dp": 16.9, "source": "index.html:220; 26px font with 1.3 leading" }, + "stageBaseline": { "$type": "dimension", "px": 24, "dp": 12, "source": "locked st-082/st-086: ink-bottom 112px, 15px block inset, 39px title, 31.2px facts, 4px margin and 1px baseline boundary" }, + "emptyMargin": { "$type": "dimension", "px": 20, "dp": 10, "source": "index.html:1905" }, + "rowPaddingY": { "$type": "dimension", "px": 14, "dp": 7, "source": "index.html:247" }, + "rowPaddingX": { "$type": "dimension", "px": 18, "dp": 9, "source": "index.html:247" }, + "rowGap": { "$type": "dimension", "px": 24, "dp": 12, "source": "index.html:247" }, + "listGap": { "$type": "dimension", "px": 6, "dp": 3, "source": "index.html:255" }, + "stageMargin": { "$type": "dimension", "px": 4, "dp": 2, "source": "index.html:252" }, + "stageGlyph": { "$type": "dimension", "px": 28, "dp": 14, "source": "index.html:221" }, + "stageGap": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:220" }, + "readinessGapX": { "$type": "dimension", "px": 22, "dp": 11, "source": "index.html:312" }, + "readinessGapY": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:312" }, + "readinessMargin": { "$type": "dimension", "px": 8, "dp": 4, "source": "index.html:312" }, + "readinessGlyph": { "$type": "dimension", "px": 26, "dp": 13, "source": "index.html:314" }, + "chevron": { "$type": "dimension", "px": 30, "dp": 15, "source": "index.html:317" }, + "chevronAlpha": { "$type": "number", "$value": 0.6, "source": "index.html:317" }, + "receiptPaddingY": { "$type": "dimension", "px": 28, "dp": 14, "source": "index.html:243" }, + "receiptPaddingX": { "$type": "dimension", "px": 30, "dp": 15, "source": "index.html:243" }, + "receiptGap": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:243" }, + "receiptGlyph": { "$type": "dimension", "px": 36, "dp": 18, "source": "index.html:245" }, + "buttonMargin": { "$type": "dimension", "px": 12, "dp": 6, "source": "index.html:241" }, + "skeletonTitleWidth": { "$type": "dimension", "px": 360, "dp": 180, "source": "index.html:1902" }, + "skeletonTitleHeight": { "$type": "dimension", "px": 34, "dp": 17, "source": "index.html:1902" }, + "skeletonSubtitleWidth": { "$type": "dimension", "px": 240, "dp": 120, "source": "index.html:1902" }, + "skeletonSubtitleHeight": { "$type": "dimension", "px": 26, "dp": 13, "source": "index.html:1902" }, + "skeletonGap": { "$type": "dimension", "px": 12, "dp": 6, "source": "index.html:1902" }, + "ratingsGap": { "$type": "dimension", "px": 20, "dp": 10, "source": "index.html:156" }, + "ratingsEntryGap": { "$type": "dimension", "px": 8, "dp": 4, "source": "index.html:157" }, + "shadowBlur": { "$type": "dimension", "px": 80, "dp": 40, "source": "index.html:227" }, + "shadowOffset": { "$type": "dimension", "px": -30, "dp": -15, "source": "index.html:227" }, + "shadowColor": { "$type": "color", "$value": "#00000080", "source": "index.html:227" }, + "ratingsWordmarkRatio": { "$type": "number", "$value": 1.8, "source": "index.html:160" } + }, "shell": { "navLabelFade": { "$type": "duration", diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInFixture.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInFixture.kt new file mode 100644 index 00000000..50608f0b --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInFixture.kt @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import org.json.JSONObject + +/** Debug assets and native screenshots consume the same projection of the frozen prototype. */ +fun readComingInFixture(json: String, poster: (String) -> Any?): List { + val items = JSONObject(json).getJSONArray("items") + return List(items.length()) { index -> + val item = items.getJSONObject(index) + val scores = item.getJSONArray("ratings") + ComingInRequest( + id = item.getString("id"), mediaType = item.getString("mediaType"), tmdbId = item.getLong("tmdbId"), + title = item.getString("title"), requesterKey = item.getString("requesterKey"), requesterName = item.getString("requesterName"), + actorKey = item.getString("actorKey"), actorName = item.getString("actorName"), stage = ComingInStage.valueOf(item.getString("stage")), + poster = poster(item.getString("poster")), + ratings = List(scores.length()) { scoreIndex -> + val score = scores.getJSONObject(scoreIndex) + ComingInRating(score.getString("mark"), score.getDouble("score"), score.getBoolean("percentage")) + }, + ) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInLineBox.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInLineBox.kt new file mode 100644 index 00000000..e7ae86a9 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInLineBox.kt @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Dp + +/** A CSS line box has its own height and baseline, independent of native leading rounding. */ +internal fun Modifier.comingInLineBox(height: Dp, baseline: Dp): Modifier = layout { measurable, constraints -> + val text = measurable.measure(constraints.copy(minHeight = 0)) + val baselineOffset = baseline.roundToPx() - text[FirstBaseline] + layout(text.width, height.roundToPx()) { + text.placeRelative(0, baselineOffset) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInRatings.kt new file mode 100644 index 00000000..3f2b0da1 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInRatings.kt @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.foundation.background +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.tv.material3.Text +import java.util.Locale +import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T + +/** Percentages remain whole; ten-point scores use the television's locale. */ +fun formatComingInRating(rating: ComingInRating, locale: Locale = Locale.getDefault()): String = + if (rating.percentage) "${rating.score.toInt()}%" else String.format(locale, "%.1f", rating.score) + +/** This row always displays the full supplier set rather than the card plate's shortened prefix. */ +@Composable +fun ComingInRatings(ratings: List, color: Color) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.ratingsGap)) { + ratings.forEach { rating -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.ratingsEntryGap)) { + val mark = when (rating.mark) { + "fresh" -> R.drawable.coming_in_rating_fresh + "rotten" -> R.drawable.coming_in_rating_rotten + "upright" -> R.drawable.coming_in_rating_upright + "spilled" -> R.drawable.coming_in_rating_spilled + "imdb" -> R.drawable.coming_in_rating_imdb + else -> R.drawable.coming_in_rating_tmdb + } + val wide = rating.mark == "imdb" || rating.mark == "tmdb" + Image(painterResource(mark), contentDescription = rating.mark, + modifier = Modifier.height(T.readinessGlyph).width(T.readinessGlyph * if (wide) T.ratingsWordmarkRatio else 1f) + .clip(ratingMarkShape).background(WolfskinTokens.Color.surface3), + contentScale = ContentScale.Fit) + Text(formatComingInRating(rating), style = ComingInTypography.rating, color = color) + } + } + } +} + +private val ratingMarkShape = RoundedCornerShape(WolfskinTokens.Radius.thumb) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt new file mode 100644 index 00000000..46816d69 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.tv.material3.Text +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter +import androidx.compose.runtime.mutableStateOf +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T + +/** The overlay intercepts pointer input as well as the shell's virtual D-pad focus. */ +@Composable +fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { + val state = shell.requests + val data = state.snapshot + LaunchedEffect(state.focusedId, data.signedIn, data.status) { + state.awaitFocusedRatings() + } + Box(modifier.fillMaxSize().background(C.sheetScrim).clickable { shell.closeRequests() }.testTag("coming-in-scrim")) + Column(modifier.fillMaxHeight().width(L.sheetWidth) + .offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth) + .drawWithCache { + val panel = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { + color = C.surface.toArgb() + setShadowLayer(T.shadowBlur.toPx(), T.shadowOffset.toPx(), 0f, T.shadowColor.toArgb()) + } + onDrawBehind { + drawIntoCanvas { it.nativeCanvas.drawRect(0f, 0f, size.width, size.height, panel) } + } + }.pointerInput(Unit) { + awaitPointerEventScope { + while (true) awaitPointerEvent(PointerEventPass.Final).changes.forEach { it.consume() } + } + } + .padding(horizontal = T.sheetPaddingX, vertical = T.sheetPaddingY) + .testTag("coming-in"), verticalArrangement = Arrangement.spacedBy(T.sheetGap)) { + Text("Coming in", Modifier.comingInLineBox(T.sheetTitleLineHeight, T.sheetTitleBaseline), style = ComingInTypography.title, color = C.fg) + when { + data.status == ComingInStatus.Loading && data.items.isEmpty() -> { + LazyColumn(verticalArrangement = Arrangement.spacedBy(T.listGap)) { + items(3, key = { "request-skeleton-$it" }, contentType = { "skeleton" }) { ComingInSkeleton() } + } + SheetButton("Close") { shell.closeRequests() } + } + data.status == ComingInStatus.Unavailable && data.items.isEmpty() -> { + Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)) + .padding(horizontal = T.receiptPaddingX, vertical = T.receiptPaddingY), + verticalArrangement = Arrangement.spacedBy(T.receiptGap)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowPaddingY)) { + Glyph("alert", C.warn, Modifier.size(T.receiptGlyph)) + Text("Requests unavailable", style = ComingInTypography.stateTitle, color = C.warn) + } + Text("Conductarr didn’t answer.", style = ComingInTypography.body, color = C.muted) + } + SheetButton("Try again", primary = true) { shell.retryRequests() } + } + data.items.isEmpty() -> Text("Nothing coming in.", Modifier.padding(top = T.emptyMargin), style = ComingInTypography.body, color = C.muted) + else -> { + if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn) + val start = state.windowStart + val visibleRows = remember(data, start, state.windowSize) { + data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size)) + } + LazyColumn(Modifier.clipToBounds(), verticalArrangement = Arrangement.spacedBy(T.listGap)) { + itemsIndexed(visibleRows, + key = { _, item -> item.id }, contentType = { _, _ -> "request" }) { _, item -> + ComingInRow(item, shell.person, item.id == state.focusedId, state.expanded && item.id == state.focusedId, + state.ratingsVisible && data.signedIn && item.id == state.focusedId, + onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() }) + } + } + } + } + } +} + +@Composable +private fun SheetButton(label: String, primary: Boolean = false, onClick: () -> Unit) { + Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, true, primary, onClick) } +} + +@Composable +private fun ComingInSkeleton() { + Row(Modifier.fillMaxWidth().padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) { + Box(Modifier.size(L.thumbWidth, L.thumbHeight).background(C.surface3, RoundedCornerShape(WolfskinTokens.Radius.thumb))) + Column(verticalArrangement = Arrangement.spacedBy(T.skeletonGap)) { + Box(Modifier.size(T.skeletonTitleWidth, T.skeletonTitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Shell.skeletonRadius))) + Box(Modifier.size(T.skeletonSubtitleWidth, T.skeletonSubtitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) + } + } +} + +/** Thumbnail slots do not resize when the text or the readiness block grows. */ +@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class) +@Composable +fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, expanded: Boolean, + showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit) { + val foreground = if (focused) C.onFocus else C.fg + val secondary = if (focused) C.onFocus else C.muted + val context = LocalContext.current + val request = remember(item.poster, context) { + item.poster?.let { WolfskinImages.request(context, it, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt(), true) } + } + val posterReady = remember(request) { mutableStateOf(false) } + Row(Modifier.fillMaxWidth().focusScale(focused, WolfskinTokens.Focus.scaleRow) + .background(if (focused) C.fg else Color.Transparent, RoundedCornerShape(WolfskinTokens.Radius.panel)) + .pointerInput(item.id) { + awaitPointerEventScope { + while (true) if (awaitPointerEvent().type == PointerEventType.Enter) onFocus() + } + }.clickable(onClick = onClick) + .padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY).testTag("coming-row-${item.id}"), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) { + AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null, + modifier = Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb)) + .background(if (posterReady.value) Color.Transparent else C.surface3), + onState = { posterReady.value = it is AsyncImagePainter.State.Success }, + contentScale = androidx.compose.ui.layout.ContentScale.Crop) + Column(Modifier.weight(1f)) { + Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline), + style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("for ${item.recipient(viewer)} · by ${item.actor(viewer)}", Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline), + style = ComingInTypography.caption, color = secondary) + FlowRow(Modifier.padding(top = T.stageMargin).heightIn(min = T.stageLineHeight), horizontalArrangement = Arrangement.spacedBy(T.readinessGlyph)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.stageGap)) { + Glyph(item.stage.glyph, secondary, Modifier.size(T.stageGlyph)) + Text(item.stage.label, Modifier.comingInLineBox(T.stageLabelLineHeight, T.stageBaseline), style = ComingInTypography.label, color = secondary) + } + if (showRatings) ComingInRatings(item.ratings, foreground) + } + if (expanded) FlowRow(Modifier.padding(top = T.readinessMargin), + horizontalArrangement = Arrangement.spacedBy(T.readinessGapX), verticalArrangement = Arrangement.spacedBy(T.readinessGapY)) { + item.stage.readiness.forEachIndexed { index, word -> + Row { + val glyph = when (word) { "Done" -> "check"; "Failed" -> "alert"; "Not yet", "Unknown" -> null; else -> "clock" } + if (glyph != null) Glyph(glyph, secondary, Modifier.size(T.readinessGlyph).alignBy { it.measuredHeight }) + Text("${READINESS_NAMES[index]} · $word", Modifier.alignByBaseline(), style = ComingInTypography.readiness, color = secondary) + } + } + } + } + if (focused && !expanded) Glyph("right", foreground.copy(alpha = T.chevronAlpha), Modifier.size(T.chevron)) + } +} + +private val READINESS_NAMES = listOf("Request", "Download", "Library", "Playback") diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt new file mode 100644 index 00000000..86d6c5c3 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +import kotlinx.coroutines.delay +/** Household acquisition stages, ordered independently from their display wording. */ +enum class ComingInStage(val label: String, val rank: Int, val glyph: String, val readiness: List) { + Failed("Failed", 0, "alert", listOf("Done", "Failed", "Not yet", "Not yet")), + Downloading("Downloading", 1, "download", listOf("Done", "Downloading", "Not yet", "Not yet")), + AlmostReady("Almost ready", 1, "download", listOf("Done", "Done", "Being added", "Not yet")), + Checking("Checking", 2, "clock", listOf("Checking", "Unknown", "Not yet", "Not yet")), + Searching("Searching", 2, "clock", listOf("Done", "Searching", "Not yet", "Not yet")), + Waiting("Waiting", 3, "clock", listOf("Waiting", "Not yet", "Not yet", "Not yet")), + Ready("Ready", 4, "check", listOf("Done", "Done", "Done", "Not checked yet")), +} + +/** Ratings have already passed the supplier's observed-score and vote checks. */ +data class ComingInRating(val mark: String, val score: Double, val percentage: Boolean = false) + +/** Requests carry authenticated actor evidence, never the current viewer as a substitute. */ +data class ComingInRequest( + val id: String, + val mediaType: String, + val tmdbId: Long, + val title: String, + val requesterKey: String, + val requesterName: String, + val actorKey: String?, + val actorName: String?, + val stage: ComingInStage, + val poster: Any? = null, + val ratings: List = emptyList(), +) { + val ratingKey = "$mediaType:$tmdbId" + fun recipient(viewer: ShellPerson): String = if (requesterKey == viewer.key) "you" else requesterName + fun actor(viewer: ShellPerson): String = when { + actorKey == "shared" -> "Shared" + actorKey != null && actorKey == viewer.key -> "you" + else -> actorName ?: "Unknown" + } +} + +enum class ComingInStatus { Ready, Loading, Unavailable } + +/** The provider retains saved rows when refresh fails; an unavailable empty result is not empty success. */ +data class ComingInSnapshot( + val items: List = emptyList(), + val status: ComingInStatus = ComingInStatus.Unavailable, + val partial: Boolean = false, + val signedIn: Boolean = false, +) { + val ordered = items.sortedBy { it.stage.rank } + val openCount: Int get() = items.count { it.stage != ComingInStage.Ready } +} + +/** A bounded window and stable request id preserve focus across refreshes and title round trips. */ +class ComingInState { + var snapshot by mutableStateOf(ComingInSnapshot()) + private set + var focusedId by mutableStateOf(null) + private set + var expanded by mutableStateOf(false) + private set + var ratingsVisible by mutableStateOf(false) + private set + private val cachedRatings = mutableSetOf() + val index: Int get() = snapshot.ordered.indexOfFirst { it.id == focusedId }.coerceAtLeast(0) + val focused: ComingInRequest? get() = snapshot.ordered.getOrNull(index) + val windowSize: Int get() = if (expanded) 4 else 5 + val windowStart: Int get() = (index - 2).coerceIn(0, (snapshot.ordered.size - windowSize).coerceAtLeast(0)) + + fun update(value: ComingInSnapshot) { + snapshot = value + if (!value.signedIn) cachedRatings.clear() + if (value.ordered.none { it.id == focusedId }) focus(value.ordered.firstOrNull()?.id) + ratingsVisible = value.signedIn && focused?.ratingKey in cachedRatings + } + + fun focus(id: String?) { + focusedId = id + expanded = false + ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings + } + + /** Coroutine cancellation and the captured request id prevent stale focus reads from painting. */ + suspend fun awaitFocusedRatings() { + val item = focused ?: return + if (!snapshot.signedIn || snapshot.status != ComingInStatus.Ready || item.ratings.isEmpty() || ratingsVisible) return + delay(WolfskinTokens.Motion.ratingDwell.toLong()) + if (focusedId != item.id || !snapshot.signedIn || snapshot.status != ComingInStatus.Ready) return + cachedRatings.add(item.ratingKey) + ratingsVisible = true + } + + /** Return true only when a row requests a title; every direction remains trapped in the sheet. */ + fun key(key: ShellKey): Boolean { + when (key) { + ShellKey.Up -> focus(snapshot.ordered.getOrNull((index - 1).coerceAtLeast(0))?.id) + ShellKey.Down -> focus(snapshot.ordered.getOrNull((index + 1).coerceAtMost(snapshot.ordered.lastIndex))?.id) + ShellKey.Right -> if (focused != null) expanded = true + ShellKey.Left -> expanded = false + ShellKey.Ok -> return focused != null + else -> Unit + } + return false + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInTypography.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInTypography.kt new file mode 100644 index 00000000..a8f3b3e0 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInTypography.kt @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.text.font.FontWeight + +/** CSS line boxes retain their leading even on single lines; trimming changes sheet geometry. */ +internal object ComingInTypography { + private val lineBox = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + val title = WolfskinTokens.Type.sheetTitle.copy(lineHeightStyle = lineBox) + val listTitle = WolfskinTokens.Type.listTitle.copy(lineHeightStyle = lineBox) + val body = WolfskinTokens.Type.body.copy(lineHeightStyle = lineBox) + val label = WolfskinTokens.Type.label.copy(lineHeightStyle = lineBox) + val caption = WolfskinTokens.Type.caption.copy(lineHeightStyle = lineBox) + val stateTitle = WolfskinTokens.Type.stateTitle.copy(lineHeightStyle = lineBox, fontWeight = FontWeight.Bold) + val readiness = caption.copy(fontWeight = FontWeight.Bold) + val rating = WolfskinTokens.Type.label.copy(fontFeatureSettings = "tnum") +} 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 41b95bb6..d9d68beb 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 @@ -21,6 +21,9 @@ fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) { "advanced" -> R.drawable.wolfskin_sort "alert" -> R.drawable.wolfskin_alert "wifi" -> R.drawable.wolfskin_wifi + "clock" -> R.drawable.wolfskin_clock + "check" -> R.drawable.wolfskin_check + "download" -> R.drawable.wolfskin_download "film" -> R.drawable.wolfskin_film "eye" -> R.drawable.wolfskin_eye "down" -> R.drawable.wolfskin_down diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt index 91bf3bb3..48a8220b 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt @@ -52,7 +52,7 @@ val SETTINGS_GROUPS = listOf( SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"), ) -/** Requests is intentionally a no-data Wolfskin entry until the Requests slice. */ +/** The household request count shares the sheet's snapshot and excludes Ready. */ @Composable fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests: Int? = null) { val list = state.view.listState @@ -70,14 +70,14 @@ fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests: item(key = "settings-header") { Row(Modifier.height(L.row_lhead).padding(start = L.safeSide, top = S.settingsHeaderTop), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) { - Avatar(state.person, S.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader) + Avatar(state.person, S.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted) Text("Settings", Modifier.alignByBaseline(), style = WolfskinTokens.Type.screenTitle, color = C.fg) Text("Requests and Wholphin settings on this TV · ${state.person.name}", Modifier.alignByBaseline(), style = WolfskinTokens.Type.nav.copy(fontWeight = FontWeight.Normal), color = C.muted) } } itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group -> Box(Modifier.height(L.row_srow)) { - SettingsRow(group, !state.navFocused && index == state.settingsRow, + SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow, if (group.key == "requests") openRequests else null, Modifier.offset(L.safeSide, S.settingsRowTop)) { state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt index 81d17cd2..1cd0bcf2 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -16,6 +16,8 @@ sealed interface ShellAction { data class HandOff(val group: String) : ShellAction data object Pop : ShellAction data object RetryPeople : ShellAction + data object RetryRequests : ShellAction + data class RequestTitle(val request: ComingInRequest) : ShellAction } /** D-pad inputs are shared by Android events and deterministic JVM replays. */ @@ -57,6 +59,8 @@ class ShellState( var peopleStatus by mutableStateOf(PeopleStatus.Ready) var recoveryIndex by mutableStateOf(0) var requestsVisible by mutableStateOf(false) + val requests = ComingInState() + private var requestReturnRoute: String? = null var handOff by mutableStateOf(null) var settingsRow by mutableStateOf(0) private var rootRoute by mutableStateOf("home") @@ -78,6 +82,10 @@ class ShellState( route = destination if (destination in tabs) rootRoute = destination handOff = null + if (requestReturnRoute == destination) { + requestsVisible = true + requestReturnRoute = null + } } /** Pointer navigation uses the same transitions as D-pad selection. */ @@ -102,7 +110,12 @@ class ShellState( armedCard = null } if (requestsVisible) { - if (key == ShellKey.Back || key == ShellKey.Ok) requestsVisible = false + when { + key == ShellKey.Back -> closeRequests() + key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Unavailable && requests.snapshot.items.isEmpty() -> retryRequests() + key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Loading && requests.snapshot.items.isEmpty() -> closeRequests() + requests.key(key) -> activateRequest() + } return true } if (pickerVisible) pickerKey(key) else if (key == ShellKey.Back) back() else if (navFocused) navKey(key) else contentKey(key) @@ -151,6 +164,8 @@ class ShellState( navFocused = false navIndex = 0 rootRoute = "home" + requestReturnRoute = null + requestsVisible = false views[person.key to route] = RememberedView() onAction(ShellAction.Person(selected)) } @@ -189,6 +204,7 @@ class ShellState( gearFocused = false } else { val target = tabs[navIndex] + requestReturnRoute = null if (target == route) views[person.key to target] = RememberedView() route = target rootRoute = target @@ -197,7 +213,7 @@ class ShellState( } } else if (route == "settings") { val group = SETTINGS_GROUPS[settingsRow].key - if (group == "requests") requestsVisible = true else { + if (group == "requests") openRequests() else { handOff = group onAction(ShellAction.HandOff(group)) } @@ -205,8 +221,34 @@ class ShellState( else contentFocus?.key(ShellKey.Ok) } + /** Requested buttons may name an existing request without changing its authenticated actor. */ + fun openRequests(requestId: String? = null) { + requests.focus(requestId ?: requests.snapshot.ordered.firstOrNull()?.id) + requestsVisible = true + navFocused = false + } + + fun closeRequests() { requestsVisible = false } + fun retryRequests() { onAction(ShellAction.RetryRequests) } + + fun activateRequest() { + val request = requests.focused ?: return + requestReturnRoute = route + requestsVisible = false + onAction(ShellAction.RequestTitle(request)) + } + /** Back never discards a root view, including the card below a focused nav. */ fun back() { + if (requestsVisible) { closeRequests(); return } + val returnRoute = requestReturnRoute + if (returnRoute != null && route != returnRoute) { + route = returnRoute + requestsVisible = true + requestReturnRoute = null + onAction(ShellAction.Pop) + return + } if (!pickerVisible && !navFocused && contentFocus?.key(ShellKey.Back) == true) return if (route == "settings") { route = pickerReturnRoute diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt index 7132111f..4b013cfb 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt @@ -82,20 +82,9 @@ fun ShellSurface( if (state.pickerVisible) { WhosWatching(state) } else { - if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) + if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests ?: state.requests.snapshot.openCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2) - if (state.requestsVisible) { - Box(Modifier.fillMaxSize().background(C.sheetScrim)) { - Column(Modifier.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth) - .width(L.sheetWidth).fillMaxSize().background(C.surface).padding(L.safeSide), - verticalArrangement = Arrangement.spacedBy(L.buttonGap)) { - Text("Requests", color = C.fg, style = WolfskinTokens.Type.sheetTitle) - Text("Wolfskin screen", color = C.accentText, style = WolfskinTokens.Type.label) - Text("No request data yet. Conductarr sign-in is not available on this TV.", color = C.muted, style = WolfskinTokens.Type.body) - PickerButton("Close", true, true) { state.requestsVisible = false } - } - } - } + if (state.requestsVisible) ComingInSheet(state) } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt index 1cf6ac61..d89254f9 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt @@ -3,6 +3,7 @@ package org.justinvisser.wolfskin.design import android.content.Context import coil3.ImageLoader import coil3.disk.DiskCache +import coil3.intercept.Interceptor import coil3.disk.directory import coil3.imageLoader import coil3.request.crossfade @@ -10,6 +11,8 @@ import coil3.memory.MemoryCache import coil3.request.ImageRequest import coil3.request.allowRgb565 import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit /** Wolfskin screens share one bounded loader; hidden-switch screens retain the upstream loader. */ object WolfskinImages { @@ -17,6 +20,7 @@ object WolfskinImages { private var loader: ImageLoader? = null private const val MEBIBYTE = 1024L * 1024L private const val IMAGE_READS = 4 + private val readPermits = Semaphore(IMAGE_READS) @Synchronized fun loader(context: Context): ImageLoader { @@ -28,12 +32,13 @@ object WolfskinImages { loader = owner.imageLoader.newBuilder() .memoryCache { MemoryCache.Builder().maxSizeBytes((WolfskinTokens.Image.memoryCacheMB * MEBIBYTE).toLong()).build() } .diskCache { DiskCache.Builder().directory(owner.cacheDir.resolve("wolfskin-images")).maxSizeBytes((WolfskinTokens.Image.diskCacheMB * MEBIBYTE).toLong()).build() } + .components { add(Interceptor { chain -> readPermits.withPermit { chain.proceed() } }) } .fetcherCoroutineContext(reads).decoderCoroutineContext(reads).crossfade(false).build() } return loader!! } - fun request(context: Context, source: String, width: Int, height: Int, poster: Boolean): ImageRequest = + fun request(context: Context, source: Any, width: Int, height: Int, poster: Boolean): ImageRequest = ImageRequest.Builder(context).data(source).size(width, height).allowRgb565(poster).build() fun prefetch(context: Context, row: HomeRow) { 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 18bdac8f..9162e7c5 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 @@ -229,6 +229,50 @@ object WolfskinTokens { const val requestsPollSeconds = 30.0f const val comingInVisibleRows = 5.0f } + object Requests { + val sheetPaddingY = 40.0.dp + val sheetPaddingX = 32.0.dp + val sheetGap = 9.0.dp + val sheetTitleLineHeight = 28.0.dp + val sheetTitleBaseline = 22.0.dp + val titleLineHeight = 19.5.dp + val titleBaseline = 15.5.dp + val factsLineHeight = 15.6.dp + val factsBaseline = 12.0.dp + val stageLineHeight = 19.8.dp + val stageLabelLineHeight = 16.9.dp + val stageBaseline = 12.0.dp + val emptyMargin = 10.0.dp + val rowPaddingY = 7.0.dp + val rowPaddingX = 9.0.dp + val rowGap = 12.0.dp + val listGap = 3.0.dp + val stageMargin = 2.0.dp + val stageGlyph = 14.0.dp + val stageGap = 5.0.dp + val readinessGapX = 11.0.dp + val readinessGapY = 5.0.dp + val readinessMargin = 4.0.dp + val readinessGlyph = 13.0.dp + val chevron = 15.0.dp + const val chevronAlpha = 0.6f + val receiptPaddingY = 14.0.dp + val receiptPaddingX = 15.0.dp + val receiptGap = 5.0.dp + val receiptGlyph = 18.0.dp + val buttonMargin = 6.0.dp + val skeletonTitleWidth = 180.0.dp + val skeletonTitleHeight = 17.0.dp + val skeletonSubtitleWidth = 120.0.dp + val skeletonSubtitleHeight = 13.0.dp + val skeletonGap = 6.0.dp + val ratingsGap = 10.0.dp + val ratingsEntryGap = 4.0.dp + val shadowBlur = 40.0.dp + val shadowOffset = -15.0.dp + val shadowColor = ComposeColor(0x80000000) + const val ratingsWordmarkRatio = 1.8f + } object Shell { const val navLabelFade = 200 val zero = 0.0.dp diff --git a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_fresh.png b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_fresh.png new file mode 100644 index 00000000..ef0a98a6 Binary files /dev/null and b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_fresh.png differ diff --git a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_imdb.png b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_imdb.png new file mode 100644 index 00000000..bb7becf5 Binary files /dev/null and b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_imdb.png differ diff --git a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_rotten.png b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_rotten.png new file mode 100644 index 00000000..678af063 Binary files /dev/null and b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_rotten.png differ diff --git a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_spilled.png b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_spilled.png new file mode 100644 index 00000000..988d6554 Binary files /dev/null and b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_spilled.png differ diff --git a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_tmdb.png b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_tmdb.png new file mode 100644 index 00000000..48a14150 Binary files /dev/null and b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_tmdb.png differ diff --git a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_upright.png b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_upright.png new file mode 100644 index 00000000..2a2bfa89 Binary files /dev/null and b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_upright.png differ diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_check.xml b/wolfskin-design/src/main/res/drawable/wolfskin_check.xml new file mode 100644 index 00000000..3509d99c --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_check.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_clock.xml b/wolfskin-design/src/main/res/drawable/wolfskin_clock.xml new file mode 100644 index 00000000..8bbd323d --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_clock.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_download.xml b/wolfskin-design/src/main/res/drawable/wolfskin_download.xml new file mode 100644 index 00000000..4df16c9e --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_download.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ComingInRatingsGateTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ComingInRatingsGateTest.kt new file mode 100644 index 00000000..c79d10db --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ComingInRatingsGateTest.kt @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import java.util.Locale +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Test + +/** The fake coroutine clock proves the production dwell/cache decision without a Compose scheduler. */ +@OptIn(ExperimentalCoroutinesApi::class) +class ComingInRatingsGateTest { + private val first = ComingInRequest("first", "movie", 1, "First", "justin", "Justin", "justin", "Justin", ComingInStage.Waiting, + ratings = listOf(ComingInRating("imdb", 6.0))) + private val second = first.copy(id = "second", tmdbId = 2, title = "Second", ratings = listOf(ComingInRating("tmdb", 7.2))) + + @Test fun uncachedDwellCancelsOnLeaveAndReentryStartsAFreshRead() = runTest { + val state = ComingInState() + state.update(ComingInSnapshot(listOf(first, second), ComingInStatus.Ready, signedIn = true)) + val abandoned = launch { state.awaitFocusedRatings() } + runCurrent() + advanceTimeBy(100) + assertFalse(state.ratingsVisible) + abandoned.cancel() + state.focus(second.id) + val next = launch { state.awaitFocusedRatings() } + runCurrent() + advanceTimeBy(349) + runCurrent() + assertFalse(state.ratingsVisible) + advanceTimeBy(1) + runCurrent() + assertTrue(state.ratingsVisible) + next.join() + state.focus(first.id) + assertFalse(state.ratingsVisible) + launch { state.awaitFocusedRatings() } + runCurrent() + advanceTimeBy(350) + runCurrent() + assertTrue("Returning to the abandoned uncached title must complete a new dwell", state.ratingsVisible) + state.focus(second.id) + assertTrue("A previously read title must be visible on the focus transition itself", state.ratingsVisible) + } + + @Test fun signOutClearsCachedVisibilityAndSignInUsesFreshRatingAndLocale() = runTest { + val state = ComingInState() + val snapshot = ComingInSnapshot(listOf(first), ComingInStatus.Ready, signedIn = true) + state.update(snapshot) + launch { state.awaitFocusedRatings() } + runCurrent(); advanceTimeBy(350); runCurrent() + assertTrue(state.ratingsVisible) + state.update(snapshot.copy(signedIn = false)) + assertFalse(state.ratingsVisible) + val refreshed = first.copy(ratings = listOf(ComingInRating("imdb", 7.6))) + state.update(snapshot.copy(items = listOf(refreshed))) + assertFalse("Sign-out invalidates the previous session's rating gate", state.ratingsVisible) + launch { state.awaitFocusedRatings() } + runCurrent(); advanceTimeBy(350); runCurrent() + assertTrue(state.ratingsVisible) + val rating = state.focused!!.ratings.single() + assertEquals("7,6", formatComingInRating(rating, Locale.forLanguageTag("nl-NL"))) + assertEquals("7.6", formatComingInRating(rating, Locale.ENGLISH)) + assertEquals("87%", formatComingInRating(ComingInRating("fresh", 87.9, true), Locale.forLanguageTag("nl-NL"))) + } + + @Test fun savedOfflineRowsOnlyShowPreviouslyReadRatingsAcrossHouseholdRequests() = runTest { + val state = ComingInState() + val sharedCopy = first.copy(id = "other-request", requesterKey = "shared", requesterName = "Shared") + val online = ComingInSnapshot(listOf(first, second, sharedCopy), ComingInStatus.Ready, signedIn = true) + state.update(online) + launch { state.awaitFocusedRatings() } + runCurrent(); advanceTimeBy(350); runCurrent() + state.update(online.copy(status = ComingInStatus.Unavailable)) + state.focus(second.id) + state.awaitFocusedRatings() + assertFalse("An unread title cannot acquire ratings while offline", state.ratingsVisible) + state.focus(sharedCopy.id) + assertTrue("The title cache is shared across request rows for the same media type and TMDB id", state.ratingsVisible) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsCaptureTest.kt new file mode 100644 index 00000000..7f114a64 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsCaptureTest.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Every approved Requests state uses the actual shell overlay at native television density. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class RequestsCaptureTest { + @get:Rule val rule = createAndroidComposeRule() + + @Before fun fullscreen() { + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + } + + private fun capture(name: String, snapshot: ComingInSnapshot, person: String = "justin", expanded: Boolean = false) { + val state = ShellState(fixturePeople(), person) + state.pickerVisible = false + state.enterDestination("settings") + state.requests.update(snapshot) + state.openRequests() + if (expanded) { state.requests.key(ShellKey.Down); state.requests.key(ShellKey.Right) } + rule.setContent { + WolfskinTheme { ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) } + } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + val directory = File(System.getProperty("wolfskin.captureDirectory")) + directory.mkdirs() + rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath) + } + + @Test fun st081_empty() = capture("st-081", ComingInSnapshot(status = ComingInStatus.Ready, signedIn = true)) + @Test fun st082_stages() = capture("st-082", requestsFixture()) + @Test fun st083_unavailable() = capture("st-083", ComingInSnapshot()) + @Test fun st084_loading() = capture("st-084", ComingInSnapshot(status = ComingInStatus.Loading)) + @Test fun st085_partial() = capture("st-085", requestsFixture().copy(items = requestsFixture().items.take(3), partial = true)) + @Test fun st086_readiness() = capture("st-086", requestsFixture(), expanded = true) + @Test fun st088_sharedActor() = capture("st-088", requestsFixture(shared = true), person = "shared") + @Test fun st111_focusedRatings() = capture("st-111", requestsFixture(ratings = true)) +} + +/** Shared actor and stage fixtures are projections, never network calls or synthetic UI redraws. */ +fun requestsFixture(shared: Boolean = false, ratings: Boolean = false): ComingInSnapshot { + val source = RequestsCaptureTest::class.java.getResourceAsStream("/wolfskin-requests.json")!!.bufferedReader().use { it.readText() } + val items = readComingInFixture(source) { name -> + RequestsCaptureTest::class.java.getResourceAsStream("/wolfskin-requests/$name")!!.use { it.readBytes() } + } + val selected = when { shared -> items.filter { it.id == "x7" }; ratings -> items.filter { it.id == "x8" }; else -> items.take(6) } + return ComingInSnapshot(selected, ComingInStatus.Ready, signedIn = true) +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt new file mode 100644 index 00000000..1020f57d --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import android.app.Application +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import java.util.Locale +import org.json.JSONArray +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Key replays exercise real shell handling, including trapped overlays and title-return memory. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class RequestsKeysTest { + @get:Rule val rule = createComposeRule() + + private fun mount(state: ShellState) { + rule.setContent { WolfskinTheme { ShellSurface(state) } } + rule.mainClock.advanceTimeByFrame() + } + + private fun send(first: Key, second: Key? = null, third: Key? = null, fourth: Key? = null) { + listOfNotNull(first, second, third, fourth).forEach { key -> + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } + rule.mainClock.advanceTimeByFrame() + } + } + + private fun sheet(snapshot: ComingInSnapshot = requestsFixture(), actions: (ShellAction) -> Unit = {}): ShellState = + ShellState(fixturePeople(), "justin", actions).apply { + pickerVisible = false + enterDestination("settings") + requests.update(snapshot) + openRequests() + } + + @Test fun approvedNinePressPathUsesRealShellFocus() { + val paths = RequestsKeysTest::class.java.getResourceAsStream("/requests-press-paths.json")!!.bufferedReader().use { JSONArray(it.readText()) } + val keys = mapOf("OK" to Key.DirectionCenter, "UP" to Key.DirectionUp, "LEFT" to Key.DirectionLeft, + "RIGHT" to Key.DirectionRight, "DOWN" to Key.DirectionDown) + val state = ShellState(fixturePeople(), "justin") + state.requests.update(requestsFixture()) + mount(state) + val path = paths.getJSONObject(0) + val presses = path.getJSONArray("keys") + assertEquals(9, path.getInt("presses")) + assertEquals(path.getInt("presses"), presses.length()) + for (index in 0 until presses.length()) send(keys.getValue(presses.getString(index))) + rule.onNodeWithTag("coming-in").assertExists() + rule.onNodeWithText("Requests · 5 open").assertExists() + assertEquals("settings", state.route) + assertEquals("x5", state.requests.focusedId) + assertFalse(state.navFocused) + } + + @Test fun readinessWindowAndTitleRoundTripPreserveRequest() { + lateinit var state: ShellState + state = sheet { action -> if (action is ShellAction.RequestTitle) state.enterDestination("request-title/${action.request.tmdbId}") } + mount(state) + send(Key.DirectionDown, Key.DirectionRight) + rule.onNodeWithText("Download · Downloading").assertExists() + assertEquals(4, state.requests.windowSize) + assertEquals("x1", state.requests.focusedId) + send(Key.DirectionLeft) + rule.onNodeWithText("Download · Downloading").assertDoesNotExist() + send(Key.DirectionDown, Key.DirectionDown, Key.DirectionDown, Key.DirectionDown) + assertEquals("x6", state.requests.focusedId) + assertEquals(1, state.requests.windowStart) + send(Key.DirectionDown) + assertEquals("x6", state.requests.focusedId) + send(Key.DirectionCenter) + assertFalse(state.requestsVisible) + assertTrue(state.route.startsWith("request-title/")) + send(Key.Back) + rule.onNodeWithTag("coming-row-x6").assertExists() + assertEquals("x6", state.requests.focusedId) + assertTrue(state.requestsVisible) + send(Key.Back) + assertFalse(state.requestsVisible) + assertEquals("settings", state.route) + assertEquals(0, state.settingsRow) + } + + @Test fun emptyAndLoadingTrapDirectionsWhileUnavailableRetries() { + var retries = 0 + val state = sheet(ComingInSnapshot(status = ComingInStatus.Loading)) { if (it == ShellAction.RetryRequests) retries++ } + mount(state) + send(Key.DirectionUp, Key.DirectionLeft) + assertFalse(state.navFocused) + assertTrue(state.requestsVisible) + send(Key.DirectionCenter) + assertFalse(state.requestsVisible) + rule.runOnIdle { state.requests.update(ComingInSnapshot()); state.openRequests() } + send(Key.DirectionCenter) + assertEquals(1, retries) + assertTrue(state.requestsVisible) + rule.runOnIdle { state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready)) } + send(Key.DirectionDown, Key.DirectionCenter) + rule.onNodeWithText("Nothing coming in.").assertExists() + assertTrue(state.requestsVisible) + } + + @Test fun cachedRatingsRenderInTheTelevisionLocale() { + val original = Locale.getDefault() + try { + Locale.setDefault(Locale.forLanguageTag("nl-NL")) + val state = sheet(requestsFixture(ratings = true)) + kotlinx.coroutines.test.runTest { state.requests.awaitFocusedRatings() } + mount(state) + rule.onNodeWithText("7,6").assertExists() + rule.onNodeWithText("87%").assertExists() + rule.runOnIdle { state.requests.update(state.requests.snapshot.copy(signedIn = false)) } + rule.onNodeWithText("7,6").assertDoesNotExist() + rule.onNodeWithText("87%").assertDoesNotExist() + } finally { Locale.setDefault(original) } + } + + @Test fun selectingARootAfterARequestTitleDiscardsTheSheetReturnLink() { + lateinit var state: ShellState + state = sheet { action -> if (action is ShellAction.RequestTitle) state.enterDestination("request-title/${action.request.tmdbId}") } + mount(state) + send(Key.DirectionCenter, Key.DirectionUp, Key.DirectionCenter) + assertEquals("home", state.route) + send(Key.Back) + assertFalse("Root Back must focus navigation, not reopen the abandoned sheet", state.requestsVisible) + assertTrue(state.navFocused) + } + + @Test fun refreshKeepsStableFocusAndSharedActorNeverBecomesYou() { + val snapshot = requestsFixture() + val state = sheet(snapshot) + mount(state) + send(Key.DirectionDown) + rule.runOnIdle { state.requests.update(snapshot.copy(items = snapshot.items.reversed(), partial = true)) } + assertEquals("x1", state.requests.focusedId) + rule.onNodeWithText("Some requests may be missing: Conductarr answered only in part.").assertExists() + val shared = requestsFixture(shared = true).items.single() + val viewer = fixturePeople().single { it.key == "shared" } + assertEquals("Shared", shared.actor(viewer)) + assertEquals("Justin", shared.recipient(viewer)) + assertEquals(5, snapshot.openCount) + assertEquals(listOf(0, 1, 1, 2, 3, 4), snapshot.ordered.map { it.stage.rank }) + } +} diff --git a/wolfskin-design/src/test/resources/requests-press-paths.json b/wolfskin-design/src/test/resources/requests-press-paths.json new file mode 100644 index 00000000..5d76c663 --- /dev/null +++ b/wolfskin-design/src/test/resources/requests-press-paths.json @@ -0,0 +1,21 @@ +[ + { + "goal": "Round 4 \u00b7 Home hero \u2192 Requests (Coming in) in Settings", + "keys": [ + "OK", + "UP", + "LEFT", + "OK", + "RIGHT", + "RIGHT", + "DOWN", + "OK", + "OK" + ], + "presses": 9, + "note": "8 from the hero", + "start": "app launch: Who's watching with Justin focused (index.html:357)", + "replayed": "PASS", + "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" + } +] diff --git a/wolfskin-design/src/test/resources/wolfskin-requests.json b/wolfskin-design/src/test/resources/wolfskin-requests.json new file mode 100644 index 00000000..507ebd65 --- /dev/null +++ b/wolfskin-design/src/test/resources/wolfskin-requests.json @@ -0,0 +1,217 @@ +{ + "source": "Locked Opus fixture/fixture.json; index.html EXAMPLES() pool order", + "items": [ + { + "id": "x1", + "mediaType": "tv", + "tmdbId": 11821, + "title": "The Abbott and Costello Show", + "requesterKey": "justin", + "requesterName": "Justin", + "actorKey": "justin", + "actorName": "Justin", + "stage": "Downloading", + "poster": "tv-the-abbott-and-costello-show-1952-poster.jpg", + "ratings": [ + { + "mark": "tmdb", + "score": 7.2, + "percentage": false + } + ] + }, + { + "id": "x2", + "mediaType": "movie", + "tmdbId": 13483, + "title": "Awake", + "requesterKey": "mindel", + "requesterName": "Mindel", + "actorKey": "justin", + "actorName": "Justin", + "stage": "AlmostReady", + "poster": "movie-awake-2007-poster.jpg", + "ratings": [ + { + "mark": "rotten", + "score": 23, + "percentage": true + }, + { + "mark": "spilled", + "score": 55, + "percentage": true + }, + { + "mark": "imdb", + "score": 6.5, + "percentage": false + } + ] + }, + { + "id": "x3", + "mediaType": "tv", + "tmdbId": 70785, + "title": "Anne with an E", + "requesterKey": "shared", + "requesterName": "Shared", + "actorKey": "mindel", + "actorName": "Mindel", + "stage": "Searching", + "poster": "tv-anne-with-an-e-2017-poster.jpg", + "ratings": [ + { + "mark": "fresh", + "score": 63, + "percentage": true + } + ] + }, + { + "id": "x4", + "mediaType": "movie", + "tmdbId": 99861, + "title": "Avengers: Age of Ultron", + "requesterKey": "justin", + "requesterName": "Justin", + "actorKey": "justin", + "actorName": "Justin", + "stage": "Waiting", + "poster": "movie-avengers-age-of-ultron-2015-poster.jpg", + "ratings": [ + { + "mark": "fresh", + "score": 75, + "percentage": true + }, + { + "mark": "upright", + "score": 82, + "percentage": true + }, + { + "mark": "imdb", + "score": 7.3, + "percentage": false + } + ] + }, + { + "id": "x5", + "mediaType": "movie", + "tmdbId": 1033148, + "title": "Die My Love", + "requesterKey": "mindel", + "requesterName": "Mindel", + "actorKey": "mindel", + "actorName": "Mindel", + "stage": "Failed", + "poster": "movie-die-my-love-2025-poster.jpg", + "ratings": [ + { + "mark": "fresh", + "score": 74, + "percentage": true + }, + { + "mark": "spilled", + "score": 45, + "percentage": true + }, + { + "mark": "imdb", + "score": 6, + "percentage": false + } + ] + }, + { + "id": "x6", + "mediaType": "movie", + "tmdbId": 416153, + "title": "Arctic Dogs", + "requesterKey": "shared", + "requesterName": "Shared", + "actorKey": "justin", + "actorName": "Justin", + "stage": "Ready", + "poster": "movie-arctic-dogs-2019-poster.jpg", + "ratings": [ + { + "mark": "rotten", + "score": 13, + "percentage": true + }, + { + "mark": "upright", + "score": 60, + "percentage": true + }, + { + "mark": "imdb", + "score": 4.9, + "percentage": false + } + ] + }, + { + "id": "x7", + "mediaType": "movie", + "tmdbId": 76203, + "title": "12 Years a Slave", + "requesterKey": "justin", + "requesterName": "Justin", + "actorKey": "shared", + "actorName": "Shared", + "stage": "Waiting", + "poster": "movie-12-years-a-slave-2013-poster.jpg", + "ratings": [ + { + "mark": "fresh", + "score": 95, + "percentage": true + }, + { + "mark": "upright", + "score": 90, + "percentage": true + }, + { + "mark": "imdb", + "score": 8.1, + "percentage": false + } + ] + }, + { + "id": "x8", + "mediaType": "movie", + "tmdbId": 14069, + "title": "The Girl Who Leapt Through Time", + "requesterKey": "mindel", + "requesterName": "Mindel", + "actorKey": "justin", + "actorName": "Justin", + "stage": "Waiting", + "poster": "movie-the-girl-who-leapt-through-time-2006-poster.jpg", + "ratings": [ + { + "mark": "fresh", + "score": 87, + "percentage": true + }, + { + "mark": "upright", + "score": 90, + "percentage": true + }, + { + "mark": "imdb", + "score": 7.6, + "percentage": false + } + ] + } + ] +} diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/movie-12-years-a-slave-2013-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/movie-12-years-a-slave-2013-poster.jpg new file mode 100644 index 00000000..ff1f55e6 Binary files /dev/null and b/wolfskin-design/src/test/resources/wolfskin-requests/movie-12-years-a-slave-2013-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/movie-arctic-dogs-2019-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/movie-arctic-dogs-2019-poster.jpg new file mode 100644 index 00000000..07552443 Binary files /dev/null and b/wolfskin-design/src/test/resources/wolfskin-requests/movie-arctic-dogs-2019-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/movie-avengers-age-of-ultron-2015-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/movie-avengers-age-of-ultron-2015-poster.jpg new file mode 100644 index 00000000..300840ff Binary files /dev/null and b/wolfskin-design/src/test/resources/wolfskin-requests/movie-avengers-age-of-ultron-2015-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/movie-awake-2007-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/movie-awake-2007-poster.jpg new file mode 100644 index 00000000..32595897 Binary files /dev/null and b/wolfskin-design/src/test/resources/wolfskin-requests/movie-awake-2007-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/movie-die-my-love-2025-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/movie-die-my-love-2025-poster.jpg new file mode 100644 index 00000000..f6eca818 Binary files /dev/null and b/wolfskin-design/src/test/resources/wolfskin-requests/movie-die-my-love-2025-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/movie-the-girl-who-leapt-through-time-2006-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/movie-the-girl-who-leapt-through-time-2006-poster.jpg new file mode 100644 index 00000000..ded6cd2a Binary files /dev/null and b/wolfskin-design/src/test/resources/wolfskin-requests/movie-the-girl-who-leapt-through-time-2006-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/tv-anne-with-an-e-2017-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/tv-anne-with-an-e-2017-poster.jpg new file mode 100644 index 00000000..55d443b4 Binary files /dev/null and b/wolfskin-design/src/test/resources/wolfskin-requests/tv-anne-with-an-e-2017-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/tv-the-abbott-and-costello-show-1952-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/tv-the-abbott-and-costello-show-1952-poster.jpg new file mode 100644 index 00000000..7d6768ea Binary files /dev/null and b/wolfskin-design/src/test/resources/wolfskin-requests/tv-the-abbott-and-costello-show-1952-poster.jpg differ diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index a0f8994b..4b5ca22d 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -127,6 +127,46 @@ --motion-scroll: 180ms; --motion-ratingDwell: 350ms; --motion-toast: 3200ms; + --requests-sheetPaddingY: 80.0px; + --requests-sheetPaddingX: 64.0px; + --requests-sheetGap: 18.0px; + --requests-sheetTitleLineHeight: 56.0px; + --requests-sheetTitleBaseline: 44.0px; + --requests-titleLineHeight: 39.0px; + --requests-titleBaseline: 31.0px; + --requests-factsLineHeight: 31.2px; + --requests-factsBaseline: 24.0px; + --requests-stageLineHeight: 39.6px; + --requests-stageLabelLineHeight: 33.8px; + --requests-stageBaseline: 24.0px; + --requests-emptyMargin: 20.0px; + --requests-rowPaddingY: 14.0px; + --requests-rowPaddingX: 18.0px; + --requests-rowGap: 24.0px; + --requests-listGap: 6.0px; + --requests-stageMargin: 4.0px; + --requests-stageGlyph: 28.0px; + --requests-stageGap: 10.0px; + --requests-readinessGapX: 22.0px; + --requests-readinessGapY: 10.0px; + --requests-readinessMargin: 8.0px; + --requests-readinessGlyph: 26.0px; + --requests-chevron: 30.0px; + --requests-receiptPaddingY: 28.0px; + --requests-receiptPaddingX: 30.0px; + --requests-receiptGap: 10.0px; + --requests-receiptGlyph: 36.0px; + --requests-buttonMargin: 12.0px; + --requests-skeletonTitleWidth: 360.0px; + --requests-skeletonTitleHeight: 34.0px; + --requests-skeletonSubtitleWidth: 240.0px; + --requests-skeletonSubtitleHeight: 26.0px; + --requests-skeletonGap: 12.0px; + --requests-ratingsGap: 20.0px; + --requests-ratingsEntryGap: 8.0px; + --requests-shadowBlur: 80.0px; + --requests-shadowOffset: -30.0px; + --requests-shadowColor: #00000080; --shell-navLabelFade: 200ms; --shell-zero: 0.0px; --shell-stageWidth: 1920.0px; diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index 64ee7d14..1c285d4c 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,6 +1,6 @@ 28f6f7bb878b8388ce04293cd2819bc1cb684da34925c9ddcded78fcddf6d1dd generate_tokens.py 616924e610dfd1f249155cc9b921cb9760925d5251ad8334b026072af3cebdd9 home-tokens.json -1052c79be79e0fb85e64ba5b48a77e7e9dddd114c656e5572f39c0a85e09b9f5 shell-tokens.json -ae8d9bb2f8ce08560d9ec50fb2f1da67aac738964bab7fb9486aaf10ab90d90b src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -3af223f4ce3baf310891a3dd754e3e23576ff345053aa1b4faea69a3dae6576d tokens.css +f61da327af6f6a7fb40c65e2d187957506b72a5b03a760f0f7fba5eca6067818 shell-tokens.json +c17479fcfb0d97b674226e3cd5ef756edec3e27cdc08112341d48a97cf7c3553 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +14b9a41965fc07f6d87dcf02581d73778215208f4d6ea1dd76363fc4c6c6ee18 tokens.css 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json