Implement approved TV loading and bounded motion
All checks were successful
review/code-claude Code review passed on 16684454, no blocking finding
review/design-claude Design review pass: motion numbers equal direction A; 6 non-blocking notes
|
|
@ -15,7 +15,8 @@ import coil3.network.NetworkResponse
|
||||||
import coil3.network.cachecontrol.CacheControlCacheStrategy
|
import coil3.network.cachecontrol.CacheControlCacheStrategy
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
import coil3.request.Options
|
import coil3.request.Options
|
||||||
import coil3.request.crossfade
|
import coil3.request.transitionFactory
|
||||||
|
import org.justinvisser.wolfskin.design.NetworkArtTransition
|
||||||
import coil3.util.DebugLogger
|
import coil3.util.DebugLogger
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
|
@ -89,7 +90,7 @@ fun CoilConfig(
|
||||||
memoryCache(null)
|
memoryCache(null)
|
||||||
diskCache(null)
|
diskCache(null)
|
||||||
}
|
}
|
||||||
}.crossfade(false)
|
}.transitionFactory(NetworkArtTransition)
|
||||||
.logger(if (debugLogging) DebugLogger() else null)
|
.logger(if (debugLogging) DebugLogger() else null)
|
||||||
.components {
|
.components {
|
||||||
add(
|
add(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
import androidx.compose.ui.test.pressKey
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** A pending shelf cannot capture the D-pad or break the shell's Back ladder. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
class DiscoverSkeletonKeysTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun downAndUpSkipPendingLanesAndBackReturnsToNavWhenOnlySkeletonsRemain() {
|
||||||
|
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", false)), "justin").apply {
|
||||||
|
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
|
||||||
|
}
|
||||||
|
val pending = DiscoverLane("pending", "Pending", emptyList(), pending = true)
|
||||||
|
val ready = DiscoverLane("ready", "Available", listOf(DiscoverTitle(1, "movie", "A real title")))
|
||||||
|
val snapshot = DiscoverSnapshot(status = DiscoverStatus.Ready, lanes = listOf(pending, ready))
|
||||||
|
val state = DiscoverState(shell, snapshot, mockk(relaxed = true), CoroutineScope(Dispatchers.Main.immediate), {}, {})
|
||||||
|
state.focus(1)
|
||||||
|
rule.setContent { WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } } }
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
rule.runOnIdle { assertEquals("ready", state.focusedLane?.id); assertEquals("ready:0", state.cardKey) }
|
||||||
|
press(Key.DirectionUp)
|
||||||
|
rule.runOnIdle {
|
||||||
|
assertEquals(1, state.focusedRow)
|
||||||
|
state.updateSnapshot(snapshot.copy(lanes = listOf(pending)))
|
||||||
|
}
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
rule.runOnIdle { assertEquals(1, state.focusedRow); assertNull(state.cardKey) }
|
||||||
|
press(Key.Back)
|
||||||
|
rule.runOnIdle { assertTrue(shell.navFocused); assertFalse(shell.pickerVisible); assertEquals("discover", shell.route) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun press(key: Key) { rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } }
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Wolfskin TV specification — revision 5
|
# Wolfskin TV specification — revision 6
|
||||||
|
|
||||||
2026-11-06. This feedback amendment applies to the approved revision-4 specification and frozen frames at `data/tv-v2/focus-2026-11-06/` (prototype SHA-256 `1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d`). Unchanged geometry, navigation, typography, colours and motion remain governed by those frames and the [written specification](https://pages.justinvisser.org/wolfskin/tv-spec). The amendment's prototype delta is `data/tv-v2/feedback-2026-11-06/`.
|
2026-11-06. Revision 6 adds the approved direction-A motion and loading states from [the motion reference](https://pages.justinvisser.org/wolfskin/tv-motion-speed.html), frozen with SHA-256 checksums at `data/tv-v2/motion-2026-11-06/`. Revision-5 feedback below still applies. Unchanged static geometry, navigation, typography and colours remain governed by the approved frames and the [written specification](https://pages.justinvisser.org/wolfskin/tv-spec). The revision-5 prototype delta remains `data/tv-v2/feedback-2026-11-06/`.
|
||||||
|
|
||||||
## Who’s watching
|
## Who’s watching
|
||||||
|
|
||||||
|
|
@ -54,8 +54,39 @@ Read-only source measurement: Desperate Housewives has a 2732×1536 Backdrop. Wi
|
||||||
|
|
||||||
Every Discover lane, including Movies for you, Series for you, service entries, service-specific lanes and Hidden picks, uses a **104 px heading band**: heading line plus **60 px heading-to-card gap**, up from 40 px. This is a consistent Level 2 extension, not a Series-only exception. Poster shelves become 652 px high and service-entry shelves 316 px. Captions and frame scroll offsets use the same heading band. Home headings remain unchanged.
|
Every Discover lane, including Movies for you, Series for you, service entries, service-specific lanes and Hidden picks, uses a **104 px heading band**: heading line plus **60 px heading-to-card gap**, up from 40 px. This is a consistent Level 2 extension, not a Series-only exception. Poster shelves become 652 px high and service-entry shelves 316 px. Captions and frame scroll offsets use the same heading band. Home headings remain unchanged.
|
||||||
|
|
||||||
|
## Motion and loading — revision 6
|
||||||
|
|
||||||
|
Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episodes and cast, and person headers/filmography use shaped placeholders without loading sentences. The placeholder fill is `surface2` (`#1b1b25`). A complete shelf shares **one** alpha layer: **0.55 → 1 → 0.55 over 1400 ms**, CSS ease-in-out (`.42,0,.58,1`), 700 ms each way. No per-card clocks, staggered pulse phases, shimmer, moving gradient, or animated blur.
|
||||||
|
|
||||||
|
| Transition | Duration and curve | Transform |
|
||||||
|
|---|---|---|
|
||||||
|
| Focus scale/ring | 180 ms, ease-out (`.33,1,.68,1`) | Existing locked focus scale; no geometry change |
|
||||||
|
| Poster caption in / out | 60 ms delay + 180 ms ease-out / 100 ms ease-out | 10 px rise on entry |
|
||||||
|
| Network artwork only | 160 ms, Coil painter alpha | None; memory, disk and local hits are instant |
|
||||||
|
| Row/tab content reveal | 180 ms, ease-out | One layer per row, 24 px rise |
|
||||||
|
| Initial hero/title copy | 180 ms, ease-out | 16 px rise |
|
||||||
|
| Hero backdrop hand-off | 320 ms, ease-in-out (`.65,0,.35,1`) | Alpha only; retain previous art until incoming art or its final fallback is ready |
|
||||||
|
| Hero copy hand-off | Old copy out 120 ms ease (`.25,.1,.25,1`); new copy starts at 140 ms and enters over 200 ms ease-out | 14 px rise on entry |
|
||||||
|
| Late readiness line | 200 ms, ease | Alpha only, in its reserved slot |
|
||||||
|
| Horizontal / vertical focus-follow scroll | 160 ms ease / 180 ms ease-out | Existing scroll geometry |
|
||||||
|
|
||||||
|
Row reveals are grouped rather than the browser prototype's per-card staging: the armv7 frame budget permits one animated layer per shelf, not one per card. Tabs have no full-screen transition layer. The upper Home hero blend is bounded to **1920×820 px**, Discover to **1920×740 px**; the lower backdrop changes without an animated screen-sized layer. Hero copy is a separate bounded block. Image reveal uses Coil's painter, not a new full-screen compositing layer. Existing poster-only blur is static.
|
||||||
|
|
||||||
|
The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animation owners are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are active animation owners, not every static display-list node. A hero hand-off adds at most **2** bounded owners (outgoing art and copy). Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; artwork fades are painter draws without additional compositing layers. No animated owner is full-screen.
|
||||||
|
|
||||||
|
The series readiness band reserves **56 px (28 dp)** from its first content paint, including when empty or when readiness later disappears. Ratings remain inside the fixed-height facts band. The series header's measured height therefore cannot change when late ratings/readiness arrive; overview, actions, season and episode anchors retain their coordinates. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial loading keeps nav focus and Back still pops the title/person route.
|
||||||
|
|
||||||
|
Discover key traversal skips pending lanes in both directions. If every remaining lane is pending, Down stays on the real filter chips and Back returns to nav; a skeleton must never create an empty-column focus range.
|
||||||
|
|
||||||
|
`Settings.Global.ANIMATOR_DURATION_SCALE == 0`, exposed by Compose's window `MotionDurationScale`, or the host's `LocalReducedMotion` flag disables pulse and translation/scroll motion. Focus scale and nav geometry snap; permitted fades are capped at **80 ms**. The window context supplies live scale state without settings or disk reads on the main thread. Fetch/decode dispatchers and image URL logic are unchanged.
|
||||||
|
|
||||||
|
Native **1920×1080** before/after frames and the frame-budget inventory are published at [the motion build proof](https://pages.justinvisser.org/wolfskin/tv-motion-build/). Before frames use the pre-change surface sources in this same isolated worktree; after frames use the shipped Compose surfaces. No device access, installation or optimized APK is part of this lane. On-box acceptance remains: no series layout change after first content, Home scrolling no worse than **39.6% janky / p90 65 ms**. Native JVM rendering does not measure that GPU/frame-timing target.
|
||||||
|
|
||||||
|
|
||||||
## Sources and verification
|
## Sources and verification
|
||||||
|
|
||||||
Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`.
|
Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`.
|
||||||
|
|
||||||
|
`MotionSurfaceTest`, `HeroMotionTest`, `NetworkArtTransitionTest` and `DiscoverSkeletonKeysTest` cover late-line geometry, loading/ready row eligibility, Back/nav ownership, rendered pulse suppression under both motion controls, strict main-thread I/O policy, network-versus-cache reveal, bounded hero blending, delayed art and interrupted hand-offs. Manually clocked captures explicitly deliver snapshot changes before advancing animation time. The frozen prototype includes `index.html`, its local media, the original CSS/JS/builder and a complete SHA-256 manifest; replay the HTML, not the preserved builder's live-publishing entry point.
|
||||||
|
|
||||||
The feedback prototype delta links the native 1920×1080 before/after frames. `HomeViewportTest` exercises short and long poster/tile/wide rows, first focus, both row directions and native Back from the shipped title surface. The real-Bear capture harness reads recorded Jellyfin metadata through `TitleReadFacade`; it is a throwaway proof and is not shipped. The final gates are `make test` and `make apk-debug`. No TV access or optimized-APK build is part of this worker's validation.
|
The feedback prototype delta links the native 1920×1080 before/after frames. `HomeViewportTest` exercises short and long poster/tile/wide rows, first focus, both row directions and native Back from the shipped title surface. The real-Bear capture harness reads recorded Jellyfin metadata through `TitleReadFacade`; it is a throwaway proof and is not shipped. The final gates are `make test` and `make apk-debug`. No TV access or optimized-APK build is part of this worker's validation.
|
||||||
|
|
|
||||||
47
data/tv-v2/motion-2026-11-06/SHA256SUMS
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
8cca2384b2ed67b28626a316fb5b9c421c3fe0b48de4438d8c1683f6061db81d build_page.py
|
||||||
|
924b013c685c8eefe98ba985ec61d45d14a38ddc2c3e4269895ed148cee2e9e5 delta.json
|
||||||
|
e480b84afed8fbae72df846cc980b309ef3ca9342b89df74eeff8cf031050813 index.html
|
||||||
|
64ec89bd409e79737e615344ffb19eea2e358c5f61b571b2e8ad989a349cab5c proto.css
|
||||||
|
bba2e150d5ca24a0a7765cc684a7e39bb5742756e19ff855f1743e3a62950684 proto.js
|
||||||
|
7cf975a048ca1b9c2f8b2627ee034012c325246d4b6e56048c864f1ced7de09f tv-motion-speed/b1t.webp
|
||||||
|
c53fd37b2946bf54386883b321925a4d006a5e79d4e07bcb0a02755e0b2f19ec tv-motion-speed/b1.webp
|
||||||
|
6dd4a715be2361ecaee06b825559fec32432363d302d3b06b1a4d95b1dedf757 tv-motion-speed/b2t.webp
|
||||||
|
8ec6be1c5d143b5786ed4b811e42585ac92a456cf0e88795f6a2ebcaa734e75d tv-motion-speed/b2.webp
|
||||||
|
674cb1302d66e930504fcc4dff0f8c9d267c43d5a26f97ace8517dd776100cc9 tv-motion-speed/b3t.webp
|
||||||
|
261dd7de5e6a204e5b4a0433a9210a3883720be09bab890afbde37d51acf3e06 tv-motion-speed/b3.webp
|
||||||
|
3a6128506526ce54eb7c9e018880be57a1f2fbe7198f2d0fc47efa9ef82fc700 tv-motion-speed/b4t.webp
|
||||||
|
71427d171d31b499d406410b51a61a683e5fb1f664e8b483d2a95acd0f69b88c tv-motion-speed/b4.webp
|
||||||
|
825ef8387d3d035d69dcfdf618b6030dabf560e7bdb4b9f3b473990f3d5eaa48 tv-motion-speed/before-discover-tab.webm
|
||||||
|
c82bf1e41fa2866ff94369173ffd5af83efe6a6fa8499d0498f6be25682451bf tv-motion-speed/before-home-enter.webm
|
||||||
|
15d166f0741ae7b5422f466fc8e2001de167deecb81c8b9eff33e9e7ac139ff8 tv-motion-speed/before-launch-fast.webm
|
||||||
|
bacdbb57858f7f28e7af9f50d70bb3cfeac878f0204ee02b8c94e2abb686a011 tv-motion-speed/before-launch-spinner.webm
|
||||||
|
d492f500ce464b0a529dce81ed0be09d53c868d554973d61a69097b0a1f6fef5 tv-motion-speed/before-movies-grid.webm
|
||||||
|
1cfc443f8f67bbdf5acfa1341e2616fd5e0895035c390709b3e11600ea7a37e6 tv-motion-speed/before-person.webm
|
||||||
|
7d9b7acf81981f2058ef85aaff18aa6f1668dfdceca0991da1480099757baa1a tv-motion-speed/before-title-discover.webm
|
||||||
|
1a934ff576601de973103145fb00db3059b5bf114b8d36aa49fe2f2a421b324f tv-motion-speed/before-title-movie.webm
|
||||||
|
6366d9b410b5460eba7348bc9e6356f906fa2b8771931232f9a55ea4e4272c46 tv-motion-speed/before-title-series.webm
|
||||||
|
65577079db9f4a40e62bd5c676d1c4d51a1ca0c9802e364b314a5b162b9dc90b tv-motion-speed/c1.webp
|
||||||
|
91a325404fa9e6f925bab3a4370f80065f24efbb314fa75252307cf3c642af1f tv-motion-speed/c2.webp
|
||||||
|
ff018c6db960483f8a2eb73242b087a41b866e05a5bde3f6f4c76eeb1abec21b tv-motion-speed/c3.webp
|
||||||
|
75ab6defe098a403999a7a267061346afb29748bd2651791d62eb9ca9cdb1acb tv-motion-speed/c4.webp
|
||||||
|
b01e96165bc33ff5cf47da086ea966b24d58ef2e995cb16ef4af00375a54efca tv-motion-speed/c5.webp
|
||||||
|
faa1e271643dc3585c70382988fd609f6522d622887a8595658e96831226837d tv-motion-speed/c6.webp
|
||||||
|
92c1e9bea498f957e5eda418c9c74698cf693f7e7ca406b8e90cd55357a46499 tv-motion-speed/c7.webp
|
||||||
|
2a62c58b79b0592ba74498f8b7a0a996cb12b7430d5fe00bf8e8550400c3f619 tv-motion-speed/c8.webp
|
||||||
|
0366fa40daa0328bca55183b656ffc94dfb7fb1b7433732a2effce093e8e4ab0 tv-motion-speed/e1.webp
|
||||||
|
9a84c464aa98dba31e1aa0d97665e273305d08fbe33fd579136c8964073f42bb tv-motion-speed/home-before-tab.webp
|
||||||
|
8903c80b69479a5d8ffc6361628f8117a601fe5b88897f3dcc0a62f9b8a533b7 tv-motion-speed/launcher.webp
|
||||||
|
1026e09bdbd9a6a7570ebd041c098d04b2bb2e738e7047217dae479efd944120 tv-motion-speed/p10.webp
|
||||||
|
d862e8a85b49b5128837a58bad4051f460c736702d0ce2befb057ad29448b63e tv-motion-speed/p11.webp
|
||||||
|
b6f7a0c96949d690124413e7e9df249730d0b5f95cebdb1345e0f91f784577c1 tv-motion-speed/p12.webp
|
||||||
|
eebb8bec042819923bddedd2abee4841b62c0e1c10f05dc0af4569c98d5a3a54 tv-motion-speed/p1.webp
|
||||||
|
e5f6079132aa33e0a1741a335cfa00d1a476d7da38a556ac1cbadb0f410df407 tv-motion-speed/p2.webp
|
||||||
|
af3c62f1acb37a7675594179ae285b3092ca6fd3e40216bbcc442190d87c216f tv-motion-speed/p3.webp
|
||||||
|
7b303cc200c7f3932b077fcae3908a3c6ec0b75922d2bf048d876177d6c4bf4d tv-motion-speed/p4.webp
|
||||||
|
8650904e68d4085f1ef1c58a8312ed501bddcca5f5565180861339efa6cf4a92 tv-motion-speed/p5.webp
|
||||||
|
4c102e76ef046c979f728990cb9ed96a87a67ca570cafd2ead8cc3c3c7fb7900 tv-motion-speed/p6.webp
|
||||||
|
3a30007a21825461d99732063ce3e9a6dc256cb46937bfd1f29236f95480fb5b tv-motion-speed/p7.webp
|
||||||
|
9fa546b84609ef91ca43ff943b245eda53ff06715cf75f800d556fd3ba2abb12 tv-motion-speed/p8.webp
|
||||||
|
6e859f5ce40cfa21702b9458586913affafda1454a295dd6099ebc3b462f7aa4 tv-motion-speed/p9.webp
|
||||||
|
3ab8a7da1d0c2532933eb1803a3408d23e42cbed5923575deb1db62d7cbbb477 tv-motion-speed/s1t.webp
|
||||||
|
40fb5827a46bcd9d6c9e2f10190c58e0670e29e0ba908ae28600a2f8cae297cc tv-motion-speed/s1.webp
|
||||||
298
data/tv-v2/motion-2026-11-06/build_page.py
Executable file
|
|
@ -0,0 +1,298 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build the Wolfskin TV load-states / motion / speed spike page (dark only).
|
||||||
|
|
||||||
|
Inputs : page/proto.css, page/proto.js, page/tv-motion-speed/* (webm clips from the box, fixture art), numbers below
|
||||||
|
(all from data/tv-spike-motion-speed/raw/ and report.md).
|
||||||
|
Output : /opt/stacks/pages/store/wolfskin/tv-motion-speed.html + tv-motion-speed/ assets (mode 660).
|
||||||
|
"""
|
||||||
|
import os, shutil
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
STORE = '/opt/stacks/pages/store/wolfskin/'
|
||||||
|
OUT = STORE + 'tv-motion-speed.html'
|
||||||
|
ASSETS = STORE + 'tv-motion-speed/'
|
||||||
|
|
||||||
|
CSS = open(os.path.join(HERE, 'proto.css')).read()
|
||||||
|
JS = open(os.path.join(HERE, 'proto.js')).read()
|
||||||
|
|
||||||
|
|
||||||
|
def table(head, rows, cls=''):
|
||||||
|
h = ''.join(f'<th>{c}</th>' for c in head)
|
||||||
|
b = ''.join('<tr>' + ''.join(f'<td>{c}</td>' for c in r) + '</tr>' for r in rows)
|
||||||
|
return f'<div class="tw"><table class="{cls}"><thead><tr>{h}</tr></thead><tbody>{b}</tbody></table></div>'
|
||||||
|
|
||||||
|
|
||||||
|
def pair(pid, scene, before, after_title, lead, note='', variants=False, before2=None):
|
||||||
|
vids = ''.join(f'<div class="pane"><p class="lab">{lab}</p><div class="sbox"><video src="tv-motion-speed/{src}.webm" muted loop playsinline autoplay preload="auto"></video></div></div>'
|
||||||
|
for src, lab in ([before] + ([before2] if before2 else [])))
|
||||||
|
var = f'<div class="tabs" data-variants="{pid}"></div>' if variants else ''
|
||||||
|
return f'''<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button>{f'<span class="muted small">{note}</span>' if note else ''}</div>
|
||||||
|
<div class="panes">{vids}<div class="pane"><p class="lab">{after_title}</p>{var}<div class="sbox" id="{pid}" data-scene="{scene}" data-lead="{lead}"></div></div></div></div>'''
|
||||||
|
|
||||||
|
|
||||||
|
def opt(title, gain, effort, risk, see, where='TV'):
|
||||||
|
return [title, gain, effort, risk, see, where]
|
||||||
|
|
||||||
|
|
||||||
|
OPT_HEAD = ['Option', 'Expected gain (measured basis)', 'Effort', 'Risk', 'What you would see', 'Ships in']
|
||||||
|
|
||||||
|
page = f'''<!doctype html><html lang="en" data-lavish-annotate="off"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>Wolfskin TV: faster, quieter, smoother</title><style>
|
||||||
|
:root{{color-scheme:dark;--bg:#07070a;--panel:#13131a;--line:rgba(255,255,255,.1);--fg:#f4f4f6;--muted:rgba(244,244,246,.74);--acc:oklch(78% 0.14 305);--ok:#7ed6a0;--warn:#ffc46b;--bad:#ff8a7a}}
|
||||||
|
*{{box-sizing:border-box}}html{{-webkit-text-size-adjust:100%}}body{{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,Segoe UI,sans-serif}}
|
||||||
|
main{{max-width:1400px;margin:0 auto;padding:16px 14px 120px}}h1{{font-size:28px;line-height:1.2;margin:6px 0 6px}}h2{{font-size:21px;margin:34px 0 8px;color:var(--acc);border-bottom:1px solid var(--line);padding-bottom:4px}}
|
||||||
|
h3{{font-size:17px;margin:20px 0 6px}}a{{color:var(--acc)}}.muted{{color:var(--muted)}}.small{{font-size:13px}}p{{margin:6px 0}}li{{margin:3px 0}}
|
||||||
|
.lead{{font-size:17px}}.k{{color:var(--ok)}}.w{{color:var(--warn)}}.b{{color:var(--bad)}}
|
||||||
|
.tw{{overflow-x:auto;margin:8px 0;border:1px solid var(--line);border-radius:10px}}table{{border-collapse:collapse;width:100%;font-size:14px}}
|
||||||
|
th,td{{padding:6px 9px;border-top:1px solid var(--line);text-align:left;vertical-align:top}}thead th{{border-top:0;background:#1b1a22;white-space:nowrap}}
|
||||||
|
td:first-child{{font-weight:600}}table.num td:not(:first-child){{white-space:nowrap}}
|
||||||
|
.card2{{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 16px;margin:10px 0}}
|
||||||
|
.pair{{margin:14px 0 26px}}.ph{{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:0 0 6px}}
|
||||||
|
.panes{{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(380px,100%),1fr));gap:12px}}.pane{{min-width:0}}.lab{{font-size:13px;color:var(--muted);margin:0 0 4px}}
|
||||||
|
.decision{{padding:18px 22px;background:#13131a;border:1px solid var(--line);border-radius:14px;margin:14px 0}}.decision h3{{margin-top:0}}
|
||||||
|
.opt{{display:block;border:1px solid var(--line);border-radius:10px;padding:9px 12px;margin:7px 0;cursor:pointer;background:#100e16}}.opt:has(input:checked){{background:oklch(26% 0.07 305);border-color:var(--acc)}}
|
||||||
|
.opt .t{{display:block;color:var(--muted);font-size:14px;margin-left:24px}}.rec{{font-size:12px;color:var(--ok);margin-left:6px}}
|
||||||
|
textarea{{width:100%;min-height:70px;background:#0c0b10;color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:8px;font:inherit;margin-top:6px}}
|
||||||
|
.tabs{{display:flex;gap:6px;flex-wrap:wrap;margin:0 0 6px}}
|
||||||
|
.tab,button.replay{{font:inherit;font-size:13px;border-radius:999px;padding:4px 11px;border:1px solid var(--line);background:#1b1a22;color:var(--fg);cursor:pointer}}.tab.on{{background:oklch(26% 0.07 305);border-color:var(--acc)}}
|
||||||
|
button.replay{{background:oklch(26% 0.07 305);border-color:var(--acc)}}
|
||||||
|
form.decision button[type=submit]{{font:inherit;font-size:15px;font-weight:600;border-radius:999px;padding:8px 18px;border:1px solid var(--acc);background:oklch(26% 0.07 305);color:var(--fg);cursor:pointer;margin-top:8px}}
|
||||||
|
form.decision [role=status]{{font-size:13px;margin-left:10px;opacity:.8}}
|
||||||
|
.ctl{{position:sticky;top:0;z-index:8;background:#07070aee;backdrop-filter:blur(6px);border-bottom:1px solid var(--line);padding:8px 0;margin:0 0 8px;display:flex;gap:18px;flex-wrap:wrap;align-items:center;font-size:14px}}
|
||||||
|
.ctl label{{display:inline-flex;gap:6px;align-items:center;cursor:pointer}}
|
||||||
|
ol.plan>li{{margin:10px 0}}code{{font-size:13px;color:oklch(88% 0.08 305)}}
|
||||||
|
{CSS}
|
||||||
|
</style></head><body><main>
|
||||||
|
<h1>Wolfskin TV: faster, quieter, smoother</h1>
|
||||||
|
<p class="lead">Measured on your Xiaomi box with <b>Wolfskin (Opt)</b> 1.1.0-133 and from tank, on 2026-11-06. This page has the options, the plan, and live prototypes of the loading and motion. Each prototype sits next to a recording of the real app doing the same thing today.</p>
|
||||||
|
|
||||||
|
<div class="card2"><b>The short version</b><ul>
|
||||||
|
<li><b>The biggest wait isn't the app.</b> Two of four cold launches showed a spinner for <b>1.5 s</b>, and the other two showed none. The box looks up <code>jelly.justinvisser.org</code> through AdGuard Private DNS, and an uncached lookup takes <b>0.45–1.35 s</b>. The address only stays cached for 60 s, so the first launch after a minute's pause pays it, and so does the first title page that fetches TMDB art. A small TV-side DNS cache (or one box setting) removes it.</li>
|
||||||
|
<li><b>The second biggest is Conductarr.</b> On a title page, the detail, season and "Ready for you" reads take <b>1.1 s, 1.1 s and 2.9 s</b> at the median. Each one re-lists the whole Radarr/Sonarr library on every request, with no cache. A title page can't settle until the slowest arrives (3.4 s on a series). This needs a Conductarr release.</li>
|
||||||
|
<li><b>Screens are already quick, but they pop.</b> Home, the grids, Discover and Settings settle in <b>0.2–0.75 s</b>. What makes the wait visible is art and fields arriving in steps, and the layout jumping when a late line lands. That's fixable on the TV alone: fade art in, reserve space for late lines, keep last-known screens, and prefetch.</li>
|
||||||
|
<li><b>Frames are slow for two reasons.</b> At the median a frame takes 30–46 ms during moves, where 60 fps needs 16.7 ms. On grid and row scrolls, <b>30–44 %</b> of frames miss. The GPU alone takes 14–21 ms per frame, so motion here has to be cheap: fades, small rises and scale on one layer. No full-screen blur or shimmer sweeps.</li>
|
||||||
|
</ul></div>
|
||||||
|
|
||||||
|
<div class="ctl"><label><input type="checkbox" id="boxrate"> Play prototypes at the box's frame rate (40 ms per frame, measured)</label><label><input type="checkbox" id="rm"> Reduced motion (fades only, ≤100 ms)</label><span class="muted small">Prototypes loop. "Replay together" restarts the recording and the prototype in step. Counters are ms after the screen first reacts to the key.</span></div>
|
||||||
|
|
||||||
|
<h2>1. Where the time goes today</h2>
|
||||||
|
<h3>Per screen, from the first frame that reacts to the key until the screen stops changing</h3>
|
||||||
|
<p class="muted small">Method: on-device <code>screenrecord</code> (it only emits frames on change), scene-change detection with ffmpeg, logcat (OkHttp and ActivityManager) and Conductarr's own request log for the same seconds. "Settled" is the last visible change. Raw clips and sheets are in <code>data/tv-spike-motion-speed/</code>.</p>
|
||||||
|
{table(['Screen', 'First content', 'Settled', 'What arrives late', 'Repeat visit'], [
|
||||||
|
['Cold start (am start -W TotalTime, 4 runs)', '583 / 538 / 438 / 453 ms to first frame', 'picker at +0.3–0.4 s (DNS warm) or <b class="b">+1.65 s</b> (DNS cold)', 'Spinner while <code>Users/Me</code> then <code>System/Info/Public</code> run one after the other (ServerRepository.kt:104,107)', 'same, bimodal on the 60 s DNS cache'],
|
||||||
|
['Who\'s watching → Home', 'hero text 135 ms', '430 ms', 'shelf tiles flat grey until 400 ms, then all images pop', '370 ms'],
|
||||||
|
['Home → Discover tab', 'content 270 ms (dims first)', '700 ms', 'posters pop in over 0.4 s', '600 ms'],
|
||||||
|
['Movies grid', '230 ms (blank tiles)', '750 ms', 'posters fill row by row', '500 ms'],
|
||||||
|
['Shows grid', '280 ms', '640 ms', 'posters', '—'],
|
||||||
|
['Title, movie in library (American Gangster)', '430 ms (text)', '<b class="w">2.69 s</b>', 'backdrop 470 ms; cast portraits 2.7 s (initials until then)', '450 ms total'],
|
||||||
|
['Title, Discover pick (Donnie Brasco)', '980 ms (blank skeleton until then)', '1.53 s', 'backdrop 1.37 s, cast 1.5 s', '—'],
|
||||||
|
['Title, series (Adults)', '620 ms', '<b class="b">3.42 s</b>', 'ratings and "Ready for you · by Mindel" land at 3.4 s and push the overview down', '—'],
|
||||||
|
['Person page (Martin Sheen)', '620 ms', '720 ms', 'filmography posters', '—'],
|
||||||
|
['Back from title / series / person', '260 / 300 / 450 ms', '', '', ''],
|
||||||
|
['Settings', '220 ms', '220 ms', '—', ''],
|
||||||
|
['Coming in sheet', '74 ms', '74 ms (from cache)', 'posters refresh silently', ''],
|
||||||
|
], 'num')}
|
||||||
|
|
||||||
|
<h3>Frame timing per interaction (dumpsys gfxinfo, ART status <code>speed</code>)</h3>
|
||||||
|
{table(['Interaction', 'Frames', 'Janky', 'p50', 'p90', 'p99', 'Main thread slow / GPU slow'], [
|
||||||
|
['Home rows scroll (down 7, up 6, right 5), run 1 / 2', '149 / 115', '38.9 % / 34.8 %', '44 / 46 ms', '69 / 65', '89 / 101', '43 / 52 · 27 / 35'],
|
||||||
|
['Movies grid scroll (down 8, up 8), run 1 / 2', '180 / 159', '43.9 % / 32.1 %', '30 / 30 ms', '53 / 46', '93 / 73', '30 / 69 · 20 / 48'],
|
||||||
|
['Discover scroll', '146', '30.1 %', '34 ms', '57', '73', '34 / 35'],
|
||||||
|
['Tab switch ×4', '158', '18.4 %', '28 ms', '65', '150', '15 / 29'],
|
||||||
|
['Title open + back ×3', '103', '33.0 %', '48 ms', '113', '150', '29 / 34'],
|
||||||
|
['Who\'s watching → Home', '15', '53.3 %', '61 ms', '117', '133', '7 / 8'],
|
||||||
|
['Open title (movie, first time)', '20', '60.0 %', '57 ms', '150', '200', '9 / 12'],
|
||||||
|
['Back from person', '12', '75.0 %', '93 ms', '150', '150', '8 / 9'],
|
||||||
|
], 'num')}
|
||||||
|
<p class="muted small">Frame phases on the Shows grid (framestats, 68 frames): record p50 0.5 / p90 10.3 ms, GPU issue p50 4.3 / p90 8.9 ms, GPU complete p50 <b>14.3</b> / p90 <b>21.2</b> ms, total p50 25.6 / p90 41.4 ms. The app renders at 1920×1080 and the box outputs 3840×2160 at 59.94 Hz. Today's grid is worse than the earlier Opt measurement (17.7 % janky), which was taken before the spacing and focus-ring builds.</p>
|
||||||
|
|
||||||
|
<h3>Network, from tank (10 runs each; cold = new connection, warm = kept-alive)</h3>
|
||||||
|
{table(['Call', 'Bytes', 'Cold p50 / p95', 'Warm p50 / p95', 'Cache headers'], [
|
||||||
|
['Jellyfin UserViews (called 4–5× per launch)', '2.3 KB', '65 / 166 ms', '46 / 51 ms', 'none'],
|
||||||
|
['Home Resume (20)', '1.4 KB', '58 / 66', '30 / 41', 'none'],
|
||||||
|
['Home NextUp (20)', '6.2 KB', '71 / 91', '47 / 52', 'none'],
|
||||||
|
['Home Items/{{id}} per series (sequential)', '4.0 KB', '43 / 60', '24 / 28', 'none'],
|
||||||
|
['Home library shelf (20)', '18.7 KB', '85 / 116', '74 / 84', 'none'],
|
||||||
|
['Movies grid page (200)', '51 KB', '68 / 148', '61 / 64', 'none'],
|
||||||
|
['Title episodes with MediaSources + People', '<b class="w">96.8 KB</b>', '142 / 185', '122 / 169', 'none'],
|
||||||
|
['Title episodes, lean fields', '6.5 KB', '26 / 65', '11 / 12', 'none'],
|
||||||
|
['Grid poster, no size (today, LibraryReadFacade.kt:86)', '<b class="b">626 KB</b>', '26 / 30', '8 / 9', 'max-age 1 y, immutable, ETag'],
|
||||||
|
['Grid poster fillWidth 256×384 q96', '31.6 KB', '19 / 131', '2 / 4', 'same'],
|
||||||
|
['Grid poster 256×384 q80 WebP', '11.2 KB', '19 / 118', '1 / 2', 'same'],
|
||||||
|
['Backdrop 1280 q96 / q80 WebP', '91 / 31 KB', '19 / 25', '2 / 2', 'same'],
|
||||||
|
['TMDB poster w780 (today, ConductarrProjection.kt:9)', '131 KB', '128 / 144', '49 / 70', 'max-age 1 y'],
|
||||||
|
['TMDB poster w342 / w185', '41 / 15 KB', '100 / 76', '31 / 30', 'max-age 1 y'],
|
||||||
|
], 'num')}
|
||||||
|
<p class="muted small">Box-side DNS (ping timing, 6 samples 70 s apart): uncached <code>jelly.justinvisser.org</code> 490–1390 ms, <code>wolfskin-tv</code> 460–490 ms, <code>image.tmdb.org</code> 60–1170 ms; cached 40–50 ms. The box uses Private DNS <code>dns.adguard.com</code>. The LAN resolver 192.168.1.2 answers uncached names in 136–160 ms and cached in 0 ms. The A record behind both hosts has a 60 s TTL. Cold launches 1 and 3 were more than 60 s after the previous lookup, and 2 and 4 were within it.</p>
|
||||||
|
<h3>Conductarr TV routes, server-side (Conductarr request log, 22 h, 200/304)</h3>
|
||||||
|
{table(['Route', 'n', 'p50', 'p95', 'Body p50', 'Why'], [
|
||||||
|
['activity/titles/{{tmdb}}/{{type}} (Ready for you)', '45', '<b class="b">2856 ms</b>', '5002 ms', '1.7 KB', 'Re-reads the held title twice, the Seerr requests and a Jellyfin GET per episode; no cache (activity/detail.go:21, request_evidence.go:45, users.go:262). The 5002 ms p95 is most likely the TV giving up: its read timeout is 5 s (ConductarrSession.kt:53), so on the slowest titles the line never appears'],
|
||||||
|
['catalogue/titles/{{tmdb}}/{{type}} (detail)', '51', '<b class="b">1198 ms</b>', '3217 ms', '11.9 KB', 'Held-title read lists every Arr library unpaged, then Seerr details, then ratings; sequential (catalogue/detail.go:14-17, library_detail.go:81)'],
|
||||||
|
['catalogue/tv/{{tmdb}}/seasons/{{n}}', '76 / 17 (304)', '1084 / 2282 ms', '2664 ms', '1 KB', 'Same held-title read; a 304 still renders and hashes the full body first (read_validators.go)'],
|
||||||
|
['…/ratings', '265', '147 ms', '449 ms', '0.5 KB', '6 h server cache, 300 titles'],
|
||||||
|
['…/enrichment · …/related · …/cast', '46 · 42 · 26', '103 · 124 · 33 ms', '465 · 261 · 78', '3 · 14 · 12.5 KB', 'enrichment uncached'],
|
||||||
|
['recommendations feed', '42', '16 ms', '40 ms', '<b class="w">204 KB, uncompressed</b>', 'no gzip/br on the TV door (Caddyfile.base:316-333, serve.go:140)'],
|
||||||
|
['activity (polled every 30 s)', '713 × 304', '52 ms', '83 ms', '62.5 KB when 200', ''],
|
||||||
|
['operator · profiles · dismissals · options · display', '', '12–25 ms', '30–93 ms', '', 'cheap; display is called ≈1 400×/day, once per poster'],
|
||||||
|
], 'num')}
|
||||||
|
<p class="muted small">Every TV request also costs Conductarr an uncached Jellyfin <code>/Users/Me</code> to check the token (clients/jellyfin/token.go:42). Every authenticated response is <code>Cache-Control: no-store</code> (authority.go:32). The TV client revalidates with its own ETag store instead.</p>
|
||||||
|
|
||||||
|
<h3>Caches the app has today</h3>
|
||||||
|
{table(['Cache', 'Where', 'Behaviour', 'Observed'], [
|
||||||
|
['Wolfskin image loader (Coil 3)', 'WolfskinImages.kt:46-61', '64 MiB memory, 128 MiB disk, 4 parallel reads, crossfade off', 'repeat visits paint art from memory; first grid visit downloads 626 KB per poster (no size)'],
|
||||||
|
['Upstream Wholphin image loader', 'CoilConfig.kt:80-101', 'separate memory + 200 MB disk', 'a second copy of the same art in memory'],
|
||||||
|
['Conductarr JSON', 'FileCacheStore, noBackupFilesDir/conductarr', 'saved copy shown first, then revalidated with ETag; no memory layer', 'title re-open: every ON_OPEN route re-fetched (cast 304, detail 875 ms again)'],
|
||||||
|
['Home, library, title, person snapshots', 'filesDir/wolfskin-home, -library-cache, -title', 'disk JSON, painted first', 'repeat visits 370–500 ms instead of 430–750'],
|
||||||
|
['HTTP cache (OkHttp)', 'none', '—', 'Jellyfin JSON never cached; images rely on Coil only'],
|
||||||
|
['DNS', 'system only', '60 s TTL through Private DNS', '1.5 s launch spinner when expired'],
|
||||||
|
])}
|
||||||
|
|
||||||
|
<h2>2. Load states</h2>
|
||||||
|
<p>Today: a spinner on launch, flat grey blocks on Home, the grids and title pages, and a blank title page until Conductarr answers on Discover picks. Images snap in because crossfade is off in both loaders. Nothing shows a "loading" sentence; that rule already holds.</p>
|
||||||
|
{table(OPT_HEAD, [
|
||||||
|
opt('Picker from the saved people list, verify in the background', 'Removes the 1.5 s spinner on every DNS-cold launch (2 of 4 measured)', 'S', 'Low. A revoked session must still land on the recovery picker', 'The launcher fades straight into Who\'s watching'),
|
||||||
|
opt('Shaped skeletons that breathe (opacity only, staggered)', 'No speed change; replaces the flat grey and spinner states', 'S', 'Low. One alpha layer per row', 'Card-shaped tiles gently pulsing until the art lands'),
|
||||||
|
opt('Art fades in (120–160 ms) instead of popping', 'Removes the pop on every first visit', 'XS', 'Low. Keep it off for memory-cache hits so repeat visits stay instant', 'Posters settle instead of flickering in'),
|
||||||
|
opt('Reserve space for late fields (ratings, Ready for you, genres, overview)', 'No layout jump at 3.4 s on series pages', 'S', 'Low', 'The line fades into a gap that was already there'),
|
||||||
|
opt('Title page paints what the card already knew', 'First content 430–980 ms → about 60 ms', 'M', 'Medium. Card data must match detail (year, runtime, ratings)', 'Title, year, ratings and Play at once, over a colour wash of the poster'),
|
||||||
|
opt('Shimmer sweep skeletons', 'None', 'S', '<b class="w">High on this GPU</b>: a moving gradient over every card, every frame', 'Light sweeps across the tiles. Not recommended'),
|
||||||
|
])}
|
||||||
|
<h3>See it: launch</h3>
|
||||||
|
{pair('p-launch', 'launch', ('before-launch-spinner', 'Today, DNS cold (2 of 4 launches): 1.5 s spinner'), 'After: no spinner, people from the saved list', 180, '', False, ('before-launch-fast', 'Today, DNS warm'))}
|
||||||
|
<h3>See it: skeleton styles</h3>
|
||||||
|
<p class="muted small">Pick a style. Content lands at 900 ms in each, about the Movies grid's first visit.</p>
|
||||||
|
{pair('p-skel', 'skeleton', ('before-movies-grid', 'Today: Movies grid, first visit'), 'Options', 140, '', True)}
|
||||||
|
|
||||||
|
<h2>3. Motion and transitions</h2>
|
||||||
|
<p>Today's motion tokens: focus scale 1.08 over 140 ms, ring and caption fades of 140 ms, a nav blob spring, and 160–180 ms row scrolls. There are no screen transitions or hero cross-fades, and reduced motion is defined but nothing reads it (WolfskinTokens.kt:218). At 30–46 ms per frame, a 140 ms tween is drawn in 3–4 frames. Tick <b>box frame rate</b> at the top to see that.</p>
|
||||||
|
{table(OPT_HEAD, [
|
||||||
|
opt('Staged reveal on screen entry (hero copy rises 16 px, rows rise 24 px, staggered 30–40 ms, 180 ms ease-out)', 'Perceived: art arrives into motion instead of popping. Cost: translate + alpha on one layer per row', 'S', 'Low. Stagger must not delay focus', 'Home and Discover settle like a curtain rather than tiles snapping'),
|
||||||
|
opt('Hero cross-fade (backdrop 320 ms, copy hands off)', 'Removes the hard cut when the hero changes', 'S', 'Medium. Two 1080p bitmaps for 320 ms; the GPU is already at 14–21 ms/frame, so fade the backdrop only and keep the scrim static', 'The next pick dissolves in'),
|
||||||
|
opt('Focus: 180 ms ease-out, caption rises 10 px', 'Same frame cost as today; the curve hides dropped frames better', 'XS', 'Low', 'Focus glides rather than ticks'),
|
||||||
|
opt('Title entry: grid fades 160 ms, page builds from the poster colour', 'Hides the 470 ms backdrop fetch', 'M', 'Medium (see load states)', 'No black gap between grid and title'),
|
||||||
|
opt('Shared-element poster → title sheet', 'None measurable', 'L', '<b class="w">High</b>. Compose shared elements across NavHost destinations on this GPU; the movie page has no poster sheet when a backdrop exists', 'The card flies into place. Not recommended now'),
|
||||||
|
opt('Honour reduced motion (system animator scale 0 or a Wolfskin toggle)', 'Accessibility', 'S', 'Low', 'Fades only, ≤100 ms; the toggle at the top shows it'),
|
||||||
|
])}
|
||||||
|
<h3>See it: Who's watching → Home</h3>
|
||||||
|
{pair('p-home', 'home', ('before-home-enter', 'Today: grey tiles, then art pops at 400 ms'), 'After', 200)}
|
||||||
|
<h3>See it: tab switch and row reveal (Home → Discover)</h3>
|
||||||
|
{pair('p-tab', 'tab', ('before-discover-tab', 'Today'), 'After', 150)}
|
||||||
|
<h3>See it: title page entry</h3>
|
||||||
|
{pair('p-title', 'title', ('before-title-movie', 'Today: American Gangster, first open (cast at 2.7 s)'), 'After: Apocalypse Now from the same grid', 170, '', False, ('before-title-discover', 'Today: Donnie Brasco from Discover, blank until 980 ms'))}
|
||||||
|
<h3>See it: a late line on a series page</h3>
|
||||||
|
{pair('p-series', 'series', ('before-title-series', 'Today: Adults, the readiness line lands at 3.4 s and pushes the text down'), 'After', 150, '', True)}
|
||||||
|
<h3>See it: focus move</h3>
|
||||||
|
{pair('p-focus', 'focus', ('before-movies-grid', 'Today, real grid'), 'Focus along a row', 0, 'Tick box frame rate to compare at 25 fps.', True)}
|
||||||
|
<h3>See it: hero cross-fade</h3>
|
||||||
|
{pair('p-hero', 'hero', ('before-discover-tab', 'Today (Discover hero)'), 'Hero advances', 0, '', True)}
|
||||||
|
|
||||||
|
<h2>4. Caching</h2>
|
||||||
|
{table(OPT_HEAD, [
|
||||||
|
opt('Persisted DNS answers (OkHttp Dns: serve the last address at once, refresh behind)', 'Removes 0.45–1.35 s on launch and on the first TMDB image after any 60 s pause', 'S', 'Low. Fall back to the system lookup if the cached address fails', 'No spinner on launch; first cast photos in ≈0.6 s instead of 2.7 s [inference: DNS is the cast delay]'),
|
||||||
|
opt('Warm the connections at process start (Jellyfin, Conductarr door, TMDB) in parallel', 'Overlaps DNS + TLS with the 450 ms first frame', 'XS', 'Low', 'Same as above, smaller'),
|
||||||
|
opt('Memory layer over the Conductarr disk cache', 'Skips disk read and JSON decode on back and re-open (title re-open settles at 450 ms today; ~250 expected) [inference]', 'S', 'Low', 'Back and re-open feel instant'),
|
||||||
|
opt('Stale-while-revalidate for ON_OPEN title routes (skip refetch if validated in the last 5 min)', 'Re-opening a title stops costing the server 875–2 900 ms', 'S', 'Low. Requests and readiness may be 5 min old; keep requests on FORCE', 'Nothing visible; less churn'),
|
||||||
|
opt('Prefetch the focused card\'s title page (detail JSON + backdrop) after 350 ms of dwell', 'Title first content 430 ms → about 60 ms; backdrop already decoded', 'M', 'Medium. Bandwidth: at most one title per dwell; cancel on move', 'Opening a title is instant'),
|
||||||
|
opt('Pre-decode last-known Home art while Who\'s watching is up', 'Home settles at 430 ms → about 250 ms with no pops', 'S', 'Low. Memory: 6 backdrops ≈ 21 MiB of the 64 MiB', 'Home appears whole'),
|
||||||
|
opt('One image loader instead of two', 'Frees a second memory cache (Coil default ≈ 25 % of the heap) [inference]', 'M', 'Medium. Upstream screens use their own', 'Fewer re-downloads after big screens'),
|
||||||
|
opt('OkHttp HTTP cache for Jellyfin JSON', 'Small. Jellyfin sends no cache headers, so only conditional revalidation is possible', 'S', 'Low', 'Nothing visible'),
|
||||||
|
])}
|
||||||
|
|
||||||
|
<h2>5. Backend retrieval</h2>
|
||||||
|
{table(OPT_HEAD, [
|
||||||
|
opt('Cache the held-title read in Conductarr (Arr library list, queue, history) for 30–60 s, shared across routes', 'Title detail 1.2 s → ≈0.1 s, season 1.1 s → ≈0.1 s, readiness 2.9 s → <1 s [inference from the code path]', 'M', 'Medium. Freshness of "Ready" after an import; invalidate on Arr webhook (already received)', 'Series pages settle in under a second; no late line', 'Conductarr release'),
|
||||||
|
opt('Readiness: drop the duplicate held-title read and the per-episode Jellyfin GETs', 'The 5 s tail on readiness', 'M', 'Medium', '"Ready for you" appears with the page', 'Conductarr release'),
|
||||||
|
opt('Compress the TV door (Caddy <code>encode zstd gzip</code>)', 'Feed 204 KB → ~25–35 KB [inference: JSON typically 6–8×], activity 62 KB → ~8 KB', 'XS', 'Low', 'Discover opens a little faster on Wi-Fi', 'Caddy change (server-docs)'),
|
||||||
|
opt('304 before rendering (keep a per-route version, not a hash of the full body)', 'Season 304 2.3 s → ms', 'M', 'Medium', 'Nothing visible except speed', 'Conductarr release'),
|
||||||
|
opt('Cache the token check (<code>/Users/Me</code>) for 60 s per token', 'Removes a Jellyfin round trip from every TV request (~10–40 ms each, 6–10 per title page)', 'S', 'Low. Revocation lag 60 s', 'Nothing visible', 'Conductarr release'),
|
||||||
|
opt('Batch display reads (one call for a row of posters)', '≈1 400 display calls/day → a few dozen', 'M', 'Low', 'Coming in and Discover rows fill together', 'Conductarr release + TV'),
|
||||||
|
opt('Size the grid posters (<code>fillWidth=256&fillHeight=384&quality=80&format=Webp</code>)', '626 KB → 11 KB per poster (57×); 85 films 53 MB → 1 MB', 'XS', 'Low', 'Movies and Shows fill at once on first visit', 'TV'),
|
||||||
|
opt('Ask TMDB for w342 posters, not w780', '131 KB → 41 KB per Discover poster', 'XS', 'Low. 342 px for a 256 px card', 'Discover rows fill sooner', 'TV'),
|
||||||
|
opt('Lean episode fields (drop MediaSources/MediaStreams/People from the list; fetch them on Play)', '96.8 KB → 6.5 KB per season, 142 → 26 ms', 'S', 'Medium. The episode row shows file chips', 'Series pages settle sooner', 'TV'),
|
||||||
|
opt('Fewer launch calls: UserViews 4–5× and LiveTv/Recordings/Folders 2× per launch', '≈250 ms of serial calls', 'S', 'Low', 'Nothing visible', 'TV'),
|
||||||
|
])}
|
||||||
|
|
||||||
|
<h2>6. Kotlin and Compose</h2>
|
||||||
|
{table(OPT_HEAD, [
|
||||||
|
opt('Library load re-emits the full list per 200-item page and re-sorts each time (LibraryReadFacade.kt:96, LibraryGridState.kt:99)', 'Grid first visit; matters more as the library grows', 'S', 'Low', 'Grid appears in one step', 'TV'),
|
||||||
|
opt('Hoist the Regex in titleFallbackBackground / titleAmbientBackground (TitleArtwork.kt:152,167)', 'Draw-time cost per card', 'XS', 'Low', '—', 'TV'),
|
||||||
|
opt('Discover reads firstVisibleItemIndex in composition (DiscoverScreen.kt:107) → derivedStateOf', 'Fewer recompositions while scrolling Discover (34 of 44 janky frames were UI-thread)', 'XS', 'Low', 'Smoother Discover scroll', 'TV'),
|
||||||
|
opt('NavBar label colour read in composition (NavBar.kt:86) → draw-phase', 'Tab switch frames', 'XS', 'Low', '—', 'TV'),
|
||||||
|
opt('WolfskinImages.loader() is @Synchronized and called in composition', 'Lock on every poster recomposition', 'XS', 'Low', '—', 'TV'),
|
||||||
|
opt('Cut full-screen layers: hero blur (RenderEffect) + two full-screen gradient scrims + ambient', 'GPU 14–21 ms/frame is the floor today; pre-blurred tiny art and one combined scrim drawn once', 'M', 'Medium. Must match the approved frames', 'Smoother scroll on Home and Discover', 'TV'),
|
||||||
|
opt('Keys and contentType', 'Already done everywhere', '—', '—', '—', ''),
|
||||||
|
])}
|
||||||
|
|
||||||
|
<h2>7. Baseline profile</h2>
|
||||||
|
<p>Today: a hand seed of 6 lines (<code>app/src/optimized/baselineProfiles/baseline-prof.txt</code>), no generator and no Macrobenchmark. The box is at <code>speed</code> only because it was forced with <code>cmd package compile</code>. Every new install starts at <code>verify</code> (interpreted/JIT) until background dexopt runs.</p>
|
||||||
|
{table(['State (same box, earlier tv-perf run)', 'Cold start', 'Home scroll janky / p50 / p90', 'Movies grid'], [
|
||||||
|
['Opt just installed (verify)', '0.5–1.0 s', '51.4 % / 65 / 117 ms', '—'],
|
||||||
|
['Opt after forced AOT (speed)', '0.39–0.44 s', '29.2 % / 38 / 61 ms', '17.7 % / 19 / 36 ms'],
|
||||||
|
], 'num')}
|
||||||
|
<p><b>Expected from a recorded profile</b> (Baseline Profile Gradle plugin plus one UiAutomator journey through picker, Home, Discover, the grids, a title and a person page): a fresh install behaves like the AOT row from the first launch. That is roughly −40 % janky frames on Home scroll and up to half a second off cold start, for every build the second mate installs. It does <b>not</b> change steady state on a box already at <code>speed</code>. Effort M: the journey can run on an emulator in the build container; the box itself isn't needed. Risk low.</p>
|
||||||
|
|
||||||
|
<h2>8. Recommended plan</h2>
|
||||||
|
<ol class="plan">
|
||||||
|
<li><b>DNS and launch (TV, S, ~1 day).</b> Persisted DNS answers plus parallel connection warm-up at process start. Show the picker from saved people and check the session in the background. <i>Prove it:</i> 6 cold launches spaced >60 s apart, picker at ≤0.5 s every time (today 1.65 s in half); first cast photos on a new title ≤1 s.</li>
|
||||||
|
<li><b>Image sizes (TV, XS, hours).</b> Sized WebP grid posters, TMDB w342, lean episode fields. <i>Prove it:</i> first Movies visit settles ≤400 ms (today 750); tank payload table.</li>
|
||||||
|
<li><b>Load states and motion (TV, M, 2–3 days, against your pick on this page).</b> Breathing shaped skeletons, art fade-in for network loads only, reserved late-line slots, staged reveal, hero cross-fade, reduced motion. <i>Prove it:</i> recordings like the ones above, no layout change after first content, gfxinfo janky no worse than today.</li>
|
||||||
|
<li><b>Prefetch and memory cache (TV, M, 2 days).</b> Dwell prefetch of title pages, a memory layer over the Conductarr cache, pre-decoded Home art behind the picker. <i>Prove it:</i> title first content ≤100 ms and Home settled ≤250 ms.</li>
|
||||||
|
<li><b>Conductarr release (M–L, 2–4 days):</b> held-title cache with webhook invalidation, readiness without duplicate reads or per-episode GETs, 304 before render, token-check cache. <b>Caddy</b>: compress the TV door. <i>Prove it:</i> the route table above for 24 h, with detail, season and readiness p50 ≤200 ms and p95 ≤600 ms, and the feed on the wire ≤40 KB.</li>
|
||||||
|
<li><b>Baseline profile (TV build, M, 1–2 days).</b> Recorded journey profile. <i>Prove it:</i> fresh install without a forced compile, Home scroll janky ≤30 %, cold start ≤0.5 s.</li>
|
||||||
|
<li><b>Frame cost (TV, M, 2–3 days).</b> The Compose fixes in section 6 and fewer full-screen layers. <i>Prove it:</i> Home and grid scroll janky ≤20 %, GPU p90 ≤16 ms in framestats.</li>
|
||||||
|
</ol>
|
||||||
|
<p><b>Total:</b> about 2 weeks of TV work and 3–4 days for a Conductarr release. Steps 1–2 alone remove the two waits you can feel today (the launch spinner and grids filling slowly), in about a day and a half.</p>
|
||||||
|
<p class="muted small">A box-side alternative to step 1's DNS part: switch the box's Private DNS from <code>dns.adguard.com</code> to Automatic, so it uses the LAN resolver (136–160 ms uncached, 0 ms cached). That changes the whole box, not just Wolfskin, so it's your call; the TV-side fix works either way.</p>
|
||||||
|
<h2>9. Your picks (all of them on one page)</h2>
|
||||||
|
<p class="muted">Six decisions. Each locks the moment you send it and the build lanes start on it. Nothing here touches the TV while you are playing.</p>
|
||||||
|
<form class="decision" data-decision-key="tv-motion-speed-direction" data-lavish-question="tv-motion-speed-direction"><h3>1. Load-state and motion direction</h3>
|
||||||
|
<p class="muted">Decides plan step 3 (skeletons and motion). The prototypes above show each part.</p>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-motion-speed-direction" value="recommended" data-choice-key="tv-motion-speed-direction"> <b>A · As recommended</b> <span class="rec">recommended</span><span class="t">Breathing shaped skeletons, art fades in only when it came from the network, reserved space for late lines, hero cross-fade, reduced-motion safe.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-motion-speed-direction" value="flat" data-choice-key="tv-motion-speed-direction"> <b>B · Same, but flat skeletons</b><span class="t">Still card-shaped, no pulse. The calmest option.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-motion-speed-direction" value="shimmer" data-choice-key="tv-motion-speed-direction"> <b>C · Same, but shimmer skeletons</b><span class="t">A light sweep across the tiles. Costs GPU on this box every frame while loading.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-motion-speed-direction" value="changes" data-choice-key="tv-motion-speed-direction"> <b>D · Different</b><span class="t">Say what in the note: a prototype to change, a speed, an option to drop.</span></label>
|
||||||
|
<textarea aria-label="Notes for 1. Load-state and motion direction" name="tv-motion-speed-direction-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
|
||||||
|
<form class="decision" data-decision-key="tv-speed-quick-fixes" data-lavish-question="tv-speed-quick-fixes"><h3>2. The two quick speed fixes (about a day and a half, TV only)</h3>
|
||||||
|
<p class="muted">(a) DNS cache with stale-serve plus a picker painted from saved people, so the launch spinner goes away; (b) sized WebP grid posters, smaller TMDB art, lean episode fields, so grids fill fast. Both ship in the next TV build.</p>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-speed-quick-fixes" value="go" data-choice-key="tv-speed-quick-fixes"> <b>Go, start now</b> <span class="rec">recommended</span><span class="t">Two small lanes, merged and staged for the next install.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-speed-quick-fixes" value="a-only" data-choice-key="tv-speed-quick-fixes"> <b>Only (a), the launch spinner</b><span class="t">Leave the image sizing for later.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-speed-quick-fixes" value="b-only" data-choice-key="tv-speed-quick-fixes"> <b>Only (b), the image sizes</b><span class="t">Leave the DNS cache for later.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-speed-quick-fixes" value="later" data-choice-key="tv-speed-quick-fixes"> <b>Not now</b><span class="t">Skip until after the motion work.</span></label>
|
||||||
|
<textarea aria-label="Notes for 2. The two quick speed fixes (about a day and a half, TV only)" name="tv-speed-quick-fixes-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
|
||||||
|
<form class="decision" data-decision-key="tv-box-dns" data-lavish-question="tv-box-dns"><h3>3. Change the box Private DNS (your call, system setting)</h3>
|
||||||
|
<p class="muted">Today the box uses dns.adguard.com: an uncached lookup costs 450 to 1350 ms. Automatic uses the LAN resolver (136 to 160 ms uncached, 0 ms cached). It changes DNS for the whole box, not just Wolfskin. I do it with ADB only when the TV is free and you say so; the TV-side fix in 2(a) works either way.</p>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-box-dns" value="leave" data-choice-key="tv-box-dns"> <b>Leave it as it is</b> <span class="rec">recommended</span><span class="t">The TV-side cache covers the launch spinner.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-box-dns" value="automatic" data-choice-key="tv-box-dns"> <b>Switch to Automatic</b><span class="t">I change it when you free the TV and show before and after timings.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-box-dns" value="ask" data-choice-key="tv-box-dns"> <b>Ask me when the TV is free</b><span class="t">Decide then.</span></label>
|
||||||
|
<textarea aria-label="Notes for 3. Change the box Private DNS (your call, system setting)" name="tv-box-dns-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
|
||||||
|
<form class="decision" data-decision-key="tv-speed-later-steps" data-lavish-question="tv-speed-later-steps"><h3>4. Later plan steps to queue (TV, after the build above)</h3>
|
||||||
|
<p class="muted">Prefetch and memory cache (title pages, 2 days); recorded Baseline Profile (1 to 2 days); frame cost and Compose fixes (2 to 3 days). Each has its own proof target in section 8.</p>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-speed-later-steps" value="all" data-choice-key="tv-speed-later-steps"> <b>Queue all three in that order</b> <span class="rec">recommended</span><span class="t">They follow the motion work, one lane at a time.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-speed-later-steps" value="prefetch" data-choice-key="tv-speed-later-steps"> <b>Only prefetch and memory cache</b><span class="t">Skip the profile and frame work for now.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-speed-later-steps" value="none" data-choice-key="tv-speed-later-steps"> <b>None yet</b><span class="t">Revisit after you have seen the motion build on the TV.</span></label>
|
||||||
|
<textarea aria-label="Notes for 4. Later plan steps to queue (TV, after the build above)" name="tv-speed-later-steps-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
|
||||||
|
<form class="decision" data-decision-key="tv-conductarr-retrieval" data-lavish-question="tv-conductarr-retrieval"><h3>5. Conductarr title-read speed-up (needs a Conductarr release)</h3>
|
||||||
|
<p class="muted">Held-title cache with Arr webhook invalidation, readiness without duplicate reads or per-episode calls, 304 before render, token-check cache, compression on the TV door. 3 to 4 days, plus a release and a server-docs update. Title pages are slow because of this (readiness p50 2.9 s).</p>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-conductarr-retrieval" value="scope" data-choice-key="tv-conductarr-retrieval"> <b>Scope it first, then I decide</b> <span class="rec">recommended</span><span class="t">A short scout writes the exact change list and risks for a release; nothing ships.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-conductarr-retrieval" value="go" data-choice-key="tv-conductarr-retrieval"> <b>Go: build it and release it</b><span class="t">You are giving the word to build and release Conductarr when it is green.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-conductarr-retrieval" value="later" data-choice-key="tv-conductarr-retrieval"> <b>Not now</b><span class="t">Skip until after the TV-side wins.</span></label>
|
||||||
|
<textarea aria-label="Notes for 5. Conductarr title-read speed-up (needs a Conductarr release)" name="tv-conductarr-retrieval-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
|
||||||
|
<form class="decision" data-decision-key="tv-build-order" data-lavish-question="tv-build-order"><h3>6. When should these land on the TV?</h3>
|
||||||
|
<p class="muted">You said you will not be using the TV during this session.</p>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-build-order" value="as-ready" data-choice-key="tv-build-order"> <b>Bundle with the next install</b> <span class="rec">recommended</span><span class="t">The speed fixes and any approved motion work join the staged build; I install when you say the TV is free.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-build-order" value="separate" data-choice-key="tv-build-order"> <b>Install the speed fixes on their own first</b><span class="t">A quick build for steps 2(a) and 2(b) only, installed as soon as it is ready if you free the TV.</span></label>
|
||||||
|
<textarea aria-label="Notes for 6. When should these land on the TV?" name="tv-build-order-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
|
||||||
|
<p class="muted small">Method, raw numbers and files: <code>data/tv-spike-motion-speed/report.md</code>. Nothing was changed in the app or on the server. The box was left on the Movies tab.</p>
|
||||||
|
<script>
|
||||||
|
{JS}
|
||||||
|
document.querySelectorAll('form.decision').forEach(form=>{{form.addEventListener('submit',event=>{{event.preventDefault();const key=form.dataset.lavishQuestion;const answer=new FormData(form).get(key);const status=form.querySelector('[role=status]');if(!answer){{status.textContent='Pick an option first.';return;}}if(!window.lavish||!window.lavish.queuePrompt){{status.textContent='Open this page in the live review board to queue an answer.';return;}}const note=form.querySelector('textarea').value.trim();const label=form.querySelector('h3').textContent;window.lavish.queuePrompt(label+': '+answer+(note?'\\n'+note:''),{{tag:'choice',text:label+': '+answer,element:form,queueKey:key,data:{{question:key,answer:answer,note:note}}}});status.textContent='Queued. Press Send to Agent in the review toolbar.';}});}});
|
||||||
|
</script></main></body></html>'''
|
||||||
|
|
||||||
|
os.makedirs(ASSETS, exist_ok=True)
|
||||||
|
src = os.path.join(HERE, 'tv-motion-speed')
|
||||||
|
for f in os.listdir(src):
|
||||||
|
shutil.copyfile(os.path.join(src, f), ASSETS + f)
|
||||||
|
os.chmod(ASSETS + f, 0o660)
|
||||||
|
with open(OUT, 'w') as fh:
|
||||||
|
fh.write(page)
|
||||||
|
os.chmod(OUT, 0o660)
|
||||||
|
print(OUT, len(page))
|
||||||
56
data/tv-v2/motion-2026-11-06/delta.json
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
{
|
||||||
|
"specRevision": 6,
|
||||||
|
"direction": "A — As recommended",
|
||||||
|
"approvedReference": "https://pages.justinvisser.org/wolfskin/tv-motion-speed.html",
|
||||||
|
"frozenInputs": ["index.html", "proto.css", "proto.js", "build_page.py", "tv-motion-speed/"],
|
||||||
|
"checksums": "SHA256SUMS",
|
||||||
|
"stagePx": [1920, 1080],
|
||||||
|
"density": 2,
|
||||||
|
"skeleton": {
|
||||||
|
"fill": "#1b1b25",
|
||||||
|
"alpha": [0.55, 1, 0.55],
|
||||||
|
"cycleMs": 1400,
|
||||||
|
"halfCycleEasing": [0.42, 0, 0.58, 1],
|
||||||
|
"layerScope": "one layer per shelf or bounded header block; no per-card stagger",
|
||||||
|
"shimmer": false,
|
||||||
|
"focusable": false
|
||||||
|
},
|
||||||
|
"art": {
|
||||||
|
"networkRevealMs": 160,
|
||||||
|
"memoryDiskLocalRevealMs": 0,
|
||||||
|
"loaders": ["WolfskinImages", "upstream CoilConfig"],
|
||||||
|
"requestUrlChanges": false
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"backdropFadeMs": 320,
|
||||||
|
"backdropEasing": [0.65, 0, 0.35, 1],
|
||||||
|
"homeBlendBoundsPx": [1920, 820],
|
||||||
|
"discoverBlendBoundsPx": [1920, 740],
|
||||||
|
"holdPreviousUntilIncomingArtOrFallback": true,
|
||||||
|
"copyOutMs": 120,
|
||||||
|
"copyEntryAtMs": 140,
|
||||||
|
"copyInMs": 200,
|
||||||
|
"copyRisePx": 14
|
||||||
|
},
|
||||||
|
"focus": {
|
||||||
|
"durationMs": 180,
|
||||||
|
"easing": [0.33, 1, 0.68, 1],
|
||||||
|
"captionDelayMs": 60,
|
||||||
|
"captionRisePx": 10,
|
||||||
|
"captionOutMs": 100,
|
||||||
|
"geometry": "unchanged"
|
||||||
|
},
|
||||||
|
"rowEntry": {"durationMs": 180, "risePx": 24, "scope": "one row, never full screen"},
|
||||||
|
"lateSeriesReadiness": {"reservedHeightPx": 56, "fadeMs": 200, "translationPx": 0},
|
||||||
|
"reducedMotion": {
|
||||||
|
"sources": ["Compose window MotionDurationScale == 0", "LocalReducedMotion host flag"],
|
||||||
|
"maximumFadeMs": 80,
|
||||||
|
"pulse": false,
|
||||||
|
"translation": false,
|
||||||
|
"scrollAnimation": false
|
||||||
|
},
|
||||||
|
"nativeFrames": "https://pages.justinvisser.org/wolfskin/tv-motion-build/",
|
||||||
|
"onDeviceAcceptance": {"homeJankyPercentMaximum": 39.6, "homeP90MsMaximum": 65, "lateSeriesLayoutShiftPx": 0},
|
||||||
|
"deviceAccess": "not performed by this worker",
|
||||||
|
"specification": "../../tv-spec/spec.md"
|
||||||
|
}
|
||||||
483
data/tv-v2/motion-2026-11-06/index.html
Normal file
|
|
@ -0,0 +1,483 @@
|
||||||
|
<!doctype html><html lang="en" data-lavish-annotate="off"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>Wolfskin TV: faster, quieter, smoother</title><style>
|
||||||
|
:root{color-scheme:dark;--bg:#07070a;--panel:#13131a;--line:rgba(255,255,255,.1);--fg:#f4f4f6;--muted:rgba(244,244,246,.74);--acc:oklch(78% 0.14 305);--ok:#7ed6a0;--warn:#ffc46b;--bad:#ff8a7a}
|
||||||
|
*{box-sizing:border-box}html{-webkit-text-size-adjust:100%}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,Segoe UI,sans-serif}
|
||||||
|
main{max-width:1400px;margin:0 auto;padding:16px 14px 120px}h1{font-size:28px;line-height:1.2;margin:6px 0 6px}h2{font-size:21px;margin:34px 0 8px;color:var(--acc);border-bottom:1px solid var(--line);padding-bottom:4px}
|
||||||
|
h3{font-size:17px;margin:20px 0 6px}a{color:var(--acc)}.muted{color:var(--muted)}.small{font-size:13px}p{margin:6px 0}li{margin:3px 0}
|
||||||
|
.lead{font-size:17px}.k{color:var(--ok)}.w{color:var(--warn)}.b{color:var(--bad)}
|
||||||
|
.tw{overflow-x:auto;margin:8px 0;border:1px solid var(--line);border-radius:10px}table{border-collapse:collapse;width:100%;font-size:14px}
|
||||||
|
th,td{padding:6px 9px;border-top:1px solid var(--line);text-align:left;vertical-align:top}thead th{border-top:0;background:#1b1a22;white-space:nowrap}
|
||||||
|
td:first-child{font-weight:600}table.num td:not(:first-child){white-space:nowrap}
|
||||||
|
.card2{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 16px;margin:10px 0}
|
||||||
|
.pair{margin:14px 0 26px}.ph{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:0 0 6px}
|
||||||
|
.panes{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(380px,100%),1fr));gap:12px}.pane{min-width:0}.lab{font-size:13px;color:var(--muted);margin:0 0 4px}
|
||||||
|
.decision{padding:18px 22px;background:#13131a;border:1px solid var(--line);border-radius:14px;margin:14px 0}.decision h3{margin-top:0}
|
||||||
|
.opt{display:block;border:1px solid var(--line);border-radius:10px;padding:9px 12px;margin:7px 0;cursor:pointer;background:#100e16}.opt:has(input:checked){background:oklch(26% 0.07 305);border-color:var(--acc)}
|
||||||
|
.opt .t{display:block;color:var(--muted);font-size:14px;margin-left:24px}.rec{font-size:12px;color:var(--ok);margin-left:6px}
|
||||||
|
textarea{width:100%;min-height:70px;background:#0c0b10;color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:8px;font:inherit;margin-top:6px}
|
||||||
|
.tabs{display:flex;gap:6px;flex-wrap:wrap;margin:0 0 6px}
|
||||||
|
.tab,button.replay{font:inherit;font-size:13px;border-radius:999px;padding:4px 11px;border:1px solid var(--line);background:#1b1a22;color:var(--fg);cursor:pointer}.tab.on{background:oklch(26% 0.07 305);border-color:var(--acc)}
|
||||||
|
button.replay{background:oklch(26% 0.07 305);border-color:var(--acc)}
|
||||||
|
form.decision button[type=submit]{font:inherit;font-size:15px;font-weight:600;border-radius:999px;padding:8px 18px;border:1px solid var(--acc);background:oklch(26% 0.07 305);color:var(--fg);cursor:pointer;margin-top:8px}
|
||||||
|
form.decision [role=status]{font-size:13px;margin-left:10px;opacity:.8}
|
||||||
|
.ctl{position:sticky;top:0;z-index:8;background:#07070aee;backdrop-filter:blur(6px);border-bottom:1px solid var(--line);padding:8px 0;margin:0 0 8px;display:flex;gap:18px;flex-wrap:wrap;align-items:center;font-size:14px}
|
||||||
|
.ctl label{display:inline-flex;gap:6px;align-items:center;cursor:pointer}
|
||||||
|
ol.plan>li{margin:10px 0}code{font-size:13px;color:oklch(88% 0.08 305)}
|
||||||
|
/* Prototype stages: 1920x1080 CSS px, approved tokens from data/tv-v2/focus-2026-11-06 (colors, type, ring, card sizes at Level 2 spacing). */
|
||||||
|
.sbox{position:relative;width:100%;aspect-ratio:16/9;overflow:hidden;border-radius:8px;border:1px solid var(--line);background:#000}
|
||||||
|
.sbox video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
|
||||||
|
.stage{position:absolute;left:0;top:0;width:1920px;height:1080px;transform-origin:0 0;overflow:hidden;background:#07070a;font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Noto Sans",sans-serif;color:#f4f4f6;font-size:28px;line-height:1.3;
|
||||||
|
--bg:#07070a;--surface:#13131a;--surface2:#1b1b25;--surface3:#252532;--muted:rgb(244 244 246/.74);--dim:rgb(244 244 246/.56);--accent:oklch(78% 0.14 305);--accent-text:oklch(88% 0.08 305)}
|
||||||
|
.stage *{box-sizing:border-box}
|
||||||
|
.stage .abs{position:absolute}
|
||||||
|
/* nav */
|
||||||
|
.stage .nav{position:absolute;left:0;top:0;width:1920px;height:150px;z-index:5;display:flex;align-items:center;gap:12px;padding:44px 96px 0}
|
||||||
|
.stage .wm{font-size:36px;font-weight:800;letter-spacing:-.02em;margin-right:40px}.stage .wm b{color:var(--accent)}
|
||||||
|
.stage .ni{height:64px;padding:0 26px;border-radius:32px;display:inline-flex;align-items:center;font-size:30px;font-weight:600;color:var(--muted);position:relative}
|
||||||
|
.stage .ni.cur{color:#f4f4f6}.stage .ni.cur::after{content:"";position:absolute;left:26px;right:26px;bottom:4px;height:4px;border-radius:2px;background:var(--accent)}
|
||||||
|
.stage .ni.f{background:#f4f4f6;color:#0b0b10}.stage .ni.f::after{display:none}
|
||||||
|
.stage .nsp{flex:1}.stage .av{width:64px;height:64px;border-radius:50%;display:grid;place-items:center;font-weight:800;color:#0b0b10;font-size:30px;background:#9be38f}
|
||||||
|
/* hero */
|
||||||
|
.stage .amb{position:absolute;inset:0;background-size:cover;background-position:center;image-rendering:auto}
|
||||||
|
.stage .bd{position:absolute;inset:0;background-size:cover;background-position:center}
|
||||||
|
.stage .scrim{position:absolute;inset:0;background:linear-gradient(90deg,rgb(7 7 10/.92) 0%,rgb(7 7 10/.55) 42%,rgb(7 7 10/0) 70%),linear-gradient(0deg,#07070a 0%,rgb(7 7 10/.6) 26%,rgb(7 7 10/0) 48%)}
|
||||||
|
.stage .copy{position:absolute;left:96px;width:1000px}
|
||||||
|
.stage .kicker{font-size:26px;font-weight:650;color:var(--accent-text);margin-bottom:10px}
|
||||||
|
.stage .ht{font-size:84px;line-height:1.02;font-weight:800;letter-spacing:-.02em;margin:0 0 16px}
|
||||||
|
.stage .hm{font-size:28px;color:var(--muted);margin-bottom:14px;min-height:38px;display:flex;gap:22px;align-items:center}
|
||||||
|
.stage .ho{font-size:28px;color:var(--muted);margin:0 0 30px;max-width:960px}
|
||||||
|
.stage .btns{display:flex;gap:20px}
|
||||||
|
.stage .btn{height:76px;padding:0 38px;border-radius:38px;font-size:28px;font-weight:700;display:inline-flex;align-items:center;background:rgb(255 255 255/.14)}
|
||||||
|
.stage .btn.pri{background:#f4f4f6;color:#0b0b10}
|
||||||
|
.stage .btn.f{box-shadow:0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent);transform:scale(1.05)}
|
||||||
|
.stage .rate{display:inline-flex;gap:10px;align-items:center;font-weight:700;color:#f4f4f6}.stage .rate i{width:24px;height:24px;border-radius:50%;background:#e2483a;display:inline-block}
|
||||||
|
.stage .rate i.pc{background:#f5c518;border-radius:4px}
|
||||||
|
.stage .slot{display:inline-block;height:30px;border-radius:15px;background:rgb(255 255 255/.10)}
|
||||||
|
/* rows + cards */
|
||||||
|
.stage .sh{position:absolute;left:96px;font-size:34px;font-weight:700}
|
||||||
|
.stage .card{position:absolute;border-radius:16px;overflow:hidden;background:var(--surface);background-size:cover;background-position:center}
|
||||||
|
.stage .card.poster{width:256px;height:384px}.stage .card.tile{width:400px;height:225px}
|
||||||
|
.stage .card .img{position:absolute;inset:0;background-size:cover;background-position:center}
|
||||||
|
.stage .card .cap{position:absolute;left:0;right:0;bottom:0;padding:14px 18px;background:linear-gradient(rgb(7 7 10/0),rgb(7 7 10/.92));font-size:26px;font-weight:700}
|
||||||
|
.stage .ring{box-shadow:0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent),0 18px 40px rgb(0 0 0/.55)}
|
||||||
|
.stage .circle{position:absolute;width:200px;height:200px;border-radius:50%;overflow:hidden;background:var(--surface2);display:grid;place-items:center;font-size:44px;font-weight:800;color:var(--dim)}
|
||||||
|
.stage .circle .img{position:absolute;inset:0;background-size:cover;background-position:center}
|
||||||
|
.stage .bar{position:absolute;height:28px;border-radius:14px;background:var(--surface2)}
|
||||||
|
.stage .spin{position:absolute;left:940px;top:520px;width:40px;height:40px;border-radius:50%;border:4px solid rgb(155 120 255/.25);border-top-color:oklch(65% 0.2 300);animation:spin .8s linear infinite}
|
||||||
|
/* picker */
|
||||||
|
.stage .pk{position:absolute;top:290px;left:0;width:1920px;text-align:center;font-size:72px;font-weight:800;letter-spacing:-.02em}
|
||||||
|
.stage .person{position:absolute;top:426px;width:220px;height:220px;border-radius:50%;display:grid;place-items:center;font-size:110px;font-weight:800;color:#0b0b10}
|
||||||
|
.stage .pname{position:absolute;top:670px;width:220px;text-align:center;font-size:34px;font-weight:700;color:var(--muted)}
|
||||||
|
/* replay label */
|
||||||
|
.stage .tl{position:absolute;right:14px;bottom:10px;font:22px/1 ui-monospace,Menlo,monospace;background:rgb(0 0 0/.6);padding:6px 8px;border-radius:6px;color:#fff;z-index:20}
|
||||||
|
|
||||||
|
/* keyframes */
|
||||||
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
@keyframes fade{from{opacity:0}to{opacity:1}}
|
||||||
|
@keyframes fadeout{from{opacity:1}to{opacity:0}}
|
||||||
|
@keyframes rise{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:none}}
|
||||||
|
@keyframes breathe{0%,100%{opacity:.55}50%{opacity:1}}
|
||||||
|
@keyframes shimmer{from{transform:translateX(-100%)}to{transform:translateX(100%)}}
|
||||||
|
@keyframes hide{to{opacity:0;visibility:hidden}}
|
||||||
|
@keyframes show{from{opacity:0}to{opacity:1}}
|
||||||
|
|
||||||
|
/* anything with .an waits for .play */
|
||||||
|
.stage .an{opacity:0}
|
||||||
|
.stage.play .an{animation-fill-mode:both}
|
||||||
|
.stage .gone{animation-fill-mode:both}
|
||||||
|
/* skeleton styles */
|
||||||
|
.stage .sk{background:var(--surface2)}
|
||||||
|
.stage .sk.breathe{animation:breathe 1.4s ease-in-out infinite}
|
||||||
|
.stage .sk.shim{position:relative;overflow:hidden}
|
||||||
|
.stage .sk.shim::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgb(255 255 255/.07),transparent);animation:shimmer 1.3s linear infinite}
|
||||||
|
|
||||||
|
/* reduced motion: no movement, short fades only */
|
||||||
|
body.rm .stage.play .an{animation-name:fade!important;animation-duration:.08s!important}
|
||||||
|
body.rm .stage .sk.breathe,body.rm .stage .sk.shim::after{animation:none}
|
||||||
|
body.rm .stage .mv{transition:none!important}
|
||||||
|
|
||||||
|
</style></head><body><main>
|
||||||
|
<h1>Wolfskin TV: faster, quieter, smoother</h1>
|
||||||
|
<p class="lead">Measured on your Xiaomi box with <b>Wolfskin (Opt)</b> 1.1.0-133 and from tank, on 2026-11-06. This page has the options, the plan, and live prototypes of the loading and motion. Each prototype sits next to a recording of the real app doing the same thing today.</p>
|
||||||
|
|
||||||
|
<div class="card2"><b>The short version</b><ul>
|
||||||
|
<li><b>The biggest wait isn't the app.</b> Two of four cold launches showed a spinner for <b>1.5 s</b>, and the other two showed none. The box looks up <code>jelly.justinvisser.org</code> through AdGuard Private DNS, and an uncached lookup takes <b>0.45–1.35 s</b>. The address only stays cached for 60 s, so the first launch after a minute's pause pays it, and so does the first title page that fetches TMDB art. A small TV-side DNS cache (or one box setting) removes it.</li>
|
||||||
|
<li><b>The second biggest is Conductarr.</b> On a title page, the detail, season and "Ready for you" reads take <b>1.1 s, 1.1 s and 2.9 s</b> at the median. Each one re-lists the whole Radarr/Sonarr library on every request, with no cache. A title page can't settle until the slowest arrives (3.4 s on a series). This needs a Conductarr release.</li>
|
||||||
|
<li><b>Screens are already quick, but they pop.</b> Home, the grids, Discover and Settings settle in <b>0.2–0.75 s</b>. What makes the wait visible is art and fields arriving in steps, and the layout jumping when a late line lands. That's fixable on the TV alone: fade art in, reserve space for late lines, keep last-known screens, and prefetch.</li>
|
||||||
|
<li><b>Frames are slow for two reasons.</b> At the median a frame takes 30–46 ms during moves, where 60 fps needs 16.7 ms. On grid and row scrolls, <b>30–44 %</b> of frames miss. The GPU alone takes 14–21 ms per frame, so motion here has to be cheap: fades, small rises and scale on one layer. No full-screen blur or shimmer sweeps.</li>
|
||||||
|
</ul></div>
|
||||||
|
|
||||||
|
<div class="ctl"><label><input type="checkbox" id="boxrate"> Play prototypes at the box's frame rate (40 ms per frame, measured)</label><label><input type="checkbox" id="rm"> Reduced motion (fades only, ≤100 ms)</label><span class="muted small">Prototypes loop. "Replay together" restarts the recording and the prototype in step. Counters are ms after the screen first reacts to the key.</span></div>
|
||||||
|
|
||||||
|
<h2>1. Where the time goes today</h2>
|
||||||
|
<h3>Per screen, from the first frame that reacts to the key until the screen stops changing</h3>
|
||||||
|
<p class="muted small">Method: on-device <code>screenrecord</code> (it only emits frames on change), scene-change detection with ffmpeg, logcat (OkHttp and ActivityManager) and Conductarr's own request log for the same seconds. "Settled" is the last visible change. Raw clips and sheets are in <code>data/tv-spike-motion-speed/</code>.</p>
|
||||||
|
<div class="tw"><table class="num"><thead><tr><th>Screen</th><th>First content</th><th>Settled</th><th>What arrives late</th><th>Repeat visit</th></tr></thead><tbody><tr><td>Cold start (am start -W TotalTime, 4 runs)</td><td>583 / 538 / 438 / 453 ms to first frame</td><td>picker at +0.3–0.4 s (DNS warm) or <b class="b">+1.65 s</b> (DNS cold)</td><td>Spinner while <code>Users/Me</code> then <code>System/Info/Public</code> run one after the other (ServerRepository.kt:104,107)</td><td>same, bimodal on the 60 s DNS cache</td></tr><tr><td>Who's watching → Home</td><td>hero text 135 ms</td><td>430 ms</td><td>shelf tiles flat grey until 400 ms, then all images pop</td><td>370 ms</td></tr><tr><td>Home → Discover tab</td><td>content 270 ms (dims first)</td><td>700 ms</td><td>posters pop in over 0.4 s</td><td>600 ms</td></tr><tr><td>Movies grid</td><td>230 ms (blank tiles)</td><td>750 ms</td><td>posters fill row by row</td><td>500 ms</td></tr><tr><td>Shows grid</td><td>280 ms</td><td>640 ms</td><td>posters</td><td>—</td></tr><tr><td>Title, movie in library (American Gangster)</td><td>430 ms (text)</td><td><b class="w">2.69 s</b></td><td>backdrop 470 ms; cast portraits 2.7 s (initials until then)</td><td>450 ms total</td></tr><tr><td>Title, Discover pick (Donnie Brasco)</td><td>980 ms (blank skeleton until then)</td><td>1.53 s</td><td>backdrop 1.37 s, cast 1.5 s</td><td>—</td></tr><tr><td>Title, series (Adults)</td><td>620 ms</td><td><b class="b">3.42 s</b></td><td>ratings and "Ready for you · by Mindel" land at 3.4 s and push the overview down</td><td>—</td></tr><tr><td>Person page (Martin Sheen)</td><td>620 ms</td><td>720 ms</td><td>filmography posters</td><td>—</td></tr><tr><td>Back from title / series / person</td><td>260 / 300 / 450 ms</td><td></td><td></td><td></td></tr><tr><td>Settings</td><td>220 ms</td><td>220 ms</td><td>—</td><td></td></tr><tr><td>Coming in sheet</td><td>74 ms</td><td>74 ms (from cache)</td><td>posters refresh silently</td><td></td></tr></tbody></table></div>
|
||||||
|
|
||||||
|
<h3>Frame timing per interaction (dumpsys gfxinfo, ART status <code>speed</code>)</h3>
|
||||||
|
<div class="tw"><table class="num"><thead><tr><th>Interaction</th><th>Frames</th><th>Janky</th><th>p50</th><th>p90</th><th>p99</th><th>Main thread slow / GPU slow</th></tr></thead><tbody><tr><td>Home rows scroll (down 7, up 6, right 5), run 1 / 2</td><td>149 / 115</td><td>38.9 % / 34.8 %</td><td>44 / 46 ms</td><td>69 / 65</td><td>89 / 101</td><td>43 / 52 · 27 / 35</td></tr><tr><td>Movies grid scroll (down 8, up 8), run 1 / 2</td><td>180 / 159</td><td>43.9 % / 32.1 %</td><td>30 / 30 ms</td><td>53 / 46</td><td>93 / 73</td><td>30 / 69 · 20 / 48</td></tr><tr><td>Discover scroll</td><td>146</td><td>30.1 %</td><td>34 ms</td><td>57</td><td>73</td><td>34 / 35</td></tr><tr><td>Tab switch ×4</td><td>158</td><td>18.4 %</td><td>28 ms</td><td>65</td><td>150</td><td>15 / 29</td></tr><tr><td>Title open + back ×3</td><td>103</td><td>33.0 %</td><td>48 ms</td><td>113</td><td>150</td><td>29 / 34</td></tr><tr><td>Who's watching → Home</td><td>15</td><td>53.3 %</td><td>61 ms</td><td>117</td><td>133</td><td>7 / 8</td></tr><tr><td>Open title (movie, first time)</td><td>20</td><td>60.0 %</td><td>57 ms</td><td>150</td><td>200</td><td>9 / 12</td></tr><tr><td>Back from person</td><td>12</td><td>75.0 %</td><td>93 ms</td><td>150</td><td>150</td><td>8 / 9</td></tr></tbody></table></div>
|
||||||
|
<p class="muted small">Frame phases on the Shows grid (framestats, 68 frames): record p50 0.5 / p90 10.3 ms, GPU issue p50 4.3 / p90 8.9 ms, GPU complete p50 <b>14.3</b> / p90 <b>21.2</b> ms, total p50 25.6 / p90 41.4 ms. The app renders at 1920×1080 and the box outputs 3840×2160 at 59.94 Hz. Today's grid is worse than the earlier Opt measurement (17.7 % janky), which was taken before the spacing and focus-ring builds.</p>
|
||||||
|
|
||||||
|
<h3>Network, from tank (10 runs each; cold = new connection, warm = kept-alive)</h3>
|
||||||
|
<div class="tw"><table class="num"><thead><tr><th>Call</th><th>Bytes</th><th>Cold p50 / p95</th><th>Warm p50 / p95</th><th>Cache headers</th></tr></thead><tbody><tr><td>Jellyfin UserViews (called 4–5× per launch)</td><td>2.3 KB</td><td>65 / 166 ms</td><td>46 / 51 ms</td><td>none</td></tr><tr><td>Home Resume (20)</td><td>1.4 KB</td><td>58 / 66</td><td>30 / 41</td><td>none</td></tr><tr><td>Home NextUp (20)</td><td>6.2 KB</td><td>71 / 91</td><td>47 / 52</td><td>none</td></tr><tr><td>Home Items/{{id}} per series (sequential)</td><td>4.0 KB</td><td>43 / 60</td><td>24 / 28</td><td>none</td></tr><tr><td>Home library shelf (20)</td><td>18.7 KB</td><td>85 / 116</td><td>74 / 84</td><td>none</td></tr><tr><td>Movies grid page (200)</td><td>51 KB</td><td>68 / 148</td><td>61 / 64</td><td>none</td></tr><tr><td>Title episodes with MediaSources + People</td><td><b class="w">96.8 KB</b></td><td>142 / 185</td><td>122 / 169</td><td>none</td></tr><tr><td>Title episodes, lean fields</td><td>6.5 KB</td><td>26 / 65</td><td>11 / 12</td><td>none</td></tr><tr><td>Grid poster, no size (today, LibraryReadFacade.kt:86)</td><td><b class="b">626 KB</b></td><td>26 / 30</td><td>8 / 9</td><td>max-age 1 y, immutable, ETag</td></tr><tr><td>Grid poster fillWidth 256×384 q96</td><td>31.6 KB</td><td>19 / 131</td><td>2 / 4</td><td>same</td></tr><tr><td>Grid poster 256×384 q80 WebP</td><td>11.2 KB</td><td>19 / 118</td><td>1 / 2</td><td>same</td></tr><tr><td>Backdrop 1280 q96 / q80 WebP</td><td>91 / 31 KB</td><td>19 / 25</td><td>2 / 2</td><td>same</td></tr><tr><td>TMDB poster w780 (today, ConductarrProjection.kt:9)</td><td>131 KB</td><td>128 / 144</td><td>49 / 70</td><td>max-age 1 y</td></tr><tr><td>TMDB poster w342 / w185</td><td>41 / 15 KB</td><td>100 / 76</td><td>31 / 30</td><td>max-age 1 y</td></tr></tbody></table></div>
|
||||||
|
<p class="muted small">Box-side DNS (ping timing, 6 samples 70 s apart): uncached <code>jelly.justinvisser.org</code> 490–1390 ms, <code>wolfskin-tv</code> 460–490 ms, <code>image.tmdb.org</code> 60–1170 ms; cached 40–50 ms. The box uses Private DNS <code>dns.adguard.com</code>. The LAN resolver 192.168.1.2 answers uncached names in 136–160 ms and cached in 0 ms. The A record behind both hosts has a 60 s TTL. Cold launches 1 and 3 were more than 60 s after the previous lookup, and 2 and 4 were within it.</p>
|
||||||
|
<h3>Conductarr TV routes, server-side (Conductarr request log, 22 h, 200/304)</h3>
|
||||||
|
<div class="tw"><table class="num"><thead><tr><th>Route</th><th>n</th><th>p50</th><th>p95</th><th>Body p50</th><th>Why</th></tr></thead><tbody><tr><td>activity/titles/{{tmdb}}/{{type}} (Ready for you)</td><td>45</td><td><b class="b">2856 ms</b></td><td>5002 ms</td><td>1.7 KB</td><td>Re-reads the held title twice, the Seerr requests and a Jellyfin GET per episode; no cache (activity/detail.go:21, request_evidence.go:45, users.go:262). The 5002 ms p95 is most likely the TV giving up: its read timeout is 5 s (ConductarrSession.kt:53), so on the slowest titles the line never appears</td></tr><tr><td>catalogue/titles/{{tmdb}}/{{type}} (detail)</td><td>51</td><td><b class="b">1198 ms</b></td><td>3217 ms</td><td>11.9 KB</td><td>Held-title read lists every Arr library unpaged, then Seerr details, then ratings; sequential (catalogue/detail.go:14-17, library_detail.go:81)</td></tr><tr><td>catalogue/tv/{{tmdb}}/seasons/{{n}}</td><td>76 / 17 (304)</td><td>1084 / 2282 ms</td><td>2664 ms</td><td>1 KB</td><td>Same held-title read; a 304 still renders and hashes the full body first (read_validators.go)</td></tr><tr><td>…/ratings</td><td>265</td><td>147 ms</td><td>449 ms</td><td>0.5 KB</td><td>6 h server cache, 300 titles</td></tr><tr><td>…/enrichment · …/related · …/cast</td><td>46 · 42 · 26</td><td>103 · 124 · 33 ms</td><td>465 · 261 · 78</td><td>3 · 14 · 12.5 KB</td><td>enrichment uncached</td></tr><tr><td>recommendations feed</td><td>42</td><td>16 ms</td><td>40 ms</td><td><b class="w">204 KB, uncompressed</b></td><td>no gzip/br on the TV door (Caddyfile.base:316-333, serve.go:140)</td></tr><tr><td>activity (polled every 30 s)</td><td>713 × 304</td><td>52 ms</td><td>83 ms</td><td>62.5 KB when 200</td><td></td></tr><tr><td>operator · profiles · dismissals · options · display</td><td></td><td>12–25 ms</td><td>30–93 ms</td><td></td><td>cheap; display is called ≈1 400×/day, once per poster</td></tr></tbody></table></div>
|
||||||
|
<p class="muted small">Every TV request also costs Conductarr an uncached Jellyfin <code>/Users/Me</code> to check the token (clients/jellyfin/token.go:42). Every authenticated response is <code>Cache-Control: no-store</code> (authority.go:32). The TV client revalidates with its own ETag store instead.</p>
|
||||||
|
|
||||||
|
<h3>Caches the app has today</h3>
|
||||||
|
<div class="tw"><table class=""><thead><tr><th>Cache</th><th>Where</th><th>Behaviour</th><th>Observed</th></tr></thead><tbody><tr><td>Wolfskin image loader (Coil 3)</td><td>WolfskinImages.kt:46-61</td><td>64 MiB memory, 128 MiB disk, 4 parallel reads, crossfade off</td><td>repeat visits paint art from memory; first grid visit downloads 626 KB per poster (no size)</td></tr><tr><td>Upstream Wholphin image loader</td><td>CoilConfig.kt:80-101</td><td>separate memory + 200 MB disk</td><td>a second copy of the same art in memory</td></tr><tr><td>Conductarr JSON</td><td>FileCacheStore, noBackupFilesDir/conductarr</td><td>saved copy shown first, then revalidated with ETag; no memory layer</td><td>title re-open: every ON_OPEN route re-fetched (cast 304, detail 875 ms again)</td></tr><tr><td>Home, library, title, person snapshots</td><td>filesDir/wolfskin-home, -library-cache, -title</td><td>disk JSON, painted first</td><td>repeat visits 370–500 ms instead of 430–750</td></tr><tr><td>HTTP cache (OkHttp)</td><td>none</td><td>—</td><td>Jellyfin JSON never cached; images rely on Coil only</td></tr><tr><td>DNS</td><td>system only</td><td>60 s TTL through Private DNS</td><td>1.5 s launch spinner when expired</td></tr></tbody></table></div>
|
||||||
|
|
||||||
|
<h2>2. Load states</h2>
|
||||||
|
<p>Today: a spinner on launch, flat grey blocks on Home, the grids and title pages, and a blank title page until Conductarr answers on Discover picks. Images snap in because crossfade is off in both loaders. Nothing shows a "loading" sentence; that rule already holds.</p>
|
||||||
|
<div class="tw"><table class=""><thead><tr><th>Option</th><th>Expected gain (measured basis)</th><th>Effort</th><th>Risk</th><th>What you would see</th><th>Ships in</th></tr></thead><tbody><tr><td>Picker from the saved people list, verify in the background</td><td>Removes the 1.5 s spinner on every DNS-cold launch (2 of 4 measured)</td><td>S</td><td>Low. A revoked session must still land on the recovery picker</td><td>The launcher fades straight into Who's watching</td><td>TV</td></tr><tr><td>Shaped skeletons that breathe (opacity only, staggered)</td><td>No speed change; replaces the flat grey and spinner states</td><td>S</td><td>Low. One alpha layer per row</td><td>Card-shaped tiles gently pulsing until the art lands</td><td>TV</td></tr><tr><td>Art fades in (120–160 ms) instead of popping</td><td>Removes the pop on every first visit</td><td>XS</td><td>Low. Keep it off for memory-cache hits so repeat visits stay instant</td><td>Posters settle instead of flickering in</td><td>TV</td></tr><tr><td>Reserve space for late fields (ratings, Ready for you, genres, overview)</td><td>No layout jump at 3.4 s on series pages</td><td>S</td><td>Low</td><td>The line fades into a gap that was already there</td><td>TV</td></tr><tr><td>Title page paints what the card already knew</td><td>First content 430–980 ms → about 60 ms</td><td>M</td><td>Medium. Card data must match detail (year, runtime, ratings)</td><td>Title, year, ratings and Play at once, over a colour wash of the poster</td><td>TV</td></tr><tr><td>Shimmer sweep skeletons</td><td>None</td><td>S</td><td><b class="w">High on this GPU</b>: a moving gradient over every card, every frame</td><td>Light sweeps across the tiles. Not recommended</td><td>TV</td></tr></tbody></table></div>
|
||||||
|
<h3>See it: launch</h3>
|
||||||
|
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button></div>
|
||||||
|
<div class="panes"><div class="pane"><p class="lab">Today, DNS cold (2 of 4 launches): 1.5 s spinner</p><div class="sbox"><video src="tv-motion-speed/before-launch-spinner.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">Today, DNS warm</p><div class="sbox"><video src="tv-motion-speed/before-launch-fast.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">After: no spinner, people from the saved list</p><div class="sbox" id="p-launch" data-scene="launch" data-lead="180"></div></div></div></div>
|
||||||
|
<h3>See it: skeleton styles</h3>
|
||||||
|
<p class="muted small">Pick a style. Content lands at 900 ms in each, about the Movies grid's first visit.</p>
|
||||||
|
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button></div>
|
||||||
|
<div class="panes"><div class="pane"><p class="lab">Today: Movies grid, first visit</p><div class="sbox"><video src="tv-motion-speed/before-movies-grid.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">Options</p><div class="tabs" data-variants="p-skel"></div><div class="sbox" id="p-skel" data-scene="skeleton" data-lead="140"></div></div></div></div>
|
||||||
|
|
||||||
|
<h2>3. Motion and transitions</h2>
|
||||||
|
<p>Today's motion tokens: focus scale 1.08 over 140 ms, ring and caption fades of 140 ms, a nav blob spring, and 160–180 ms row scrolls. There are no screen transitions or hero cross-fades, and reduced motion is defined but nothing reads it (WolfskinTokens.kt:218). At 30–46 ms per frame, a 140 ms tween is drawn in 3–4 frames. Tick <b>box frame rate</b> at the top to see that.</p>
|
||||||
|
<div class="tw"><table class=""><thead><tr><th>Option</th><th>Expected gain (measured basis)</th><th>Effort</th><th>Risk</th><th>What you would see</th><th>Ships in</th></tr></thead><tbody><tr><td>Staged reveal on screen entry (hero copy rises 16 px, rows rise 24 px, staggered 30–40 ms, 180 ms ease-out)</td><td>Perceived: art arrives into motion instead of popping. Cost: translate + alpha on one layer per row</td><td>S</td><td>Low. Stagger must not delay focus</td><td>Home and Discover settle like a curtain rather than tiles snapping</td><td>TV</td></tr><tr><td>Hero cross-fade (backdrop 320 ms, copy hands off)</td><td>Removes the hard cut when the hero changes</td><td>S</td><td>Medium. Two 1080p bitmaps for 320 ms; the GPU is already at 14–21 ms/frame, so fade the backdrop only and keep the scrim static</td><td>The next pick dissolves in</td><td>TV</td></tr><tr><td>Focus: 180 ms ease-out, caption rises 10 px</td><td>Same frame cost as today; the curve hides dropped frames better</td><td>XS</td><td>Low</td><td>Focus glides rather than ticks</td><td>TV</td></tr><tr><td>Title entry: grid fades 160 ms, page builds from the poster colour</td><td>Hides the 470 ms backdrop fetch</td><td>M</td><td>Medium (see load states)</td><td>No black gap between grid and title</td><td>TV</td></tr><tr><td>Shared-element poster → title sheet</td><td>None measurable</td><td>L</td><td><b class="w">High</b>. Compose shared elements across NavHost destinations on this GPU; the movie page has no poster sheet when a backdrop exists</td><td>The card flies into place. Not recommended now</td><td>TV</td></tr><tr><td>Honour reduced motion (system animator scale 0 or a Wolfskin toggle)</td><td>Accessibility</td><td>S</td><td>Low</td><td>Fades only, ≤100 ms; the toggle at the top shows it</td><td>TV</td></tr></tbody></table></div>
|
||||||
|
<h3>See it: Who's watching → Home</h3>
|
||||||
|
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button></div>
|
||||||
|
<div class="panes"><div class="pane"><p class="lab">Today: grey tiles, then art pops at 400 ms</p><div class="sbox"><video src="tv-motion-speed/before-home-enter.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">After</p><div class="sbox" id="p-home" data-scene="home" data-lead="200"></div></div></div></div>
|
||||||
|
<h3>See it: tab switch and row reveal (Home → Discover)</h3>
|
||||||
|
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button></div>
|
||||||
|
<div class="panes"><div class="pane"><p class="lab">Today</p><div class="sbox"><video src="tv-motion-speed/before-discover-tab.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">After</p><div class="sbox" id="p-tab" data-scene="tab" data-lead="150"></div></div></div></div>
|
||||||
|
<h3>See it: title page entry</h3>
|
||||||
|
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button></div>
|
||||||
|
<div class="panes"><div class="pane"><p class="lab">Today: American Gangster, first open (cast at 2.7 s)</p><div class="sbox"><video src="tv-motion-speed/before-title-movie.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">Today: Donnie Brasco from Discover, blank until 980 ms</p><div class="sbox"><video src="tv-motion-speed/before-title-discover.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">After: Apocalypse Now from the same grid</p><div class="sbox" id="p-title" data-scene="title" data-lead="170"></div></div></div></div>
|
||||||
|
<h3>See it: a late line on a series page</h3>
|
||||||
|
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button></div>
|
||||||
|
<div class="panes"><div class="pane"><p class="lab">Today: Adults, the readiness line lands at 3.4 s and pushes the text down</p><div class="sbox"><video src="tv-motion-speed/before-title-series.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">After</p><div class="tabs" data-variants="p-series"></div><div class="sbox" id="p-series" data-scene="series" data-lead="150"></div></div></div></div>
|
||||||
|
<h3>See it: focus move</h3>
|
||||||
|
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button><span class="muted small">Tick box frame rate to compare at 25 fps.</span></div>
|
||||||
|
<div class="panes"><div class="pane"><p class="lab">Today, real grid</p><div class="sbox"><video src="tv-motion-speed/before-movies-grid.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">Focus along a row</p><div class="tabs" data-variants="p-focus"></div><div class="sbox" id="p-focus" data-scene="focus" data-lead="0"></div></div></div></div>
|
||||||
|
<h3>See it: hero cross-fade</h3>
|
||||||
|
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button></div>
|
||||||
|
<div class="panes"><div class="pane"><p class="lab">Today (Discover hero)</p><div class="sbox"><video src="tv-motion-speed/before-discover-tab.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">Hero advances</p><div class="tabs" data-variants="p-hero"></div><div class="sbox" id="p-hero" data-scene="hero" data-lead="0"></div></div></div></div>
|
||||||
|
|
||||||
|
<h2>4. Caching</h2>
|
||||||
|
<div class="tw"><table class=""><thead><tr><th>Option</th><th>Expected gain (measured basis)</th><th>Effort</th><th>Risk</th><th>What you would see</th><th>Ships in</th></tr></thead><tbody><tr><td>Persisted DNS answers (OkHttp Dns: serve the last address at once, refresh behind)</td><td>Removes 0.45–1.35 s on launch and on the first TMDB image after any 60 s pause</td><td>S</td><td>Low. Fall back to the system lookup if the cached address fails</td><td>No spinner on launch; first cast photos in ≈0.6 s instead of 2.7 s [inference: DNS is the cast delay]</td><td>TV</td></tr><tr><td>Warm the connections at process start (Jellyfin, Conductarr door, TMDB) in parallel</td><td>Overlaps DNS + TLS with the 450 ms first frame</td><td>XS</td><td>Low</td><td>Same as above, smaller</td><td>TV</td></tr><tr><td>Memory layer over the Conductarr disk cache</td><td>Skips disk read and JSON decode on back and re-open (title re-open settles at 450 ms today; ~250 expected) [inference]</td><td>S</td><td>Low</td><td>Back and re-open feel instant</td><td>TV</td></tr><tr><td>Stale-while-revalidate for ON_OPEN title routes (skip refetch if validated in the last 5 min)</td><td>Re-opening a title stops costing the server 875–2 900 ms</td><td>S</td><td>Low. Requests and readiness may be 5 min old; keep requests on FORCE</td><td>Nothing visible; less churn</td><td>TV</td></tr><tr><td>Prefetch the focused card's title page (detail JSON + backdrop) after 350 ms of dwell</td><td>Title first content 430 ms → about 60 ms; backdrop already decoded</td><td>M</td><td>Medium. Bandwidth: at most one title per dwell; cancel on move</td><td>Opening a title is instant</td><td>TV</td></tr><tr><td>Pre-decode last-known Home art while Who's watching is up</td><td>Home settles at 430 ms → about 250 ms with no pops</td><td>S</td><td>Low. Memory: 6 backdrops ≈ 21 MiB of the 64 MiB</td><td>Home appears whole</td><td>TV</td></tr><tr><td>One image loader instead of two</td><td>Frees a second memory cache (Coil default ≈ 25 % of the heap) [inference]</td><td>M</td><td>Medium. Upstream screens use their own</td><td>Fewer re-downloads after big screens</td><td>TV</td></tr><tr><td>OkHttp HTTP cache for Jellyfin JSON</td><td>Small. Jellyfin sends no cache headers, so only conditional revalidation is possible</td><td>S</td><td>Low</td><td>Nothing visible</td><td>TV</td></tr></tbody></table></div>
|
||||||
|
|
||||||
|
<h2>5. Backend retrieval</h2>
|
||||||
|
<div class="tw"><table class=""><thead><tr><th>Option</th><th>Expected gain (measured basis)</th><th>Effort</th><th>Risk</th><th>What you would see</th><th>Ships in</th></tr></thead><tbody><tr><td>Cache the held-title read in Conductarr (Arr library list, queue, history) for 30–60 s, shared across routes</td><td>Title detail 1.2 s → ≈0.1 s, season 1.1 s → ≈0.1 s, readiness 2.9 s → <1 s [inference from the code path]</td><td>M</td><td>Medium. Freshness of "Ready" after an import; invalidate on Arr webhook (already received)</td><td>Series pages settle in under a second; no late line</td><td>Conductarr release</td></tr><tr><td>Readiness: drop the duplicate held-title read and the per-episode Jellyfin GETs</td><td>The 5 s tail on readiness</td><td>M</td><td>Medium</td><td>"Ready for you" appears with the page</td><td>Conductarr release</td></tr><tr><td>Compress the TV door (Caddy <code>encode zstd gzip</code>)</td><td>Feed 204 KB → ~25–35 KB [inference: JSON typically 6–8×], activity 62 KB → ~8 KB</td><td>XS</td><td>Low</td><td>Discover opens a little faster on Wi-Fi</td><td>Caddy change (server-docs)</td></tr><tr><td>304 before rendering (keep a per-route version, not a hash of the full body)</td><td>Season 304 2.3 s → ms</td><td>M</td><td>Medium</td><td>Nothing visible except speed</td><td>Conductarr release</td></tr><tr><td>Cache the token check (<code>/Users/Me</code>) for 60 s per token</td><td>Removes a Jellyfin round trip from every TV request (~10–40 ms each, 6–10 per title page)</td><td>S</td><td>Low. Revocation lag 60 s</td><td>Nothing visible</td><td>Conductarr release</td></tr><tr><td>Batch display reads (one call for a row of posters)</td><td>≈1 400 display calls/day → a few dozen</td><td>M</td><td>Low</td><td>Coming in and Discover rows fill together</td><td>Conductarr release + TV</td></tr><tr><td>Size the grid posters (<code>fillWidth=256&fillHeight=384&quality=80&format=Webp</code>)</td><td>626 KB → 11 KB per poster (57×); 85 films 53 MB → 1 MB</td><td>XS</td><td>Low</td><td>Movies and Shows fill at once on first visit</td><td>TV</td></tr><tr><td>Ask TMDB for w342 posters, not w780</td><td>131 KB → 41 KB per Discover poster</td><td>XS</td><td>Low. 342 px for a 256 px card</td><td>Discover rows fill sooner</td><td>TV</td></tr><tr><td>Lean episode fields (drop MediaSources/MediaStreams/People from the list; fetch them on Play)</td><td>96.8 KB → 6.5 KB per season, 142 → 26 ms</td><td>S</td><td>Medium. The episode row shows file chips</td><td>Series pages settle sooner</td><td>TV</td></tr><tr><td>Fewer launch calls: UserViews 4–5× and LiveTv/Recordings/Folders 2× per launch</td><td>≈250 ms of serial calls</td><td>S</td><td>Low</td><td>Nothing visible</td><td>TV</td></tr></tbody></table></div>
|
||||||
|
|
||||||
|
<h2>6. Kotlin and Compose</h2>
|
||||||
|
<div class="tw"><table class=""><thead><tr><th>Option</th><th>Expected gain (measured basis)</th><th>Effort</th><th>Risk</th><th>What you would see</th><th>Ships in</th></tr></thead><tbody><tr><td>Library load re-emits the full list per 200-item page and re-sorts each time (LibraryReadFacade.kt:96, LibraryGridState.kt:99)</td><td>Grid first visit; matters more as the library grows</td><td>S</td><td>Low</td><td>Grid appears in one step</td><td>TV</td></tr><tr><td>Hoist the Regex in titleFallbackBackground / titleAmbientBackground (TitleArtwork.kt:152,167)</td><td>Draw-time cost per card</td><td>XS</td><td>Low</td><td>—</td><td>TV</td></tr><tr><td>Discover reads firstVisibleItemIndex in composition (DiscoverScreen.kt:107) → derivedStateOf</td><td>Fewer recompositions while scrolling Discover (34 of 44 janky frames were UI-thread)</td><td>XS</td><td>Low</td><td>Smoother Discover scroll</td><td>TV</td></tr><tr><td>NavBar label colour read in composition (NavBar.kt:86) → draw-phase</td><td>Tab switch frames</td><td>XS</td><td>Low</td><td>—</td><td>TV</td></tr><tr><td>WolfskinImages.loader() is @Synchronized and called in composition</td><td>Lock on every poster recomposition</td><td>XS</td><td>Low</td><td>—</td><td>TV</td></tr><tr><td>Cut full-screen layers: hero blur (RenderEffect) + two full-screen gradient scrims + ambient</td><td>GPU 14–21 ms/frame is the floor today; pre-blurred tiny art and one combined scrim drawn once</td><td>M</td><td>Medium. Must match the approved frames</td><td>Smoother scroll on Home and Discover</td><td>TV</td></tr><tr><td>Keys and contentType</td><td>Already done everywhere</td><td>—</td><td>—</td><td>—</td><td></td></tr></tbody></table></div>
|
||||||
|
|
||||||
|
<h2>7. Baseline profile</h2>
|
||||||
|
<p>Today: a hand seed of 6 lines (<code>app/src/optimized/baselineProfiles/baseline-prof.txt</code>), no generator and no Macrobenchmark. The box is at <code>speed</code> only because it was forced with <code>cmd package compile</code>. Every new install starts at <code>verify</code> (interpreted/JIT) until background dexopt runs.</p>
|
||||||
|
<div class="tw"><table class="num"><thead><tr><th>State (same box, earlier tv-perf run)</th><th>Cold start</th><th>Home scroll janky / p50 / p90</th><th>Movies grid</th></tr></thead><tbody><tr><td>Opt just installed (verify)</td><td>0.5–1.0 s</td><td>51.4 % / 65 / 117 ms</td><td>—</td></tr><tr><td>Opt after forced AOT (speed)</td><td>0.39–0.44 s</td><td>29.2 % / 38 / 61 ms</td><td>17.7 % / 19 / 36 ms</td></tr></tbody></table></div>
|
||||||
|
<p><b>Expected from a recorded profile</b> (Baseline Profile Gradle plugin plus one UiAutomator journey through picker, Home, Discover, the grids, a title and a person page): a fresh install behaves like the AOT row from the first launch. That is roughly −40 % janky frames on Home scroll and up to half a second off cold start, for every build the second mate installs. It does <b>not</b> change steady state on a box already at <code>speed</code>. Effort M: the journey can run on an emulator in the build container; the box itself isn't needed. Risk low.</p>
|
||||||
|
|
||||||
|
<h2>8. Recommended plan</h2>
|
||||||
|
<ol class="plan">
|
||||||
|
<li><b>DNS and launch (TV, S, ~1 day).</b> Persisted DNS answers plus parallel connection warm-up at process start. Show the picker from saved people and check the session in the background. <i>Prove it:</i> 6 cold launches spaced >60 s apart, picker at ≤0.5 s every time (today 1.65 s in half); first cast photos on a new title ≤1 s.</li>
|
||||||
|
<li><b>Image sizes (TV, XS, hours).</b> Sized WebP grid posters, TMDB w342, lean episode fields. <i>Prove it:</i> first Movies visit settles ≤400 ms (today 750); tank payload table.</li>
|
||||||
|
<li><b>Load states and motion (TV, M, 2–3 days, against your pick on this page).</b> Breathing shaped skeletons, art fade-in for network loads only, reserved late-line slots, staged reveal, hero cross-fade, reduced motion. <i>Prove it:</i> recordings like the ones above, no layout change after first content, gfxinfo janky no worse than today.</li>
|
||||||
|
<li><b>Prefetch and memory cache (TV, M, 2 days).</b> Dwell prefetch of title pages, a memory layer over the Conductarr cache, pre-decoded Home art behind the picker. <i>Prove it:</i> title first content ≤100 ms and Home settled ≤250 ms.</li>
|
||||||
|
<li><b>Conductarr release (M–L, 2–4 days):</b> held-title cache with webhook invalidation, readiness without duplicate reads or per-episode GETs, 304 before render, token-check cache. <b>Caddy</b>: compress the TV door. <i>Prove it:</i> the route table above for 24 h, with detail, season and readiness p50 ≤200 ms and p95 ≤600 ms, and the feed on the wire ≤40 KB.</li>
|
||||||
|
<li><b>Baseline profile (TV build, M, 1–2 days).</b> Recorded journey profile. <i>Prove it:</i> fresh install without a forced compile, Home scroll janky ≤30 %, cold start ≤0.5 s.</li>
|
||||||
|
<li><b>Frame cost (TV, M, 2–3 days).</b> The Compose fixes in section 6 and fewer full-screen layers. <i>Prove it:</i> Home and grid scroll janky ≤20 %, GPU p90 ≤16 ms in framestats.</li>
|
||||||
|
</ol>
|
||||||
|
<p><b>Total:</b> about 2 weeks of TV work and 3–4 days for a Conductarr release. Steps 1–2 alone remove the two waits you can feel today (the launch spinner and grids filling slowly), in about a day and a half.</p>
|
||||||
|
<p class="muted small">A box-side alternative to step 1's DNS part: switch the box's Private DNS from <code>dns.adguard.com</code> to Automatic, so it uses the LAN resolver (136–160 ms uncached, 0 ms cached). That changes the whole box, not just Wolfskin, so it's your call; the TV-side fix works either way.</p>
|
||||||
|
<h2>9. Your picks (all of them on one page)</h2>
|
||||||
|
<p class="muted">Six decisions. Each locks the moment you send it and the build lanes start on it. Nothing here touches the TV while you are playing.</p>
|
||||||
|
<form class="decision" data-decision-key="tv-motion-speed-direction" data-lavish-question="tv-motion-speed-direction"><h3>1. Load-state and motion direction</h3>
|
||||||
|
<p class="muted">Decides plan step 3 (skeletons and motion). The prototypes above show each part.</p>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-motion-speed-direction" value="recommended" data-choice-key="tv-motion-speed-direction"> <b>A · As recommended</b> <span class="rec">recommended</span><span class="t">Breathing shaped skeletons, art fades in only when it came from the network, reserved space for late lines, hero cross-fade, reduced-motion safe.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-motion-speed-direction" value="flat" data-choice-key="tv-motion-speed-direction"> <b>B · Same, but flat skeletons</b><span class="t">Still card-shaped, no pulse. The calmest option.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-motion-speed-direction" value="shimmer" data-choice-key="tv-motion-speed-direction"> <b>C · Same, but shimmer skeletons</b><span class="t">A light sweep across the tiles. Costs GPU on this box every frame while loading.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-motion-speed-direction" value="changes" data-choice-key="tv-motion-speed-direction"> <b>D · Different</b><span class="t">Say what in the note: a prototype to change, a speed, an option to drop.</span></label>
|
||||||
|
<textarea aria-label="Notes for 1. Load-state and motion direction" name="tv-motion-speed-direction-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
|
||||||
|
<form class="decision" data-decision-key="tv-speed-quick-fixes" data-lavish-question="tv-speed-quick-fixes"><h3>2. The two quick speed fixes (about a day and a half, TV only)</h3>
|
||||||
|
<p class="muted">(a) DNS cache with stale-serve plus a picker painted from saved people, so the launch spinner goes away; (b) sized WebP grid posters, smaller TMDB art, lean episode fields, so grids fill fast. Both ship in the next TV build.</p>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-speed-quick-fixes" value="go" data-choice-key="tv-speed-quick-fixes"> <b>Go, start now</b> <span class="rec">recommended</span><span class="t">Two small lanes, merged and staged for the next install.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-speed-quick-fixes" value="a-only" data-choice-key="tv-speed-quick-fixes"> <b>Only (a), the launch spinner</b><span class="t">Leave the image sizing for later.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-speed-quick-fixes" value="b-only" data-choice-key="tv-speed-quick-fixes"> <b>Only (b), the image sizes</b><span class="t">Leave the DNS cache for later.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-speed-quick-fixes" value="later" data-choice-key="tv-speed-quick-fixes"> <b>Not now</b><span class="t">Skip until after the motion work.</span></label>
|
||||||
|
<textarea aria-label="Notes for 2. The two quick speed fixes (about a day and a half, TV only)" name="tv-speed-quick-fixes-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
|
||||||
|
<form class="decision" data-decision-key="tv-box-dns" data-lavish-question="tv-box-dns"><h3>3. Change the box Private DNS (your call, system setting)</h3>
|
||||||
|
<p class="muted">Today the box uses dns.adguard.com: an uncached lookup costs 450 to 1350 ms. Automatic uses the LAN resolver (136 to 160 ms uncached, 0 ms cached). It changes DNS for the whole box, not just Wolfskin. I do it with ADB only when the TV is free and you say so; the TV-side fix in 2(a) works either way.</p>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-box-dns" value="leave" data-choice-key="tv-box-dns"> <b>Leave it as it is</b> <span class="rec">recommended</span><span class="t">The TV-side cache covers the launch spinner.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-box-dns" value="automatic" data-choice-key="tv-box-dns"> <b>Switch to Automatic</b><span class="t">I change it when you free the TV and show before and after timings.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-box-dns" value="ask" data-choice-key="tv-box-dns"> <b>Ask me when the TV is free</b><span class="t">Decide then.</span></label>
|
||||||
|
<textarea aria-label="Notes for 3. Change the box Private DNS (your call, system setting)" name="tv-box-dns-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
|
||||||
|
<form class="decision" data-decision-key="tv-speed-later-steps" data-lavish-question="tv-speed-later-steps"><h3>4. Later plan steps to queue (TV, after the build above)</h3>
|
||||||
|
<p class="muted">Prefetch and memory cache (title pages, 2 days); recorded Baseline Profile (1 to 2 days); frame cost and Compose fixes (2 to 3 days). Each has its own proof target in section 8.</p>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-speed-later-steps" value="all" data-choice-key="tv-speed-later-steps"> <b>Queue all three in that order</b> <span class="rec">recommended</span><span class="t">They follow the motion work, one lane at a time.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-speed-later-steps" value="prefetch" data-choice-key="tv-speed-later-steps"> <b>Only prefetch and memory cache</b><span class="t">Skip the profile and frame work for now.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-speed-later-steps" value="none" data-choice-key="tv-speed-later-steps"> <b>None yet</b><span class="t">Revisit after you have seen the motion build on the TV.</span></label>
|
||||||
|
<textarea aria-label="Notes for 4. Later plan steps to queue (TV, after the build above)" name="tv-speed-later-steps-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
|
||||||
|
<form class="decision" data-decision-key="tv-conductarr-retrieval" data-lavish-question="tv-conductarr-retrieval"><h3>5. Conductarr title-read speed-up (needs a Conductarr release)</h3>
|
||||||
|
<p class="muted">Held-title cache with Arr webhook invalidation, readiness without duplicate reads or per-episode calls, 304 before render, token-check cache, compression on the TV door. 3 to 4 days, plus a release and a server-docs update. Title pages are slow because of this (readiness p50 2.9 s).</p>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-conductarr-retrieval" value="scope" data-choice-key="tv-conductarr-retrieval"> <b>Scope it first, then I decide</b> <span class="rec">recommended</span><span class="t">A short scout writes the exact change list and risks for a release; nothing ships.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-conductarr-retrieval" value="go" data-choice-key="tv-conductarr-retrieval"> <b>Go: build it and release it</b><span class="t">You are giving the word to build and release Conductarr when it is green.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-conductarr-retrieval" value="later" data-choice-key="tv-conductarr-retrieval"> <b>Not now</b><span class="t">Skip until after the TV-side wins.</span></label>
|
||||||
|
<textarea aria-label="Notes for 5. Conductarr title-read speed-up (needs a Conductarr release)" name="tv-conductarr-retrieval-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
|
||||||
|
<form class="decision" data-decision-key="tv-build-order" data-lavish-question="tv-build-order"><h3>6. When should these land on the TV?</h3>
|
||||||
|
<p class="muted">You said you will not be using the TV during this session.</p>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-build-order" value="as-ready" data-choice-key="tv-build-order"> <b>Bundle with the next install</b> <span class="rec">recommended</span><span class="t">The speed fixes and any approved motion work join the staged build; I install when you say the TV is free.</span></label>
|
||||||
|
<label class="opt"><input required type="radio" name="tv-build-order" value="separate" data-choice-key="tv-build-order"> <b>Install the speed fixes on their own first</b><span class="t">A quick build for steps 2(a) and 2(b) only, installed as soon as it is ready if you free the TV.</span></label>
|
||||||
|
<textarea aria-label="Notes for 6. When should these land on the TV?" name="tv-build-order-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
|
||||||
|
<p class="muted small">Method, raw numbers and files: <code>data/tv-spike-motion-speed/report.md</code>. Nothing was changed in the app or on the server. The box was left on the Movies tab.</p>
|
||||||
|
<script>
|
||||||
|
// Wolfskin TV motion prototypes. One clock per stage drives Web Animations; "box rate" quantises the clock
|
||||||
|
// to the measured frame time on the Xiaomi box (p50 40 ms while moving, data/tv-spike-motion-speed/raw/gfx-*),
|
||||||
|
// so the same motion can be watched at 60 fps and at the rate the box actually draws today.
|
||||||
|
(() => {
|
||||||
|
const A = 'tv-motion-speed/';
|
||||||
|
const BOX_MS = 40;
|
||||||
|
const EASE = 'cubic-bezier(.25,.1,.25,1)', OUT = 'cubic-bezier(.33,1,.68,1)', INOUT = 'cubic-bezier(.65,0,.35,1)';
|
||||||
|
const POSTERS = [
|
||||||
|
['American Gangster', '2007 · 2h 36m'], ['Annie Hall', '1977 · 1h 33m'], ['Apocalypse Now', '1979 · 2h 27m'],
|
||||||
|
['Arrival', '2016 · 1h 56m'], ['Avatar: The Last Airbender', '2026 · 1h 52m'], ['Bohemian Rhapsody', '2018 · 2h 15m'],
|
||||||
|
['Braveheart', '1995 · 2h 58m'], ['Citizen Kane', '1941 · 1h 59m'], ['Spirited Away', '2001 · 2h 5m'],
|
||||||
|
['12 Years a Slave', '2013 · 2h 14m'], ['A Bronx Tale', '1993 · 2h 1m'], ['All the Money in the World', '2017 · 2h 13m'],
|
||||||
|
];
|
||||||
|
const poster = i => `${A}p${i + 1}.webp`;
|
||||||
|
const html = (s) => { const t = document.createElement('template'); t.innerHTML = s.trim(); return t.content.firstChild; };
|
||||||
|
|
||||||
|
function nav(cur, focus) {
|
||||||
|
const items = ['Home', 'Discover', 'Movies', 'Shows'];
|
||||||
|
return `<div class="nav"><span class="wm">Wolf<b>skin</b></span>${items.map((n, i) => `<span class="ni ${i === cur ? 'cur' : ''} ${i === focus ? 'f' : ''}">${n}</span>`).join('')}<span class="nsp"></span><span class="av">S</span></div>`;
|
||||||
|
}
|
||||||
|
function rate(rt, pc) { return `<span class="rate"><i></i>${rt}</span><span class="rate"><i class="pc"></i>${pc}</span>`; }
|
||||||
|
function heroCopy(o) {
|
||||||
|
return `<div class="copy" style="bottom:${o.bottom || 330}px"><div class="kicker">${o.kicker}</div><div class="ht">${o.title}</div><div class="hm">${o.meta}</div>${o.ov ? `<div class="ho">${o.ov}</div>` : ''}<div class="btns">${o.btns}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- scenes: build(stage, T, v) where v is the selected variant ----------
|
||||||
|
const SCENES = {
|
||||||
|
// 1. launch -> Who's watching
|
||||||
|
launch: { len: 1400, variants: { after: 'After: people from the saved list' }, build(s, T) {
|
||||||
|
s.append(html(`<div class="abs" style="inset:0;background:url(${A}launcher.webp) center/cover"></div>`));
|
||||||
|
const bg = s.appendChild(html(`<div class="abs" style="inset:0;background:#07070a"></div>`));
|
||||||
|
T.a(bg, [{ opacity: 0 }, { opacity: 1 }], 0, 160);
|
||||||
|
const pk = s.appendChild(html(`<div class="abs" style="inset:0"><div class="abs" style="left:96px;top:60px;font-size:36px;font-weight:800">Wolf<b style="color:var(--accent)">skin</b></div><div class="pk">Who’s watching?</div>
|
||||||
|
${[['J', '#64c3ff', 'justin', 540], ['M', '#ffa47a', 'mindel', 850], ['S', '#9be38f', 'shared', 1160]].map(([l, c, n, x]) => `<div class="person ${n === 'shared' ? 'ring' : ''}" style="left:${x}px;background:${c}">${l}</div><div class="pname" style="left:${x}px">${n}</div>`).join('')}</div>`));
|
||||||
|
T.a(pk, [{ opacity: 0 }, { opacity: 1 }], 450, 160);
|
||||||
|
} },
|
||||||
|
|
||||||
|
// 2. picker -> Home
|
||||||
|
home: { len: 1100, variants: { after: 'After: last Home painted from disk, art pre-decoded' }, build(s, T) {
|
||||||
|
const home = s.appendChild(html(`<div class="abs" style="inset:0">
|
||||||
|
<div class="amb" style="background-image:url(${A}b2t.webp);filter:blur(30px);transform:scale(1.1)"></div>
|
||||||
|
<div class="bd" style="background-image:url(${A}b2.webp)"></div><div class="scrim"></div>
|
||||||
|
${heroCopy({ kicker: '▸ Continue watching', title: 'Arrival', meta: '2016 · 1h 56m · 38 min left ' + rate('94%', '82%'), ov: 'Taking place after alien crafts land around the world, an expert linguist is recruited by the military to determine whether they come in peace or are a threat.', btns: '<span class="btn pri f">▶ Resume</span><span class="btn">ⓘ Details</span>', bottom: 340 })}
|
||||||
|
<div class="sh" style="top:780px">Next up</div>
|
||||||
|
${[3, 4, 1, 2].map((b, i) => `<div class="card tile" style="left:${96 + i * 440}px;top:850px"><div class="img" style="background-image:url(${A}b${b}.webp)"></div><div class="cap">${['American Gangster', 'Bohemian Rhapsody', 'Apocalypse Now', 'Arrival'][i]}</div></div>`).join('')}
|
||||||
|
${nav(0)}</div>`));
|
||||||
|
const picker = s.appendChild(html(`<div class="abs" style="inset:0;background:#07070a;z-index:10"><div class="pk">Who’s watching?</div>
|
||||||
|
${[['J', '#64c3ff', 'justin', 540], ['M', '#ffa47a', 'mindel', 850], ['S', '#9be38f', 'shared', 1160]].map(([l, c, n, x]) => `<div class="person ${n === 'shared' ? 'ring' : ''}" style="left:${x}px;background:${c}">${l}</div><div class="pname" style="left:${x}px">${n}</div>`).join('')}</div>`));
|
||||||
|
T.a(picker, [{ opacity: 1 }, { opacity: 0 }], 0, 140);
|
||||||
|
T.a(home.querySelector('.amb'), [{ opacity: 0 }, { opacity: 1 }], 40, 120);
|
||||||
|
T.a(home.querySelector('.bd'), [{ opacity: 0 }, { opacity: 1 }], 120, 220, INOUT);
|
||||||
|
T.a(home.querySelector('.copy'), [{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'none' }], 60, 180, OUT);
|
||||||
|
T.a(home.querySelector('.sh'), [{ opacity: 0 }, { opacity: 1 }], 120, 160);
|
||||||
|
home.querySelectorAll('.card').forEach((c, i) => {
|
||||||
|
T.a(c, [{ opacity: 0, transform: 'translateY(24px)' }, { opacity: 1, transform: 'none' }], 140 + i * 40, 180, OUT);
|
||||||
|
});
|
||||||
|
} },
|
||||||
|
|
||||||
|
// 3. skeleton styles on a poster grid
|
||||||
|
skeleton: { len: 2200, variants: { breathe: 'Breathe (pick)', flat: 'Flat blocks (today)', shimmer: 'Shimmer sweep', spinner: 'Spinner (today, launch)' }, build(s, T, v) {
|
||||||
|
s.append(html(nav(2)));
|
||||||
|
s.append(html(`<div class="abs" style="left:96px;top:150px;font-size:72px;font-weight:800;letter-spacing:-.02em">Movies <span style="font-size:30px;font-weight:500;color:var(--muted)">85 films</span></div>`));
|
||||||
|
const cells = [];
|
||||||
|
for (let r = 0; r < 2; r++) for (let c = 0; c < 6; c++) cells.push([96 + c * 296, 330 + r * 460, r * 6 + c]);
|
||||||
|
if (v === 'spinner') { const sp = s.appendChild(html('<div class="spin"></div>')); T.a(sp, [{ opacity: 1 }, { opacity: 0 }], 900, 120); }
|
||||||
|
cells.forEach(([x, y, i]) => {
|
||||||
|
if (v !== 'spinner') {
|
||||||
|
const sk = s.appendChild(html(`<div class="card poster sk ${v === 'breathe' ? 'breathe' : v === 'shimmer' ? 'shim' : ''}" style="left:${x}px;top:${y}px;${v === 'breathe' ? `animation-delay:${(i % 6) * 90}ms` : ''}"></div>`));
|
||||||
|
T.a(sk, [{ opacity: 1 }, { opacity: 0 }], 900 + i * 25, 160);
|
||||||
|
}
|
||||||
|
const c = s.appendChild(html(`<div class="card poster" style="left:${x}px;top:${y}px;background-image:url(${poster(i)})"></div>`));
|
||||||
|
T.a(c, [{ opacity: 0 }, { opacity: 1 }], 900 + i * 25, 160);
|
||||||
|
});
|
||||||
|
} },
|
||||||
|
|
||||||
|
// 4. tab switch: Home -> Discover, rows reveal
|
||||||
|
tab: { len: 1100, variants: { after: 'After: last Discover painted, rows rise, art fades' }, build(s, T) {
|
||||||
|
const disc = s.appendChild(html(`<div class="abs" style="inset:0">
|
||||||
|
<div class="bd" style="background-image:url(${A}b4.webp)"></div><div class="scrim"></div>
|
||||||
|
${heroCopy({ kicker: '✦ For you', title: 'Bohemian Rhapsody', meta: 'Movie · 2018 · Drama, Music ' + rate('60%', '85%'), ov: 'Singer Freddie Mercury, guitarist Brian May, drummer Roger Taylor and bass guitarist John Deacon take the music world by storm.', btns: '<span class="btn">ⓘ Details</span>', bottom: 520 })}
|
||||||
|
<div class="sh" style="top:600px">Movies for you</div>
|
||||||
|
${[6, 7, 8, 9, 10, 11].map((p, i) => `<div class="card poster" style="left:${96 + i * 296}px;top:670px"><div class="img" style="background-image:url(${poster(p)})"></div></div>`).join('')}
|
||||||
|
</div>`));
|
||||||
|
const home = s.appendChild(html(`<div class="abs" style="inset:0"><div class="bd" style="background-image:url(${A}b2.webp)"></div><div class="scrim"></div>
|
||||||
|
${heroCopy({ kicker: '▸ Continue watching', title: 'Arrival', meta: '2016 · 1h 56m · 38 min left', ov: 'Taking place after alien crafts land around the world, an expert linguist is recruited by the military.', btns: '<span class="btn pri">▶ Resume</span><span class="btn">ⓘ Details</span>', bottom: 340 })}</div>`));
|
||||||
|
const n = s.appendChild(html(nav(1, 1)));
|
||||||
|
T.a(home, [{ opacity: 1 }, { opacity: 0 }], 0, 200, EASE);
|
||||||
|
T.a(disc.querySelector('.bd'), [{ opacity: 0 }, { opacity: 1 }], 0, 240, INOUT);
|
||||||
|
T.a(disc.querySelector('.copy'), [{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'none' }], 80, 180, OUT);
|
||||||
|
T.a(disc.querySelector('.sh'), [{ opacity: 0 }, { opacity: 1 }], 140, 160);
|
||||||
|
disc.querySelectorAll('.card').forEach((c, i) => T.a(c, [{ opacity: 0, transform: 'translateY(24px)' }, { opacity: 1, transform: 'none' }], 160 + i * 30, 180, OUT));
|
||||||
|
} },
|
||||||
|
|
||||||
|
// 5. title page entry from the Movies grid
|
||||||
|
title: { len: 1600, variants: { after: 'After: what the card knew, at once' }, build(s, T) {
|
||||||
|
const page = s.appendChild(html(`<div class="abs" style="inset:0">
|
||||||
|
<div class="amb" style="background-image:url(${poster(2)});filter:blur(60px) saturate(1.2) brightness(.55);transform:scale(1.2)"></div>
|
||||||
|
<div class="bd" style="background-image:url(${A}b1.webp)"></div><div class="scrim"></div>
|
||||||
|
<div class="copy" style="bottom:360px"><div class="kicker">✓ Movie · In your library</div><div class="ht">Apocalypse Now</div>
|
||||||
|
<div class="hm">1979 · 2h 27m ${rate('91%', '94%')} <span class="late" style="color:var(--muted)">Drama, War</span></div>
|
||||||
|
<div class="ho late2" style="min-height:76px">At the height of the Vietnam war, Captain Benjamin Willard is sent on a dangerous mission that, officially, “does not exist, nor will it ever exist.”</div>
|
||||||
|
<div class="btns"><span class="btn pri f">▶ Play</span><span class="btn">⋯</span><span class="btn">Add for someone</span></div></div>
|
||||||
|
<div class="sh" style="top:830px">Cast</div>
|
||||||
|
${[0, 1, 2, 3, 4, 5, 6].map(i => `<div class="circle" style="left:${96 + i * 244}px;top:900px"><span class="sk breathe abs" style="inset:0;border-radius:50%;animation-delay:${i * 90}ms"></span><div class="img" style="background-image:url(${A}c${i + 1}.webp)"></div></div>`).join('')}
|
||||||
|
${nav(2)}</div>`));
|
||||||
|
const grid = s.appendChild(html(`<div class="abs" style="inset:0;background:#07070a">${nav(2)}
|
||||||
|
<div class="abs" style="left:96px;top:150px;font-size:72px;font-weight:800;letter-spacing:-.02em">Movies <span style="font-size:30px;font-weight:500;color:var(--muted)">85 films</span></div>
|
||||||
|
${[0, 1, 2, 3, 4, 5].map(i => `<div class="card poster ${i === 2 ? 'ring' : ''}" style="left:${96 + i * 296}px;top:330px;background-image:url(${poster(i)});${i === 2 ? 'transform:scale(1.08)' : ''}"></div>`).join('')}
|
||||||
|
${[6, 7, 8, 9, 10, 11].map((p, i) => `<div class="card poster" style="left:${96 + i * 296}px;top:790px;background-image:url(${poster(p)})"></div>`).join('')}</div>`));
|
||||||
|
T.a(grid, [{ opacity: 1 }, { opacity: 0 }], 0, 160);
|
||||||
|
T.a(page.querySelector('.amb'), [{ opacity: 0 }, { opacity: 1 }], 0, 160);
|
||||||
|
T.a(page.querySelector('.copy'), [{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'none' }], 40, 180, OUT);
|
||||||
|
T.a(page.querySelector('.late'), [{ opacity: 0 }, { opacity: 1 }], 430, 160);
|
||||||
|
T.a(page.querySelector('.late2'), [{ opacity: 0 }, { opacity: 1 }], 430, 160);
|
||||||
|
T.a(page.querySelector('.bd'), [{ opacity: 0 }, { opacity: 1 }], 470, 280, INOUT);
|
||||||
|
T.a(page.querySelector('.sh'), [{ opacity: 0 }, { opacity: 1 }], 120, 160);
|
||||||
|
page.querySelectorAll('.circle').forEach((c, i) => {
|
||||||
|
T.a(c, [{ opacity: 0, transform: 'translateY(20px)' }, { opacity: 1, transform: 'none' }], 140 + i * 30, 180, OUT);
|
||||||
|
T.a(c.querySelector('.img'), [{ opacity: 0 }, { opacity: 1 }], 620 + i * 40, 160);
|
||||||
|
});
|
||||||
|
} },
|
||||||
|
|
||||||
|
// 6. series title: late readiness line
|
||||||
|
series: { len: 4000, variants: { tv: 'TV-only: slot reserved, fades in at 3.4 s', conductarr: 'With the Conductarr cache: arrives ≈0.4 s' }, build(s, T, v) {
|
||||||
|
const late = v === 'conductarr' ? 380 : 3400;
|
||||||
|
const page = s.appendChild(html(`<div class="abs" style="inset:0">
|
||||||
|
<div class="amb" style="background-image:url(${A}s1t.webp);filter:blur(30px);transform:scale(1.1)"></div>
|
||||||
|
<div class="bd" style="background-image:url(${A}s1.webp)"></div><div class="scrim"></div>
|
||||||
|
<div class="copy" style="bottom:430px"><div class="kicker">✓ Series · In your library · Watched</div><div class="ht">Adults</div>
|
||||||
|
<div class="hm">2025 · 2 seasons · Comedy <span class="lr">${rate('88%', '93%')}</span></div>
|
||||||
|
<div class="hm lr2" style="color:var(--ok,#9be38f);font-size:26px">✓ Ready for you · by Mindel</div>
|
||||||
|
<div class="ho" style="min-height:76px">A group of twenty-somethings in New York trying to be good people, despite being neither “good” nor “people” yet.</div>
|
||||||
|
<div class="btns"><span class="btn pri f">▶ Play S1 E1</span><span class="btn">⋯</span><span class="btn">Seasons</span></div></div>
|
||||||
|
<div class="abs" style="left:96px;top:790px;display:flex;gap:16px"><span class="btn" style="height:56px;font-size:24px;background:var(--accent-soft,#2a1f3d)">Season 1</span><span class="btn" style="height:56px;font-size:24px">Season 2</span></div>
|
||||||
|
${[0, 1, 2, 3].map(i => `<div class="card tile sk breathe" style="left:${96 + i * 440}px;top:880px;animation-delay:${i * 90}ms"><div class="img" style="background-image:url(${A}${i % 2 ? 's1' : 'e1'}.webp)"></div></div>`).join('')}
|
||||||
|
${nav(3)}</div>`));
|
||||||
|
T.a(page.querySelector('.copy'), [{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'none' }], 0, 180, OUT);
|
||||||
|
T.a(page.querySelector('.amb'), [{ opacity: 0 }, { opacity: 1 }], 0, 160);
|
||||||
|
T.a(page.querySelector('.bd'), [{ opacity: 0 }, { opacity: 1 }], 620, 280, INOUT);
|
||||||
|
T.a(page.querySelector('.lr'), [{ opacity: 0 }, { opacity: 1 }], late, 200);
|
||||||
|
T.a(page.querySelector('.lr2'), [{ opacity: 0 }, { opacity: 1 }], late, 200);
|
||||||
|
page.querySelectorAll('.card .img').forEach((c, i) => T.a(c, [{ opacity: 0 }, { opacity: 1 }], 700 + i * 40, 160));
|
||||||
|
} },
|
||||||
|
|
||||||
|
// 7. focus move along a row
|
||||||
|
focus: { len: 3000, variants: { after: 'Proposed: 180 ms ease-out, caption rises', today: 'Today: 140 ms, caption cross-fade' }, build(s, T, v) {
|
||||||
|
s.append(html(nav(2)));
|
||||||
|
s.append(html(`<div class="sh" style="top:250px">Movies for you</div>`));
|
||||||
|
const dur = v === 'today' ? 140 : 180, ease = v === 'today' ? EASE : OUT;
|
||||||
|
const cards = [0, 1, 2, 3, 4, 5].map(i => s.appendChild(html(`<div class="card poster" style="left:${96 + i * 296}px;top:330px;background-image:url(${poster(i + 6)})"></div>`)));
|
||||||
|
const caps = [0, 1, 2, 3, 4, 5].map(i => s.appendChild(html(`<div class="abs" style="left:${96 + i * 296}px;top:740px;width:520px"><div style="font-size:30px;font-weight:700">${POSTERS[i + 6][0]}</div><div style="font-size:26px;color:var(--muted)">${POSTERS[i + 6][1]}</div></div>`)));
|
||||||
|
const step = 600;
|
||||||
|
cards.forEach((c, i) => {
|
||||||
|
const on = i * step, off = (i + 1) * step;
|
||||||
|
const up = [{ transform: 'scale(1)', boxShadow: '0 0 0 0 transparent' }, { transform: 'scale(1.08)', boxShadow: '0 0 0 2px oklch(78% 0.14 305), inset 0 0 0 2px oklch(78% 0.14 305), 0 18px 40px rgb(0 0 0/.55)' }];
|
||||||
|
if (i > 0) T.a(c, up, on, dur, ease); else T.a(c, [up[1], up[1]], 0, 1);
|
||||||
|
if (i < 4) T.a(c, [up[1], up[0]], off, dur, ease);
|
||||||
|
const capIn = v === 'today' ? [{ opacity: 0 }, { opacity: 1 }] : [{ opacity: 0, transform: 'translateY(10px)' }, { opacity: 1, transform: 'none' }];
|
||||||
|
if (i > 0) T.a(caps[i], capIn, on + (v === 'today' ? 0 : 60), dur, ease); else T.a(caps[i], [{ opacity: 1 }, { opacity: 1 }], 0, 1);
|
||||||
|
if (i < 4) T.a(caps[i], [{ opacity: 1 }, { opacity: 0 }], off, v === 'today' ? dur : 100, ease);
|
||||||
|
if (i > 4) caps[i].style.opacity = 0;
|
||||||
|
});
|
||||||
|
} },
|
||||||
|
|
||||||
|
// 8. hero crossfade (Discover hero advances)
|
||||||
|
hero: { len: 2600, variants: { after: 'Proposed: 320 ms backdrop cross-fade, copy hand-off', today: 'Today: cut' }, build(s, T, v) {
|
||||||
|
const mk = (b, t, meta, ov) => s.appendChild(html(`<div class="abs hero" style="inset:0"><div class="bd" style="background-image:url(${A}b${b}.webp)"></div><div class="scrim"></div>${heroCopy({ kicker: '✦ For you', title: t, meta, ov, btns: '<span class="btn">ⓘ Details</span>', bottom: 520 })}</div>`));
|
||||||
|
const b = mk(2, 'Arrival', 'Movie · 2016 · Drama, Sci-Fi ' + rate('94%', '82%'), 'Taking place after alien crafts land around the world, an expert linguist is recruited by the military to determine whether they come in peace or are a threat.');
|
||||||
|
const a = mk(1, 'Apocalypse Now', 'Movie · 1979 · Drama, War ' + rate('91%', '94%'), 'At the height of the Vietnam war, Captain Benjamin Willard is sent on a dangerous mission that, officially, does not exist.');
|
||||||
|
s.append(html(nav(1)));
|
||||||
|
s.append(html(`<div class="abs" style="left:96px;top:600px;font-size:34px;font-weight:700">Movies for you</div>`));
|
||||||
|
[0, 1, 2, 3, 4, 5].map(i => s.append(html(`<div class="card poster" style="left:${96 + i * 296}px;top:670px;background-image:url(${poster(i)})"></div>`)));
|
||||||
|
const at = 900;
|
||||||
|
if (v === 'today') { T.a(a, [{ opacity: 1 }, { opacity: 0 }], at, 1); return; }
|
||||||
|
T.a(a.querySelector('.copy'), [{ opacity: 1 }, { opacity: 0 }], at, 120, EASE);
|
||||||
|
T.a(a.querySelector('.bd'), [{ opacity: 1 }, { opacity: 0 }], at, 320, INOUT);
|
||||||
|
T.a(a.querySelector('.scrim'), [{ opacity: 1 }, { opacity: 1 }], at, 320);
|
||||||
|
T.a(a, [{ opacity: 1 }, { opacity: 0 }], at + 300, 20);
|
||||||
|
T.a(b.querySelector('.copy'), [{ opacity: 0, transform: 'translateY(14px)' }, { opacity: 1, transform: 'none' }], at + 140, 200, OUT);
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- player ----------
|
||||||
|
const players = [];
|
||||||
|
let boxRate = false;
|
||||||
|
function makePlayer(box) {
|
||||||
|
const name = box.dataset.scene, sc = SCENES[name];
|
||||||
|
const lead = +(box.dataset.lead || 300);
|
||||||
|
let variant = box.dataset.variant || Object.keys(sc.variants)[0];
|
||||||
|
let anims = [], t0 = 0, raf = 0, stage, label;
|
||||||
|
const total = () => lead + sc.len + 1400;
|
||||||
|
function build() {
|
||||||
|
box.querySelectorAll('.stage').forEach(e => e.remove());
|
||||||
|
stage = html('<div class="stage"></div>'); box.appendChild(stage);
|
||||||
|
label = stage.appendChild(html('<div class="tl">0 ms</div>'));
|
||||||
|
anims = [];
|
||||||
|
const rm = document.body.classList.contains('rm');
|
||||||
|
const seen = new WeakSet();
|
||||||
|
const T = { a(el, kf, start, dur, easing = EASE, opt = {}) {
|
||||||
|
let k = kf, d = dur;
|
||||||
|
if (rm) { k = kf.map(f => { const g = { ...f }; delete g.transform; return g; }); d = Math.min(dur, 100); }
|
||||||
|
// only an element's first animation fills backwards; later ones on the same element must not override it while waiting
|
||||||
|
const fill = seen.has(el) ? 'forwards' : 'both'; seen.add(el);
|
||||||
|
const an = el.animate(k, { delay: lead + start, duration: Math.max(1, d), easing, fill, ...opt });
|
||||||
|
an.pause(); anims.push(an);
|
||||||
|
} };
|
||||||
|
sc.build(stage, T, variant);
|
||||||
|
fit();
|
||||||
|
}
|
||||||
|
function fit() { if (stage) stage.style.transform = `scale(${box.clientWidth / 1920})`; }
|
||||||
|
function tick(now) {
|
||||||
|
let t = now - t0;
|
||||||
|
if (t > total()) { t0 = now; t = 0; }
|
||||||
|
const q = boxRate ? Math.floor(t / BOX_MS) * BOX_MS : t;
|
||||||
|
for (const an of anims) an.currentTime = q;
|
||||||
|
label.textContent = `${Math.max(0, Math.round(q - lead))} ms`;
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
function start() { cancelAnimationFrame(raf); build(); t0 = performance.now(); raf = requestAnimationFrame(tick); }
|
||||||
|
function stop() { cancelAnimationFrame(raf); raf = 0; }
|
||||||
|
new ResizeObserver(fit).observe(box);
|
||||||
|
new IntersectionObserver(es => es.forEach(e => { if (e.isIntersecting) { if (!raf) start(); } else stop(); }), { rootMargin: '100px' }).observe(box);
|
||||||
|
const p = { box, start, setVariant(v) { variant = v; start(); }, rebuild: start };
|
||||||
|
players.push(p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.sbox[data-scene]').forEach(makePlayer);
|
||||||
|
document.querySelectorAll('[data-variants]').forEach(bar => {
|
||||||
|
const p = players.find(x => x.box.id === bar.dataset.variants);
|
||||||
|
const sc = SCENES[p.box.dataset.scene];
|
||||||
|
Object.entries(sc.variants).forEach(([k, label], i) => {
|
||||||
|
const b = document.createElement('button'); b.type = 'button'; b.className = 'tab' + (i === 0 ? ' on' : ''); b.textContent = label;
|
||||||
|
b.onclick = () => { bar.querySelectorAll('.tab').forEach(x => x.classList.toggle('on', x === b)); p.setVariant(k); };
|
||||||
|
bar.appendChild(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('button.replay').forEach(b => b.addEventListener('click', () => {
|
||||||
|
const pair = b.closest('.pair');
|
||||||
|
pair.querySelectorAll('video').forEach(v => { v.currentTime = 0; v.play().catch(() => {}); });
|
||||||
|
players.filter(p => pair.contains(p.box)).forEach(p => p.start());
|
||||||
|
}));
|
||||||
|
const rate_ = document.getElementById('boxrate');
|
||||||
|
if (rate_) rate_.addEventListener('change', () => { boxRate = rate_.checked; });
|
||||||
|
const rm = document.getElementById('rm');
|
||||||
|
if (rm) {
|
||||||
|
if (matchMedia('(prefers-reduced-motion: reduce)').matches) { rm.checked = true; document.body.classList.add('rm'); }
|
||||||
|
rm.addEventListener('change', () => { document.body.classList.toggle('rm', rm.checked); players.forEach(p => p.rebuild()); });
|
||||||
|
}
|
||||||
|
document.body.dataset.ready = '1';
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.querySelectorAll('form.decision').forEach(form=>{form.addEventListener('submit',event=>{event.preventDefault();const key=form.dataset.lavishQuestion;const answer=new FormData(form).get(key);const status=form.querySelector('[role=status]');if(!answer){status.textContent='Pick an option first.';return;}if(!window.lavish||!window.lavish.queuePrompt){status.textContent='Open this page in the live review board to queue an answer.';return;}const note=form.querySelector('textarea').value.trim();const label=form.querySelector('h3').textContent;window.lavish.queuePrompt(label+': '+answer+(note?'\n'+note:''),{tag:'choice',text:label+': '+answer,element:form,queueKey:key,data:{question:key,answer:answer,note:note}});status.textContent='Queued. Press Send to Agent in the review toolbar.';});});
|
||||||
|
</script></main></body></html>
|
||||||
72
data/tv-v2/motion-2026-11-06/proto.css
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
/* Prototype stages: 1920x1080 CSS px, approved tokens from data/tv-v2/focus-2026-11-06 (colors, type, ring, card sizes at Level 2 spacing). */
|
||||||
|
.sbox{position:relative;width:100%;aspect-ratio:16/9;overflow:hidden;border-radius:8px;border:1px solid var(--line);background:#000}
|
||||||
|
.sbox video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
|
||||||
|
.stage{position:absolute;left:0;top:0;width:1920px;height:1080px;transform-origin:0 0;overflow:hidden;background:#07070a;font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Noto Sans",sans-serif;color:#f4f4f6;font-size:28px;line-height:1.3;
|
||||||
|
--bg:#07070a;--surface:#13131a;--surface2:#1b1b25;--surface3:#252532;--muted:rgb(244 244 246/.74);--dim:rgb(244 244 246/.56);--accent:oklch(78% 0.14 305);--accent-text:oklch(88% 0.08 305)}
|
||||||
|
.stage *{box-sizing:border-box}
|
||||||
|
.stage .abs{position:absolute}
|
||||||
|
/* nav */
|
||||||
|
.stage .nav{position:absolute;left:0;top:0;width:1920px;height:150px;z-index:5;display:flex;align-items:center;gap:12px;padding:44px 96px 0}
|
||||||
|
.stage .wm{font-size:36px;font-weight:800;letter-spacing:-.02em;margin-right:40px}.stage .wm b{color:var(--accent)}
|
||||||
|
.stage .ni{height:64px;padding:0 26px;border-radius:32px;display:inline-flex;align-items:center;font-size:30px;font-weight:600;color:var(--muted);position:relative}
|
||||||
|
.stage .ni.cur{color:#f4f4f6}.stage .ni.cur::after{content:"";position:absolute;left:26px;right:26px;bottom:4px;height:4px;border-radius:2px;background:var(--accent)}
|
||||||
|
.stage .ni.f{background:#f4f4f6;color:#0b0b10}.stage .ni.f::after{display:none}
|
||||||
|
.stage .nsp{flex:1}.stage .av{width:64px;height:64px;border-radius:50%;display:grid;place-items:center;font-weight:800;color:#0b0b10;font-size:30px;background:#9be38f}
|
||||||
|
/* hero */
|
||||||
|
.stage .amb{position:absolute;inset:0;background-size:cover;background-position:center;image-rendering:auto}
|
||||||
|
.stage .bd{position:absolute;inset:0;background-size:cover;background-position:center}
|
||||||
|
.stage .scrim{position:absolute;inset:0;background:linear-gradient(90deg,rgb(7 7 10/.92) 0%,rgb(7 7 10/.55) 42%,rgb(7 7 10/0) 70%),linear-gradient(0deg,#07070a 0%,rgb(7 7 10/.6) 26%,rgb(7 7 10/0) 48%)}
|
||||||
|
.stage .copy{position:absolute;left:96px;width:1000px}
|
||||||
|
.stage .kicker{font-size:26px;font-weight:650;color:var(--accent-text);margin-bottom:10px}
|
||||||
|
.stage .ht{font-size:84px;line-height:1.02;font-weight:800;letter-spacing:-.02em;margin:0 0 16px}
|
||||||
|
.stage .hm{font-size:28px;color:var(--muted);margin-bottom:14px;min-height:38px;display:flex;gap:22px;align-items:center}
|
||||||
|
.stage .ho{font-size:28px;color:var(--muted);margin:0 0 30px;max-width:960px}
|
||||||
|
.stage .btns{display:flex;gap:20px}
|
||||||
|
.stage .btn{height:76px;padding:0 38px;border-radius:38px;font-size:28px;font-weight:700;display:inline-flex;align-items:center;background:rgb(255 255 255/.14)}
|
||||||
|
.stage .btn.pri{background:#f4f4f6;color:#0b0b10}
|
||||||
|
.stage .btn.f{box-shadow:0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent);transform:scale(1.05)}
|
||||||
|
.stage .rate{display:inline-flex;gap:10px;align-items:center;font-weight:700;color:#f4f4f6}.stage .rate i{width:24px;height:24px;border-radius:50%;background:#e2483a;display:inline-block}
|
||||||
|
.stage .rate i.pc{background:#f5c518;border-radius:4px}
|
||||||
|
.stage .slot{display:inline-block;height:30px;border-radius:15px;background:rgb(255 255 255/.10)}
|
||||||
|
/* rows + cards */
|
||||||
|
.stage .sh{position:absolute;left:96px;font-size:34px;font-weight:700}
|
||||||
|
.stage .card{position:absolute;border-radius:16px;overflow:hidden;background:var(--surface);background-size:cover;background-position:center}
|
||||||
|
.stage .card.poster{width:256px;height:384px}.stage .card.tile{width:400px;height:225px}
|
||||||
|
.stage .card .img{position:absolute;inset:0;background-size:cover;background-position:center}
|
||||||
|
.stage .card .cap{position:absolute;left:0;right:0;bottom:0;padding:14px 18px;background:linear-gradient(rgb(7 7 10/0),rgb(7 7 10/.92));font-size:26px;font-weight:700}
|
||||||
|
.stage .ring{box-shadow:0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent),0 18px 40px rgb(0 0 0/.55)}
|
||||||
|
.stage .circle{position:absolute;width:200px;height:200px;border-radius:50%;overflow:hidden;background:var(--surface2);display:grid;place-items:center;font-size:44px;font-weight:800;color:var(--dim)}
|
||||||
|
.stage .circle .img{position:absolute;inset:0;background-size:cover;background-position:center}
|
||||||
|
.stage .bar{position:absolute;height:28px;border-radius:14px;background:var(--surface2)}
|
||||||
|
.stage .spin{position:absolute;left:940px;top:520px;width:40px;height:40px;border-radius:50%;border:4px solid rgb(155 120 255/.25);border-top-color:oklch(65% 0.2 300);animation:spin .8s linear infinite}
|
||||||
|
/* picker */
|
||||||
|
.stage .pk{position:absolute;top:290px;left:0;width:1920px;text-align:center;font-size:72px;font-weight:800;letter-spacing:-.02em}
|
||||||
|
.stage .person{position:absolute;top:426px;width:220px;height:220px;border-radius:50%;display:grid;place-items:center;font-size:110px;font-weight:800;color:#0b0b10}
|
||||||
|
.stage .pname{position:absolute;top:670px;width:220px;text-align:center;font-size:34px;font-weight:700;color:var(--muted)}
|
||||||
|
/* replay label */
|
||||||
|
.stage .tl{position:absolute;right:14px;bottom:10px;font:22px/1 ui-monospace,Menlo,monospace;background:rgb(0 0 0/.6);padding:6px 8px;border-radius:6px;color:#fff;z-index:20}
|
||||||
|
|
||||||
|
/* keyframes */
|
||||||
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
@keyframes fade{from{opacity:0}to{opacity:1}}
|
||||||
|
@keyframes fadeout{from{opacity:1}to{opacity:0}}
|
||||||
|
@keyframes rise{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:none}}
|
||||||
|
@keyframes breathe{0%,100%{opacity:.55}50%{opacity:1}}
|
||||||
|
@keyframes shimmer{from{transform:translateX(-100%)}to{transform:translateX(100%)}}
|
||||||
|
@keyframes hide{to{opacity:0;visibility:hidden}}
|
||||||
|
@keyframes show{from{opacity:0}to{opacity:1}}
|
||||||
|
|
||||||
|
/* anything with .an waits for .play */
|
||||||
|
.stage .an{opacity:0}
|
||||||
|
.stage.play .an{animation-fill-mode:both}
|
||||||
|
.stage .gone{animation-fill-mode:both}
|
||||||
|
/* skeleton styles */
|
||||||
|
.stage .sk{background:var(--surface2)}
|
||||||
|
.stage .sk.breathe{animation:breathe 1.4s ease-in-out infinite}
|
||||||
|
.stage .sk.shim{position:relative;overflow:hidden}
|
||||||
|
.stage .sk.shim::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgb(255 255 255/.07),transparent);animation:shimmer 1.3s linear infinite}
|
||||||
|
|
||||||
|
/* reduced motion: no movement, short fades only */
|
||||||
|
body.rm .stage.play .an{animation-name:fade!important;animation-duration:.08s!important}
|
||||||
|
body.rm .stage .sk.breathe,body.rm .stage .sk.shim::after{animation:none}
|
||||||
|
body.rm .stage .mv{transition:none!important}
|
||||||
251
data/tv-v2/motion-2026-11-06/proto.js
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
// Wolfskin TV motion prototypes. One clock per stage drives Web Animations; "box rate" quantises the clock
|
||||||
|
// to the measured frame time on the Xiaomi box (p50 40 ms while moving, data/tv-spike-motion-speed/raw/gfx-*),
|
||||||
|
// so the same motion can be watched at 60 fps and at the rate the box actually draws today.
|
||||||
|
(() => {
|
||||||
|
const A = 'tv-motion-speed/';
|
||||||
|
const BOX_MS = 40;
|
||||||
|
const EASE = 'cubic-bezier(.25,.1,.25,1)', OUT = 'cubic-bezier(.33,1,.68,1)', INOUT = 'cubic-bezier(.65,0,.35,1)';
|
||||||
|
const POSTERS = [
|
||||||
|
['American Gangster', '2007 · 2h 36m'], ['Annie Hall', '1977 · 1h 33m'], ['Apocalypse Now', '1979 · 2h 27m'],
|
||||||
|
['Arrival', '2016 · 1h 56m'], ['Avatar: The Last Airbender', '2026 · 1h 52m'], ['Bohemian Rhapsody', '2018 · 2h 15m'],
|
||||||
|
['Braveheart', '1995 · 2h 58m'], ['Citizen Kane', '1941 · 1h 59m'], ['Spirited Away', '2001 · 2h 5m'],
|
||||||
|
['12 Years a Slave', '2013 · 2h 14m'], ['A Bronx Tale', '1993 · 2h 1m'], ['All the Money in the World', '2017 · 2h 13m'],
|
||||||
|
];
|
||||||
|
const poster = i => `${A}p${i + 1}.webp`;
|
||||||
|
const html = (s) => { const t = document.createElement('template'); t.innerHTML = s.trim(); return t.content.firstChild; };
|
||||||
|
|
||||||
|
function nav(cur, focus) {
|
||||||
|
const items = ['Home', 'Discover', 'Movies', 'Shows'];
|
||||||
|
return `<div class="nav"><span class="wm">Wolf<b>skin</b></span>${items.map((n, i) => `<span class="ni ${i === cur ? 'cur' : ''} ${i === focus ? 'f' : ''}">${n}</span>`).join('')}<span class="nsp"></span><span class="av">S</span></div>`;
|
||||||
|
}
|
||||||
|
function rate(rt, pc) { return `<span class="rate"><i></i>${rt}</span><span class="rate"><i class="pc"></i>${pc}</span>`; }
|
||||||
|
function heroCopy(o) {
|
||||||
|
return `<div class="copy" style="bottom:${o.bottom || 330}px"><div class="kicker">${o.kicker}</div><div class="ht">${o.title}</div><div class="hm">${o.meta}</div>${o.ov ? `<div class="ho">${o.ov}</div>` : ''}<div class="btns">${o.btns}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- scenes: build(stage, T, v) where v is the selected variant ----------
|
||||||
|
const SCENES = {
|
||||||
|
// 1. launch -> Who's watching
|
||||||
|
launch: { len: 1400, variants: { after: 'After: people from the saved list' }, build(s, T) {
|
||||||
|
s.append(html(`<div class="abs" style="inset:0;background:url(${A}launcher.webp) center/cover"></div>`));
|
||||||
|
const bg = s.appendChild(html(`<div class="abs" style="inset:0;background:#07070a"></div>`));
|
||||||
|
T.a(bg, [{ opacity: 0 }, { opacity: 1 }], 0, 160);
|
||||||
|
const pk = s.appendChild(html(`<div class="abs" style="inset:0"><div class="abs" style="left:96px;top:60px;font-size:36px;font-weight:800">Wolf<b style="color:var(--accent)">skin</b></div><div class="pk">Who’s watching?</div>
|
||||||
|
${[['J', '#64c3ff', 'justin', 540], ['M', '#ffa47a', 'mindel', 850], ['S', '#9be38f', 'shared', 1160]].map(([l, c, n, x]) => `<div class="person ${n === 'shared' ? 'ring' : ''}" style="left:${x}px;background:${c}">${l}</div><div class="pname" style="left:${x}px">${n}</div>`).join('')}</div>`));
|
||||||
|
T.a(pk, [{ opacity: 0 }, { opacity: 1 }], 450, 160);
|
||||||
|
} },
|
||||||
|
|
||||||
|
// 2. picker -> Home
|
||||||
|
home: { len: 1100, variants: { after: 'After: last Home painted from disk, art pre-decoded' }, build(s, T) {
|
||||||
|
const home = s.appendChild(html(`<div class="abs" style="inset:0">
|
||||||
|
<div class="amb" style="background-image:url(${A}b2t.webp);filter:blur(30px);transform:scale(1.1)"></div>
|
||||||
|
<div class="bd" style="background-image:url(${A}b2.webp)"></div><div class="scrim"></div>
|
||||||
|
${heroCopy({ kicker: '▸ Continue watching', title: 'Arrival', meta: '2016 · 1h 56m · 38 min left ' + rate('94%', '82%'), ov: 'Taking place after alien crafts land around the world, an expert linguist is recruited by the military to determine whether they come in peace or are a threat.', btns: '<span class="btn pri f">▶ Resume</span><span class="btn">ⓘ Details</span>', bottom: 340 })}
|
||||||
|
<div class="sh" style="top:780px">Next up</div>
|
||||||
|
${[3, 4, 1, 2].map((b, i) => `<div class="card tile" style="left:${96 + i * 440}px;top:850px"><div class="img" style="background-image:url(${A}b${b}.webp)"></div><div class="cap">${['American Gangster', 'Bohemian Rhapsody', 'Apocalypse Now', 'Arrival'][i]}</div></div>`).join('')}
|
||||||
|
${nav(0)}</div>`));
|
||||||
|
const picker = s.appendChild(html(`<div class="abs" style="inset:0;background:#07070a;z-index:10"><div class="pk">Who’s watching?</div>
|
||||||
|
${[['J', '#64c3ff', 'justin', 540], ['M', '#ffa47a', 'mindel', 850], ['S', '#9be38f', 'shared', 1160]].map(([l, c, n, x]) => `<div class="person ${n === 'shared' ? 'ring' : ''}" style="left:${x}px;background:${c}">${l}</div><div class="pname" style="left:${x}px">${n}</div>`).join('')}</div>`));
|
||||||
|
T.a(picker, [{ opacity: 1 }, { opacity: 0 }], 0, 140);
|
||||||
|
T.a(home.querySelector('.amb'), [{ opacity: 0 }, { opacity: 1 }], 40, 120);
|
||||||
|
T.a(home.querySelector('.bd'), [{ opacity: 0 }, { opacity: 1 }], 120, 220, INOUT);
|
||||||
|
T.a(home.querySelector('.copy'), [{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'none' }], 60, 180, OUT);
|
||||||
|
T.a(home.querySelector('.sh'), [{ opacity: 0 }, { opacity: 1 }], 120, 160);
|
||||||
|
home.querySelectorAll('.card').forEach((c, i) => {
|
||||||
|
T.a(c, [{ opacity: 0, transform: 'translateY(24px)' }, { opacity: 1, transform: 'none' }], 140 + i * 40, 180, OUT);
|
||||||
|
});
|
||||||
|
} },
|
||||||
|
|
||||||
|
// 3. skeleton styles on a poster grid
|
||||||
|
skeleton: { len: 2200, variants: { breathe: 'Breathe (pick)', flat: 'Flat blocks (today)', shimmer: 'Shimmer sweep', spinner: 'Spinner (today, launch)' }, build(s, T, v) {
|
||||||
|
s.append(html(nav(2)));
|
||||||
|
s.append(html(`<div class="abs" style="left:96px;top:150px;font-size:72px;font-weight:800;letter-spacing:-.02em">Movies <span style="font-size:30px;font-weight:500;color:var(--muted)">85 films</span></div>`));
|
||||||
|
const cells = [];
|
||||||
|
for (let r = 0; r < 2; r++) for (let c = 0; c < 6; c++) cells.push([96 + c * 296, 330 + r * 460, r * 6 + c]);
|
||||||
|
if (v === 'spinner') { const sp = s.appendChild(html('<div class="spin"></div>')); T.a(sp, [{ opacity: 1 }, { opacity: 0 }], 900, 120); }
|
||||||
|
cells.forEach(([x, y, i]) => {
|
||||||
|
if (v !== 'spinner') {
|
||||||
|
const sk = s.appendChild(html(`<div class="card poster sk ${v === 'breathe' ? 'breathe' : v === 'shimmer' ? 'shim' : ''}" style="left:${x}px;top:${y}px;${v === 'breathe' ? `animation-delay:${(i % 6) * 90}ms` : ''}"></div>`));
|
||||||
|
T.a(sk, [{ opacity: 1 }, { opacity: 0 }], 900 + i * 25, 160);
|
||||||
|
}
|
||||||
|
const c = s.appendChild(html(`<div class="card poster" style="left:${x}px;top:${y}px;background-image:url(${poster(i)})"></div>`));
|
||||||
|
T.a(c, [{ opacity: 0 }, { opacity: 1 }], 900 + i * 25, 160);
|
||||||
|
});
|
||||||
|
} },
|
||||||
|
|
||||||
|
// 4. tab switch: Home -> Discover, rows reveal
|
||||||
|
tab: { len: 1100, variants: { after: 'After: last Discover painted, rows rise, art fades' }, build(s, T) {
|
||||||
|
const disc = s.appendChild(html(`<div class="abs" style="inset:0">
|
||||||
|
<div class="bd" style="background-image:url(${A}b4.webp)"></div><div class="scrim"></div>
|
||||||
|
${heroCopy({ kicker: '✦ For you', title: 'Bohemian Rhapsody', meta: 'Movie · 2018 · Drama, Music ' + rate('60%', '85%'), ov: 'Singer Freddie Mercury, guitarist Brian May, drummer Roger Taylor and bass guitarist John Deacon take the music world by storm.', btns: '<span class="btn">ⓘ Details</span>', bottom: 520 })}
|
||||||
|
<div class="sh" style="top:600px">Movies for you</div>
|
||||||
|
${[6, 7, 8, 9, 10, 11].map((p, i) => `<div class="card poster" style="left:${96 + i * 296}px;top:670px"><div class="img" style="background-image:url(${poster(p)})"></div></div>`).join('')}
|
||||||
|
</div>`));
|
||||||
|
const home = s.appendChild(html(`<div class="abs" style="inset:0"><div class="bd" style="background-image:url(${A}b2.webp)"></div><div class="scrim"></div>
|
||||||
|
${heroCopy({ kicker: '▸ Continue watching', title: 'Arrival', meta: '2016 · 1h 56m · 38 min left', ov: 'Taking place after alien crafts land around the world, an expert linguist is recruited by the military.', btns: '<span class="btn pri">▶ Resume</span><span class="btn">ⓘ Details</span>', bottom: 340 })}</div>`));
|
||||||
|
const n = s.appendChild(html(nav(1, 1)));
|
||||||
|
T.a(home, [{ opacity: 1 }, { opacity: 0 }], 0, 200, EASE);
|
||||||
|
T.a(disc.querySelector('.bd'), [{ opacity: 0 }, { opacity: 1 }], 0, 240, INOUT);
|
||||||
|
T.a(disc.querySelector('.copy'), [{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'none' }], 80, 180, OUT);
|
||||||
|
T.a(disc.querySelector('.sh'), [{ opacity: 0 }, { opacity: 1 }], 140, 160);
|
||||||
|
disc.querySelectorAll('.card').forEach((c, i) => T.a(c, [{ opacity: 0, transform: 'translateY(24px)' }, { opacity: 1, transform: 'none' }], 160 + i * 30, 180, OUT));
|
||||||
|
} },
|
||||||
|
|
||||||
|
// 5. title page entry from the Movies grid
|
||||||
|
title: { len: 1600, variants: { after: 'After: what the card knew, at once' }, build(s, T) {
|
||||||
|
const page = s.appendChild(html(`<div class="abs" style="inset:0">
|
||||||
|
<div class="amb" style="background-image:url(${poster(2)});filter:blur(60px) saturate(1.2) brightness(.55);transform:scale(1.2)"></div>
|
||||||
|
<div class="bd" style="background-image:url(${A}b1.webp)"></div><div class="scrim"></div>
|
||||||
|
<div class="copy" style="bottom:360px"><div class="kicker">✓ Movie · In your library</div><div class="ht">Apocalypse Now</div>
|
||||||
|
<div class="hm">1979 · 2h 27m ${rate('91%', '94%')} <span class="late" style="color:var(--muted)">Drama, War</span></div>
|
||||||
|
<div class="ho late2" style="min-height:76px">At the height of the Vietnam war, Captain Benjamin Willard is sent on a dangerous mission that, officially, “does not exist, nor will it ever exist.”</div>
|
||||||
|
<div class="btns"><span class="btn pri f">▶ Play</span><span class="btn">⋯</span><span class="btn">Add for someone</span></div></div>
|
||||||
|
<div class="sh" style="top:830px">Cast</div>
|
||||||
|
${[0, 1, 2, 3, 4, 5, 6].map(i => `<div class="circle" style="left:${96 + i * 244}px;top:900px"><span class="sk breathe abs" style="inset:0;border-radius:50%;animation-delay:${i * 90}ms"></span><div class="img" style="background-image:url(${A}c${i + 1}.webp)"></div></div>`).join('')}
|
||||||
|
${nav(2)}</div>`));
|
||||||
|
const grid = s.appendChild(html(`<div class="abs" style="inset:0;background:#07070a">${nav(2)}
|
||||||
|
<div class="abs" style="left:96px;top:150px;font-size:72px;font-weight:800;letter-spacing:-.02em">Movies <span style="font-size:30px;font-weight:500;color:var(--muted)">85 films</span></div>
|
||||||
|
${[0, 1, 2, 3, 4, 5].map(i => `<div class="card poster ${i === 2 ? 'ring' : ''}" style="left:${96 + i * 296}px;top:330px;background-image:url(${poster(i)});${i === 2 ? 'transform:scale(1.08)' : ''}"></div>`).join('')}
|
||||||
|
${[6, 7, 8, 9, 10, 11].map((p, i) => `<div class="card poster" style="left:${96 + i * 296}px;top:790px;background-image:url(${poster(p)})"></div>`).join('')}</div>`));
|
||||||
|
T.a(grid, [{ opacity: 1 }, { opacity: 0 }], 0, 160);
|
||||||
|
T.a(page.querySelector('.amb'), [{ opacity: 0 }, { opacity: 1 }], 0, 160);
|
||||||
|
T.a(page.querySelector('.copy'), [{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'none' }], 40, 180, OUT);
|
||||||
|
T.a(page.querySelector('.late'), [{ opacity: 0 }, { opacity: 1 }], 430, 160);
|
||||||
|
T.a(page.querySelector('.late2'), [{ opacity: 0 }, { opacity: 1 }], 430, 160);
|
||||||
|
T.a(page.querySelector('.bd'), [{ opacity: 0 }, { opacity: 1 }], 470, 280, INOUT);
|
||||||
|
T.a(page.querySelector('.sh'), [{ opacity: 0 }, { opacity: 1 }], 120, 160);
|
||||||
|
page.querySelectorAll('.circle').forEach((c, i) => {
|
||||||
|
T.a(c, [{ opacity: 0, transform: 'translateY(20px)' }, { opacity: 1, transform: 'none' }], 140 + i * 30, 180, OUT);
|
||||||
|
T.a(c.querySelector('.img'), [{ opacity: 0 }, { opacity: 1 }], 620 + i * 40, 160);
|
||||||
|
});
|
||||||
|
} },
|
||||||
|
|
||||||
|
// 6. series title: late readiness line
|
||||||
|
series: { len: 4000, variants: { tv: 'TV-only: slot reserved, fades in at 3.4 s', conductarr: 'With the Conductarr cache: arrives ≈0.4 s' }, build(s, T, v) {
|
||||||
|
const late = v === 'conductarr' ? 380 : 3400;
|
||||||
|
const page = s.appendChild(html(`<div class="abs" style="inset:0">
|
||||||
|
<div class="amb" style="background-image:url(${A}s1t.webp);filter:blur(30px);transform:scale(1.1)"></div>
|
||||||
|
<div class="bd" style="background-image:url(${A}s1.webp)"></div><div class="scrim"></div>
|
||||||
|
<div class="copy" style="bottom:430px"><div class="kicker">✓ Series · In your library · Watched</div><div class="ht">Adults</div>
|
||||||
|
<div class="hm">2025 · 2 seasons · Comedy <span class="lr">${rate('88%', '93%')}</span></div>
|
||||||
|
<div class="hm lr2" style="color:var(--ok,#9be38f);font-size:26px">✓ Ready for you · by Mindel</div>
|
||||||
|
<div class="ho" style="min-height:76px">A group of twenty-somethings in New York trying to be good people, despite being neither “good” nor “people” yet.</div>
|
||||||
|
<div class="btns"><span class="btn pri f">▶ Play S1 E1</span><span class="btn">⋯</span><span class="btn">Seasons</span></div></div>
|
||||||
|
<div class="abs" style="left:96px;top:790px;display:flex;gap:16px"><span class="btn" style="height:56px;font-size:24px;background:var(--accent-soft,#2a1f3d)">Season 1</span><span class="btn" style="height:56px;font-size:24px">Season 2</span></div>
|
||||||
|
${[0, 1, 2, 3].map(i => `<div class="card tile sk breathe" style="left:${96 + i * 440}px;top:880px;animation-delay:${i * 90}ms"><div class="img" style="background-image:url(${A}${i % 2 ? 's1' : 'e1'}.webp)"></div></div>`).join('')}
|
||||||
|
${nav(3)}</div>`));
|
||||||
|
T.a(page.querySelector('.copy'), [{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'none' }], 0, 180, OUT);
|
||||||
|
T.a(page.querySelector('.amb'), [{ opacity: 0 }, { opacity: 1 }], 0, 160);
|
||||||
|
T.a(page.querySelector('.bd'), [{ opacity: 0 }, { opacity: 1 }], 620, 280, INOUT);
|
||||||
|
T.a(page.querySelector('.lr'), [{ opacity: 0 }, { opacity: 1 }], late, 200);
|
||||||
|
T.a(page.querySelector('.lr2'), [{ opacity: 0 }, { opacity: 1 }], late, 200);
|
||||||
|
page.querySelectorAll('.card .img').forEach((c, i) => T.a(c, [{ opacity: 0 }, { opacity: 1 }], 700 + i * 40, 160));
|
||||||
|
} },
|
||||||
|
|
||||||
|
// 7. focus move along a row
|
||||||
|
focus: { len: 3000, variants: { after: 'Proposed: 180 ms ease-out, caption rises', today: 'Today: 140 ms, caption cross-fade' }, build(s, T, v) {
|
||||||
|
s.append(html(nav(2)));
|
||||||
|
s.append(html(`<div class="sh" style="top:250px">Movies for you</div>`));
|
||||||
|
const dur = v === 'today' ? 140 : 180, ease = v === 'today' ? EASE : OUT;
|
||||||
|
const cards = [0, 1, 2, 3, 4, 5].map(i => s.appendChild(html(`<div class="card poster" style="left:${96 + i * 296}px;top:330px;background-image:url(${poster(i + 6)})"></div>`)));
|
||||||
|
const caps = [0, 1, 2, 3, 4, 5].map(i => s.appendChild(html(`<div class="abs" style="left:${96 + i * 296}px;top:740px;width:520px"><div style="font-size:30px;font-weight:700">${POSTERS[i + 6][0]}</div><div style="font-size:26px;color:var(--muted)">${POSTERS[i + 6][1]}</div></div>`)));
|
||||||
|
const step = 600;
|
||||||
|
cards.forEach((c, i) => {
|
||||||
|
const on = i * step, off = (i + 1) * step;
|
||||||
|
const up = [{ transform: 'scale(1)', boxShadow: '0 0 0 0 transparent' }, { transform: 'scale(1.08)', boxShadow: '0 0 0 2px oklch(78% 0.14 305), inset 0 0 0 2px oklch(78% 0.14 305), 0 18px 40px rgb(0 0 0/.55)' }];
|
||||||
|
if (i > 0) T.a(c, up, on, dur, ease); else T.a(c, [up[1], up[1]], 0, 1);
|
||||||
|
if (i < 4) T.a(c, [up[1], up[0]], off, dur, ease);
|
||||||
|
const capIn = v === 'today' ? [{ opacity: 0 }, { opacity: 1 }] : [{ opacity: 0, transform: 'translateY(10px)' }, { opacity: 1, transform: 'none' }];
|
||||||
|
if (i > 0) T.a(caps[i], capIn, on + (v === 'today' ? 0 : 60), dur, ease); else T.a(caps[i], [{ opacity: 1 }, { opacity: 1 }], 0, 1);
|
||||||
|
if (i < 4) T.a(caps[i], [{ opacity: 1 }, { opacity: 0 }], off, v === 'today' ? dur : 100, ease);
|
||||||
|
if (i > 4) caps[i].style.opacity = 0;
|
||||||
|
});
|
||||||
|
} },
|
||||||
|
|
||||||
|
// 8. hero crossfade (Discover hero advances)
|
||||||
|
hero: { len: 2600, variants: { after: 'Proposed: 320 ms backdrop cross-fade, copy hand-off', today: 'Today: cut' }, build(s, T, v) {
|
||||||
|
const mk = (b, t, meta, ov) => s.appendChild(html(`<div class="abs hero" style="inset:0"><div class="bd" style="background-image:url(${A}b${b}.webp)"></div><div class="scrim"></div>${heroCopy({ kicker: '✦ For you', title: t, meta, ov, btns: '<span class="btn">ⓘ Details</span>', bottom: 520 })}</div>`));
|
||||||
|
const b = mk(2, 'Arrival', 'Movie · 2016 · Drama, Sci-Fi ' + rate('94%', '82%'), 'Taking place after alien crafts land around the world, an expert linguist is recruited by the military to determine whether they come in peace or are a threat.');
|
||||||
|
const a = mk(1, 'Apocalypse Now', 'Movie · 1979 · Drama, War ' + rate('91%', '94%'), 'At the height of the Vietnam war, Captain Benjamin Willard is sent on a dangerous mission that, officially, does not exist.');
|
||||||
|
s.append(html(nav(1)));
|
||||||
|
s.append(html(`<div class="abs" style="left:96px;top:600px;font-size:34px;font-weight:700">Movies for you</div>`));
|
||||||
|
[0, 1, 2, 3, 4, 5].map(i => s.append(html(`<div class="card poster" style="left:${96 + i * 296}px;top:670px;background-image:url(${poster(i)})"></div>`)));
|
||||||
|
const at = 900;
|
||||||
|
if (v === 'today') { T.a(a, [{ opacity: 1 }, { opacity: 0 }], at, 1); return; }
|
||||||
|
T.a(a.querySelector('.copy'), [{ opacity: 1 }, { opacity: 0 }], at, 120, EASE);
|
||||||
|
T.a(a.querySelector('.bd'), [{ opacity: 1 }, { opacity: 0 }], at, 320, INOUT);
|
||||||
|
T.a(a.querySelector('.scrim'), [{ opacity: 1 }, { opacity: 1 }], at, 320);
|
||||||
|
T.a(a, [{ opacity: 1 }, { opacity: 0 }], at + 300, 20);
|
||||||
|
T.a(b.querySelector('.copy'), [{ opacity: 0, transform: 'translateY(14px)' }, { opacity: 1, transform: 'none' }], at + 140, 200, OUT);
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- player ----------
|
||||||
|
const players = [];
|
||||||
|
let boxRate = false;
|
||||||
|
function makePlayer(box) {
|
||||||
|
const name = box.dataset.scene, sc = SCENES[name];
|
||||||
|
const lead = +(box.dataset.lead || 300);
|
||||||
|
let variant = box.dataset.variant || Object.keys(sc.variants)[0];
|
||||||
|
let anims = [], t0 = 0, raf = 0, stage, label;
|
||||||
|
const total = () => lead + sc.len + 1400;
|
||||||
|
function build() {
|
||||||
|
box.querySelectorAll('.stage').forEach(e => e.remove());
|
||||||
|
stage = html('<div class="stage"></div>'); box.appendChild(stage);
|
||||||
|
label = stage.appendChild(html('<div class="tl">0 ms</div>'));
|
||||||
|
anims = [];
|
||||||
|
const rm = document.body.classList.contains('rm');
|
||||||
|
const seen = new WeakSet();
|
||||||
|
const T = { a(el, kf, start, dur, easing = EASE, opt = {}) {
|
||||||
|
let k = kf, d = dur;
|
||||||
|
if (rm) { k = kf.map(f => { const g = { ...f }; delete g.transform; return g; }); d = Math.min(dur, 100); }
|
||||||
|
// only an element's first animation fills backwards; later ones on the same element must not override it while waiting
|
||||||
|
const fill = seen.has(el) ? 'forwards' : 'both'; seen.add(el);
|
||||||
|
const an = el.animate(k, { delay: lead + start, duration: Math.max(1, d), easing, fill, ...opt });
|
||||||
|
an.pause(); anims.push(an);
|
||||||
|
} };
|
||||||
|
sc.build(stage, T, variant);
|
||||||
|
fit();
|
||||||
|
}
|
||||||
|
function fit() { if (stage) stage.style.transform = `scale(${box.clientWidth / 1920})`; }
|
||||||
|
function tick(now) {
|
||||||
|
let t = now - t0;
|
||||||
|
if (t > total()) { t0 = now; t = 0; }
|
||||||
|
const q = boxRate ? Math.floor(t / BOX_MS) * BOX_MS : t;
|
||||||
|
for (const an of anims) an.currentTime = q;
|
||||||
|
label.textContent = `${Math.max(0, Math.round(q - lead))} ms`;
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
function start() { cancelAnimationFrame(raf); build(); t0 = performance.now(); raf = requestAnimationFrame(tick); }
|
||||||
|
function stop() { cancelAnimationFrame(raf); raf = 0; }
|
||||||
|
new ResizeObserver(fit).observe(box);
|
||||||
|
new IntersectionObserver(es => es.forEach(e => { if (e.isIntersecting) { if (!raf) start(); } else stop(); }), { rootMargin: '100px' }).observe(box);
|
||||||
|
const p = { box, start, setVariant(v) { variant = v; start(); }, rebuild: start };
|
||||||
|
players.push(p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.sbox[data-scene]').forEach(makePlayer);
|
||||||
|
document.querySelectorAll('[data-variants]').forEach(bar => {
|
||||||
|
const p = players.find(x => x.box.id === bar.dataset.variants);
|
||||||
|
const sc = SCENES[p.box.dataset.scene];
|
||||||
|
Object.entries(sc.variants).forEach(([k, label], i) => {
|
||||||
|
const b = document.createElement('button'); b.type = 'button'; b.className = 'tab' + (i === 0 ? ' on' : ''); b.textContent = label;
|
||||||
|
b.onclick = () => { bar.querySelectorAll('.tab').forEach(x => x.classList.toggle('on', x === b)); p.setVariant(k); };
|
||||||
|
bar.appendChild(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('button.replay').forEach(b => b.addEventListener('click', () => {
|
||||||
|
const pair = b.closest('.pair');
|
||||||
|
pair.querySelectorAll('video').forEach(v => { v.currentTime = 0; v.play().catch(() => {}); });
|
||||||
|
players.filter(p => pair.contains(p.box)).forEach(p => p.start());
|
||||||
|
}));
|
||||||
|
const rate_ = document.getElementById('boxrate');
|
||||||
|
if (rate_) rate_.addEventListener('change', () => { boxRate = rate_.checked; });
|
||||||
|
const rm = document.getElementById('rm');
|
||||||
|
if (rm) {
|
||||||
|
if (matchMedia('(prefers-reduced-motion: reduce)').matches) { rm.checked = true; document.body.classList.add('rm'); }
|
||||||
|
rm.addEventListener('change', () => { document.body.classList.toggle('rm', rm.checked); players.forEach(p => p.rebuild()); });
|
||||||
|
}
|
||||||
|
document.body.dataset.ready = '1';
|
||||||
|
})();
|
||||||
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/b1.webp
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/b1t.webp
Normal file
|
After Width: | Height: | Size: 244 B |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/b2.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/b2t.webp
Normal file
|
After Width: | Height: | Size: 204 B |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/b3.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/b3t.webp
Normal file
|
After Width: | Height: | Size: 388 B |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/b4.webp
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/b4t.webp
Normal file
|
After Width: | Height: | Size: 522 B |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/before-person.webm
Normal file
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/c1.webp
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/c2.webp
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/c3.webp
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/c4.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/c5.webp
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/c6.webp
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/c7.webp
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/c8.webp
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/e1.webp
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 66 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/launcher.webp
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/p1.webp
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/p10.webp
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/p11.webp
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/p12.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/p2.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/p3.webp
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/p4.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/p5.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/p6.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/p7.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/p8.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/p9.webp
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/s1.webp
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
data/tv-v2/motion-2026-11-06/tv-motion-speed/s1t.webp
Normal file
|
After Width: | Height: | Size: 732 B |
|
|
@ -32,6 +32,7 @@ def render_tokens(tokens: dict) -> tuple[str, str]:
|
||||||
"// Generated by generate_tokens.py; edit the JSON sources instead.",
|
"// Generated by generate_tokens.py; edit the JSON sources instead.",
|
||||||
"package org.justinvisser.wolfskin.design",
|
"package org.justinvisser.wolfskin.design",
|
||||||
"",
|
"",
|
||||||
|
"import androidx.compose.animation.core.CubicBezierEasing",
|
||||||
"import androidx.compose.ui.graphics.Color as ComposeColor",
|
"import androidx.compose.ui.graphics.Color as ComposeColor",
|
||||||
"import androidx.compose.ui.text.TextStyle",
|
"import androidx.compose.ui.text.TextStyle",
|
||||||
"import androidx.compose.ui.text.font.FontWeight",
|
"import androidx.compose.ui.text.font.FontWeight",
|
||||||
|
|
@ -71,6 +72,10 @@ def render_tokens(tokens: dict) -> tuple[str, str]:
|
||||||
elif kind == "duration":
|
elif kind == "duration":
|
||||||
lines.append(f" const val {name} = {int(value.removesuffix('ms'))}")
|
lines.append(f" const val {name} = {int(value.removesuffix('ms'))}")
|
||||||
css.append(f" --{group_name}-{name}: {value};")
|
css.append(f" --{group_name}-{name}: {value};")
|
||||||
|
elif kind == "cubicBezier":
|
||||||
|
points = ", ".join(f"{number(point)}f" for point in value)
|
||||||
|
lines.append(f" val {name} = CubicBezierEasing({points})")
|
||||||
|
css.append(f" --{group_name}-{name}: cubic-bezier({', '.join(map(str, value))});")
|
||||||
elif kind == "transition" and value.get("type") == "spring":
|
elif kind == "transition" and value.get("type") == "spring":
|
||||||
lines.append(f" const val {name}DampingRatio = {number(value['dampingRatio'])}f")
|
lines.append(f" const val {name}DampingRatio = {number(value['dampingRatio'])}f")
|
||||||
lines.append(f" const val {name}Stiffness = {number(value['stiffness'])}f")
|
lines.append(f" const val {name}Stiffness = {number(value['stiffness'])}f")
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.CubicBezierEasing
|
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -17,16 +16,19 @@ import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.layout.Layout
|
import androidx.compose.ui.layout.Layout
|
||||||
import androidx.compose.ui.unit.constrainHeight
|
import androidx.compose.ui.unit.constrainHeight
|
||||||
|
|
||||||
/** Captions fade without moving the reserved band under the focused poster. */
|
/** Captions rise inside the reserved band without changing measured geometry. */
|
||||||
@Composable
|
@Composable
|
||||||
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false, accentFirstFact: Boolean = false,
|
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false, accentFirstFact: Boolean = false,
|
||||||
titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption,
|
titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption,
|
||||||
titleOverflow: TextOverflow = TextOverflow.Ellipsis) {
|
titleOverflow: TextOverflow = TextOverflow.Ellipsis) {
|
||||||
val alpha = remember(title, facts) { Animatable(0f) }
|
val alpha = remember(title, facts) { Animatable(0f) }
|
||||||
LaunchedEffect(alpha, visible) {
|
val reduced = reducedMotion()
|
||||||
alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
|
val rise = with(androidx.compose.ui.platform.LocalDensity.current) { WolfskinTokens.Motion.captionRise.toPx() }
|
||||||
|
LaunchedEffect(alpha, visible, reduced) {
|
||||||
|
if (visible && !reduced) kotlinx.coroutines.delay(WolfskinTokens.Motion.captionDelay.toLong())
|
||||||
|
alpha.animateTo(if (visible) 1f else 0f, tween(motionDuration(if (visible) WolfskinTokens.Motion.focusMove else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut))
|
||||||
}
|
}
|
||||||
Column(modifier.graphicsLayer { this.alpha = alpha.value }) {
|
Column(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - alpha.value) }) {
|
||||||
Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = titleOverflow)
|
Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = titleOverflow)
|
||||||
CaptionFacts(facts, accentFirstFact, accentLastFact, factsStyle)
|
CaptionFacts(facts, accentFirstFact, accentLastFact, factsStyle)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,12 +56,17 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
|
||||||
if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth,
|
if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth,
|
||||||
if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright)
|
if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright)
|
||||||
}
|
}
|
||||||
if (painter != null) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
|
val heroReady = LocalHeroArtReady.current
|
||||||
|
if (painter != null) {
|
||||||
|
androidx.compose.runtime.SideEffect { heroReady?.invoke() }
|
||||||
|
Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
|
||||||
|
}
|
||||||
else AsyncImage(
|
else AsyncImage(
|
||||||
model = request,
|
model = request,
|
||||||
imageLoader = WolfskinImages.loader(context),
|
imageLoader = WolfskinImages.loader(context),
|
||||||
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = filter,
|
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = filter,
|
||||||
onError = { onError() },
|
onError = { onError() },
|
||||||
|
onSuccess = { heroReady?.invoke() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,7 +75,11 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
|
||||||
fun DiscoverBackdrop(title: DiscoverTitle, detail: Boolean = false, modifier: Modifier = Modifier) {
|
fun DiscoverBackdrop(title: DiscoverTitle, detail: Boolean = false, modifier: Modifier = Modifier) {
|
||||||
val failed = remember(title.poster, title.backdrop) { androidx.compose.runtime.mutableStateOf(false) }
|
val failed = remember(title.poster, title.backdrop) { androidx.compose.runtime.mutableStateOf(false) }
|
||||||
Box(modifier.size(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).clipToBounds().background(C.surface3)) {
|
Box(modifier.size(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).clipToBounds().background(C.surface3)) {
|
||||||
if (failed.value || (title.poster == null && title.backdrop == null)) HomeAmbient(Modifier.fillMaxSize().testTag("discover-missing-art"))
|
if (failed.value || (title.poster == null && title.backdrop == null)) {
|
||||||
|
val heroReady = LocalHeroArtReady.current
|
||||||
|
androidx.compose.runtime.SideEffect { heroReady?.invoke() }
|
||||||
|
HomeAmbient(Modifier.fillMaxSize().testTag("discover-missing-art"))
|
||||||
|
}
|
||||||
else if (title.posterOnly) {
|
else if (title.posterOnly) {
|
||||||
DiscoverArtwork(title.poster, Modifier.offset(-D.ambientInset, -D.ambientInset)
|
DiscoverArtwork(title.poster, Modifier.offset(-D.ambientInset, -D.ambientInset)
|
||||||
.wrapContentSize(Alignment.TopStart, unbounded = true)
|
.wrapContentSize(Alignment.TopStart, unbounded = true)
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
|
@ -73,8 +73,8 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (state.snapshot.status == DiscoverStatus.Loading) {
|
if (state.snapshot.status == DiscoverStatus.Loading) {
|
||||||
val loading = remember(state) { LibraryGridState("Discover", emptyList(), catalogue = true).apply { status = LibraryStatus.Loading } }
|
LaunchedEffect(state) { if (!shell.navFocused) shell.focusNav(shell.currentTab) }
|
||||||
LibraryScreen(shell, loading, modifier, offlineArt = LocalDiscoverArt.current, registerFocus = false)
|
DiscoverLoading(modifier)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
@ -104,7 +104,9 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(modifier.fillMaxSize().testTag("discover-screen")) {
|
Box(modifier.fillMaxSize().testTag("discover-screen")) {
|
||||||
if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { DiscoverBackdrop(it, modifier = Modifier.graphicsLayer { translationY = -list.firstVisibleItemScrollOffset.toFloat() }) }
|
if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { title ->
|
||||||
|
HeroArtCrossfade(title.key, title, L.row_phero, Modifier.offset { IntOffset(0, -list.firstVisibleItemScrollOffset) }) { DiscoverBackdrop(it) }
|
||||||
|
}
|
||||||
LazyColumn(state = list, contentPadding = PaddingValues(bottom = (WolfskinTokens.Shell.stageHeight - L.pinnedLine - heights.last()).coerceAtLeast(D.frameBottomMin)), modifier = Modifier.fillMaxSize()) {
|
LazyColumn(state = list, contentPadding = PaddingValues(bottom = (WolfskinTokens.Shell.stageHeight - L.pinnedLine - heights.last()).coerceAtLeast(D.frameBottomMin)), modifier = Modifier.fillMaxSize()) {
|
||||||
item(key = "discover-header", contentType = if (state.hasHero && state.service == null) "hero" else "header") {
|
item(key = "discover-header", contentType = if (state.hasHero && state.service == null) "hero" else "header") {
|
||||||
if (state.hasHero && state.service == null) DiscoverHero(state) else LibraryHeader(state.shell.person, state.service?.name ?: "Discover", if (state.service != null) "General interest" else "")
|
if (state.hasHero && state.service == null) DiscoverHero(state) else LibraryHeader(state.shell.person, state.service?.name ?: "Discover", if (state.service != null) "General interest" else "")
|
||||||
|
|
@ -132,22 +134,23 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
||||||
@Composable
|
@Composable
|
||||||
private fun DiscoverHero(state: DiscoverState) {
|
private fun DiscoverHero(state: DiscoverState) {
|
||||||
val title = state.hero ?: return
|
val title = state.hero ?: return
|
||||||
|
HeroCopyHandoff(title.key, title) { copy ->
|
||||||
Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) {
|
Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) {
|
Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Glyph("seerr", C.accentText, Modifier.size(L.buttonIcon))
|
Glyph("seerr", C.accentText, Modifier.size(L.buttonIcon))
|
||||||
Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = DiscoverType.label)
|
Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = DiscoverType.label)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(D.heroKickerGap))
|
Spacer(Modifier.height(D.heroKickerGap))
|
||||||
Text(title.title, Modifier.width(L.heroCopyWidth).testTag("discover-hero-title-box").discoverLineBox(DiscoverType.hero), color = C.fg, style = DiscoverType.hero, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
Text(copy.title, Modifier.width(L.heroCopyWidth).testTag("discover-hero-title-box").discoverLineBox(DiscoverType.hero), color = C.fg, style = DiscoverType.hero, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
Spacer(Modifier.height(D.heroTitleGap))
|
Spacer(Modifier.height(D.heroTitleGap))
|
||||||
Row(Modifier.height(D.heroFactsHeight), horizontalArrangement = Arrangement.spacedBy(D.heroFactsGap), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.height(D.heroFactsHeight), horizontalArrangement = Arrangement.spacedBy(D.heroFactsGap), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text("${title.typeWord}${title.year?.let { " · $it" }.orEmpty()}", color = C.muted, style = DiscoverType.body)
|
Text("${copy.typeWord}${copy.year?.let { " · $it" }.orEmpty()}", color = C.muted, style = DiscoverType.body)
|
||||||
if (title.genres.isNotEmpty()) Text(title.genres.take(2).joinToString(", "), color = C.muted, style = DiscoverType.body)
|
if (copy.genres.isNotEmpty()) Text(copy.genres.take(2).joinToString(", "), color = C.muted, style = DiscoverType.body)
|
||||||
if (state.isSignedIn) DiscoverRatings(title.ratings)
|
if (state.isSignedIn) DiscoverRatings(copy.ratings)
|
||||||
if (state.shell.person.key in title.heldBy) Text("In your library", color = C.accentText, style = DiscoverType.body)
|
if (state.shell.person.key in copy.heldBy) Text("In your library", color = C.accentText, style = DiscoverType.body)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(D.heroMetaGap))
|
Spacer(Modifier.height(D.heroMetaGap))
|
||||||
Text(title.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
Text(copy.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
Spacer(Modifier.height(D.heroOverviewGap))
|
Spacer(Modifier.height(D.heroOverviewGap))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) }
|
DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) }
|
||||||
|
|
@ -158,6 +161,7 @@ private fun DiscoverHero(state: DiscoverState) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
|
||||||
LazyRow(state = cards, userScrollEnabled = false,
|
LazyRow(state = cards, userScrollEnabled = false,
|
||||||
contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap),
|
contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap),
|
||||||
horizontalArrangement = Arrangement.spacedBy(K.posterGap),
|
horizontalArrangement = Arrangement.spacedBy(K.posterGap),
|
||||||
modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + K.posterGap * 2)) {
|
modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + K.posterGap * 2).skeletonPulse(lane.pending).rowReveal(lane.id, !lane.pending)) {
|
||||||
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
|
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
|
||||||
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
|
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
|
||||||
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
|
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
|
||||||
|
|
@ -66,7 +66,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
|
||||||
}
|
}
|
||||||
} else if (lane.pending) {
|
} else if (lane.pending) {
|
||||||
items(slots, key = { "pending:$it" }) {
|
items(slots, key = { "pending:$it" }) {
|
||||||
Box(Modifier.size(K.posterW, K.posterH).background(C.surface3, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
Box(Modifier.size(K.posterW, K.posterH).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->
|
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,8 @@ class DiscoverState(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun moveRow(direction: Int) {
|
private fun moveRow(direction: Int) {
|
||||||
val target = focusedRow + direction
|
var target = focusedRow + direction
|
||||||
|
while (target in firstShelf until rowCount && lanes[target - firstShelf].pending) target += direction
|
||||||
if (target < if (hasHero) 0 else 1) { shell.focusNav(shell.currentTab); return }
|
if (target < if (hasHero) 0 else 1) { shell.focusNav(shell.currentTab); return }
|
||||||
if (target >= rowCount) return
|
if (target >= rowCount) return
|
||||||
val key = when (target) { 0 -> "phero"; 1 -> "dchips"; else -> lanes[target - firstShelf].id }
|
val key = when (target) { 0 -> "phero"; 1 -> "dchips"; else -> lanes[target - firstShelf].id }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.animation.core.CubicBezierEasing
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -26,9 +25,10 @@ import androidx.compose.ui.unit.Dp
|
||||||
/** Only transforms animate: focus never changes measured geometry. */
|
/** Only transforms animate: focus never changes measured geometry. */
|
||||||
@Composable
|
@Composable
|
||||||
fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier {
|
fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier {
|
||||||
|
val reduced = reducedMotion()
|
||||||
val animated by animateFloatAsState(
|
val animated by animateFloatAsState(
|
||||||
if (focused) scale else 1f,
|
if (focused) scale else 1f,
|
||||||
tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)),
|
tween(if (reduced) 0 else WolfskinTokens.Motion.focusMove, easing = WolfskinTokens.Motion.easeOut),
|
||||||
label = "focus-scale",
|
label = "focus-scale",
|
||||||
)
|
)
|
||||||
return graphicsLayer { scaleX = animated; scaleY = animated }
|
return graphicsLayer { scaleX = animated; scaleY = animated }
|
||||||
|
|
@ -40,8 +40,9 @@ enum class FocusLift { None, Card, Button }
|
||||||
/** Every focus ring straddles the surface edge above content, without a background gap. */
|
/** Every focus ring straddles the surface edge above content, without a background gap. */
|
||||||
@Composable
|
@Composable
|
||||||
fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier {
|
fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier {
|
||||||
|
val reduced = reducedMotion()
|
||||||
val ringAlpha by animateFloatAsState(if (focused) 1f else 0f,
|
val ringAlpha by animateFloatAsState(if (focused) 1f else 0f,
|
||||||
tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring")
|
tween(motionDuration(WolfskinTokens.Motion.focusMove, reduced), easing = WolfskinTokens.Motion.easeOut), label = "focus-ring")
|
||||||
val shadow = if (lift == FocusLift.None) this else {
|
val shadow = if (lift == FocusLift.None) this else {
|
||||||
val shape = remember(radius) { RoundedCornerShape(radius) }
|
val shape = remember(radius) { RoundedCornerShape(radius) }
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.animation.core.CubicBezierEasing
|
|
||||||
import androidx.compose.animation.core.animate
|
import androidx.compose.animation.core.animate
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
|
@ -11,8 +10,8 @@ internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) {
|
||||||
if (distance == 0f) return
|
if (distance == 0f) return
|
||||||
list.scroll {
|
list.scroll {
|
||||||
var previous = 0f
|
var previous = 0f
|
||||||
animate(0f, distance, animationSpec = tween(WolfskinTokens.Motion.scroll,
|
animate(0f, distance, animationSpec = tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll,
|
||||||
easing = CubicBezierEasing(.33f, 1f, .68f, 1f))) { value, _ ->
|
easing = WolfskinTokens.Motion.easeOut)) { value, _ ->
|
||||||
scrollBy(value - previous)
|
scrollBy(value - previous)
|
||||||
previous = value
|
previous = value
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
|
/** Artwork success or its final fallback releases a pending hero hand-off. */
|
||||||
|
internal val LocalHeroArtReady = compositionLocalOf<(() -> Unit)?> { null }
|
||||||
|
|
||||||
|
/** Only the upper hero region blends; the lower backdrop changes without an animated screen layer. */
|
||||||
|
@Composable
|
||||||
|
fun <T> HeroArtCrossfade(identity: Any, target: T, height: Dp, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) {
|
||||||
|
var displayed by remember { mutableStateOf(target) }
|
||||||
|
var displayedIdentity by remember { mutableStateOf(identity) }
|
||||||
|
var outgoing by remember { mutableStateOf<T?>(null) }
|
||||||
|
val alpha = remember { Animatable(0f) }
|
||||||
|
val reduced = reducedMotion()
|
||||||
|
var readyIdentity by remember { mutableStateOf(identity) }
|
||||||
|
val latestIdentity by rememberUpdatedState(identity)
|
||||||
|
val onReady = remember(identity) { { if (latestIdentity == identity) readyIdentity = identity } }
|
||||||
|
LaunchedEffect(identity, reduced) {
|
||||||
|
if (displayedIdentity != identity) {
|
||||||
|
outgoing = displayed
|
||||||
|
alpha.snapTo(1f)
|
||||||
|
snapshotFlow { readyIdentity == identity }.first { it }
|
||||||
|
displayed = target
|
||||||
|
displayedIdentity = identity
|
||||||
|
}
|
||||||
|
if (outgoing != null) {
|
||||||
|
alpha.animateTo(0f, tween(motionDuration(M.heroFade, reduced), easing = M.easeInOut))
|
||||||
|
outgoing = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(modifier) {
|
||||||
|
CompositionLocalProvider(LocalHeroArtReady provides onReady) { content(target) }
|
||||||
|
outgoing?.let { old ->
|
||||||
|
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, height).graphicsLayer { this.alpha = alpha.value; clip = true }.testTag("hero-outgoing")) {
|
||||||
|
Box(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { content(old) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One bounded copy layer fades out, hands off, then rises; controls keep their logical owner. */
|
||||||
|
@Composable
|
||||||
|
fun <T> HeroCopyHandoff(identity: Any, target: T, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) {
|
||||||
|
val reduced = reducedMotion()
|
||||||
|
val alpha = remember { Animatable(0f) }
|
||||||
|
val progress = remember { Animatable(0f) }
|
||||||
|
var displayed by remember { mutableStateOf(target) }
|
||||||
|
var previous by remember { mutableStateOf(identity) }
|
||||||
|
var handingOff by remember { mutableStateOf(false) }
|
||||||
|
var entered by remember { mutableStateOf(false) }
|
||||||
|
val rise = with(androidx.compose.ui.platform.LocalDensity.current) { (if (entered) M.copyRise else M.copyEntryRise).toPx() }
|
||||||
|
LaunchedEffect(identity, reduced) {
|
||||||
|
if (previous != identity || handingOff) {
|
||||||
|
handingOff = true
|
||||||
|
alpha.animateTo(0f, tween(motionDuration(M.heroCopyOut, reduced), easing = M.ease))
|
||||||
|
displayed = target
|
||||||
|
previous = identity
|
||||||
|
progress.snapTo(0f)
|
||||||
|
if (!reduced) kotlinx.coroutines.delay((M.heroCopyDelay - M.heroCopyOut).toLong())
|
||||||
|
kotlinx.coroutines.coroutineScope {
|
||||||
|
launch { alpha.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) }
|
||||||
|
launch { progress.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) }
|
||||||
|
}
|
||||||
|
handingOff = false
|
||||||
|
} else if (!entered) {
|
||||||
|
kotlinx.coroutines.coroutineScope {
|
||||||
|
launch { alpha.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) }
|
||||||
|
launch { progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entered = true
|
||||||
|
displayed = target
|
||||||
|
}
|
||||||
|
Box(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - progress.value) }) {
|
||||||
|
content(if (handingOff || previous != identity) displayed else target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,24 +23,27 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
|
||||||
if (!row.skeleton) {
|
if (!row.skeleton) {
|
||||||
val title = row.cards.first()
|
val title = row.cards.first()
|
||||||
val artModifier = Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)
|
val artModifier = Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)
|
||||||
TitleArtwork(title.title, title.type, title.poster, WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight, artModifier, backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = true, hero = true)
|
HeroArtCrossfade(title.id, title, L.row_hero, artModifier) { art ->
|
||||||
|
TitleArtwork(art.title, art.type, art.poster, WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight, artModifier, backdrop = art.backdrop, posterOnly = art.posterOnly, landscape = true, hero = true)
|
||||||
|
}
|
||||||
Box(artModifier.background(Brush.verticalGradient(0f to C.bg.copy(alpha = 0f), .52f to C.bg.copy(alpha = 0f), .74f to C.bg.copy(alpha = .6f), 1f to C.bg)))
|
Box(artModifier.background(Brush.verticalGradient(0f to C.bg.copy(alpha = 0f), .52f to C.bg.copy(alpha = 0f), .74f to C.bg.copy(alpha = .6f), 1f to C.bg)))
|
||||||
Box(artModifier.background(Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f))))
|
Box(artModifier.background(Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f))))
|
||||||
Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) {
|
HeroCopyHandoff(title.id, title, Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) { copy ->
|
||||||
|
Column {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.kickerGap)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.kickerGap)) {
|
||||||
Glyph(if (row.heroKind == "pick") "seerr" else "playback", C.accentText, Modifier.size(L.buttonIcon * (28f / 30f)))
|
Glyph(if (row.heroKind == "pick") "seerr" else "playback", C.accentText, Modifier.size(L.buttonIcon * (28f / 30f)))
|
||||||
Text(when (row.heroKind) { "resume" -> "Continue watching"; "next" -> "Up next"; "new" -> "New in your library"; else -> "For you" }, modifier = Modifier.exactLineBox(T.label), color = C.accentText, style = T.label)
|
Text(when (row.heroKind) { "resume" -> "Continue watching"; "next" -> "Up next"; "new" -> "New in your library"; else -> "For you" }, modifier = Modifier.exactLineBox(T.label), color = C.accentText, style = T.label)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(H.kickerBottom))
|
Spacer(Modifier.height(H.kickerBottom))
|
||||||
Text(title.displayTitle, modifier = Modifier.exactLineBox(T.displayHero), color = C.fg, style = T.displayHero, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
Text(copy.displayTitle, modifier = Modifier.exactLineBox(T.displayHero), color = C.fg, style = T.displayHero, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
Spacer(Modifier.height(H.titleBottom))
|
Spacer(Modifier.height(H.titleBottom))
|
||||||
Row(Modifier.heightIn(min = H.factsHeight), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.factsGap)) {
|
Row(Modifier.heightIn(min = H.factsHeight), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.factsGap)) {
|
||||||
Text(if (title.isEpisode) title.subtitle else title.year?.toString().orEmpty(), modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body, maxLines = 1)
|
Text(if (copy.isEpisode) copy.subtitle else copy.year?.toString().orEmpty(), modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body, maxLines = 1)
|
||||||
if (row.heroKind == "resume" || title.runtimeSeconds != null) Text(if (row.heroKind == "resume") title.remaining else title.runtime, modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body, maxLines = 1)
|
if (row.heroKind == "resume" || copy.runtimeSeconds != null) Text(if (row.heroKind == "resume") copy.remaining else copy.runtime, modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body, maxLines = 1)
|
||||||
HomeRatingsRow(ratings)
|
HomeRatingsRow(ratings)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(H.factsBottom))
|
Spacer(Modifier.height(H.factsBottom))
|
||||||
Text(title.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
|
Text(copy.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
|
||||||
Spacer(Modifier.height(H.overviewBottom))
|
Spacer(Modifier.height(H.overviewBottom))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||||
val detailsOnly = row.heroKind == "pick" || !row.watchReady
|
val detailsOnly = row.heroKind == "pick" || !row.watchReady
|
||||||
|
|
@ -49,14 +52,15 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
|
||||||
if (!detailsOnly) PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
|
if (!detailsOnly) PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
HomeAmbient(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
|
HomeAmbient(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
|
||||||
Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom)) {
|
Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).skeletonPulse()) {
|
||||||
Box(Modifier.size(H.heroSkeletonWidth, H.heroSkeletonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
Box(Modifier.size(H.heroSkeletonWidth, H.heroSkeletonHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||||
Spacer(Modifier.height(H.heroSkeletonGap))
|
Spacer(Modifier.height(H.heroSkeletonGap))
|
||||||
Box(Modifier.size(H.heroSkeletonFactsWidth, H.heroSkeletonFactsHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
|
Box(Modifier.size(H.heroSkeletonFactsWidth, H.heroSkeletonFactsHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
|
||||||
Spacer(Modifier.height(H.heroSkeletonButtonGap))
|
Spacer(Modifier.height(H.heroSkeletonButtonGap))
|
||||||
Box(Modifier.size(H.heroSkeletonButtonWidth, L.buttonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button)))
|
Box(Modifier.size(H.heroSkeletonButtonWidth, L.buttonHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.button)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ fun HomeRatingsRow(ratings: List<HomeRating>, modifier: Modifier = Modifier, fon
|
||||||
/** The plate is a prefix of the full ratings set; its first entry is never discarded. */
|
/** The plate is a prefix of the full ratings set; its first entry is never discarded. */
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeRatingPlate(ratings: List<HomeRating>, visible: Boolean, modifier: Modifier = Modifier) {
|
fun HomeRatingPlate(ratings: List<HomeRating>, visible: Boolean, modifier: Modifier = Modifier) {
|
||||||
val alpha by animateFloatAsState(if (visible && ratings.isNotEmpty()) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "rating-plate")
|
val alpha by animateFloatAsState(if (visible && ratings.isNotEmpty()) 1f else 0f, tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())), label = "rating-plate")
|
||||||
if (ratings.isEmpty()) return
|
if (ratings.isEmpty()) return
|
||||||
val measurer = rememberTextMeasurer()
|
val measurer = rememberTextMeasurer()
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.animation.core.CubicBezierEasing
|
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.gestures.animateScrollBy
|
import androidx.compose.foundation.gestures.animateScrollBy
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
|
@ -69,7 +68,7 @@ fun HomeScreen(
|
||||||
(heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx()
|
(heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx()
|
||||||
}.coerceAtLeast(0f)
|
}.coerceAtLeast(0f)
|
||||||
val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset
|
val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset
|
||||||
view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)))
|
view.listState.animateScrollBy(target - current, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut))
|
||||||
view.home.positionedKey = key
|
view.home.positionedKey = key
|
||||||
view.home.positionedReset = view.home.resetScroll
|
view.home.positionedReset = view.home.resetScroll
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,15 +50,15 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
|
||||||
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) {
|
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) {
|
||||||
if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide))
|
if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide))
|
||||||
val cards = if (row.skeleton) List(if (row.cardKind == HomeCardKind.Poster) 8 else 5) { null } else row.cards + if (row.hasSeeAll) listOf(null) else emptyList()
|
val cards = if (row.skeleton) List(if (row.cardKind == HomeCardKind.Poster) 8 else 5) { null } else row.cards + if (row.hasSeeAll) listOf(null) else emptyList()
|
||||||
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + L.rowGapAfter * 2), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) {
|
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + L.rowGapAfter * 2).skeletonPulse(row.skeleton).rowReveal(row.id, !row.skeleton), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) {
|
||||||
itemsIndexed(cards, key = { index, title -> title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title ->
|
itemsIndexed(cards, key = { index, title -> title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title ->
|
||||||
val selected = focused && column == index
|
val selected = !row.skeleton && focused && column == index
|
||||||
val cardModifier = Modifier.zIndex(if (selected) 2f else 0f).testTag("home-card:${row.id}:$index")
|
val cardModifier = Modifier.zIndex(if (selected) 2f else 0f).testTag("home-card:${row.id}:$index")
|
||||||
.then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier)
|
.then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier)
|
||||||
if (title == null && !row.skeleton) {
|
if (title == null && !row.skeleton) {
|
||||||
LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label)
|
LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label)
|
||||||
} else TitleCard(selected, cardModifier.then(if (!row.skeleton) Modifier.clickable { onCard(index) } else Modifier), width, height) {
|
} else TitleCard(selected, cardModifier.then(if (!row.skeleton) Modifier.clickable { onCard(index) } else Modifier), width, height) {
|
||||||
if (title == null) Box(Modifier.fillMaxSize().background(C.surface)) else {
|
if (title == null) Box(Modifier.fillMaxSize().background(C.surface2)) else {
|
||||||
TitleArtwork(title.title, title.type, title.poster, width, height, Modifier.fillMaxSize(), backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = row.cardKind != HomeCardKind.Poster)
|
TitleArtwork(title.title, title.type, title.poster, width, height, Modifier.fillMaxSize(), backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = row.cardKind != HomeCardKind.Poster)
|
||||||
if (row.cardKind != HomeCardKind.Poster) TileOverlay(title)
|
if (row.cardKind != HomeCardKind.Poster) TileOverlay(title)
|
||||||
if (row.catalogue && title.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { Text("✓", color = C.onFocus, style = T.button) }
|
if (row.catalogue && title.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { Text("✓", color = C.onFocus, style = T.button) }
|
||||||
|
|
|
||||||
|
|
@ -120,8 +120,8 @@ fun LibraryScreen(
|
||||||
when (grid.status) {
|
when (grid.status) {
|
||||||
LibraryStatus.Loading -> items(3, key = { "skeleton-$it" }, contentType = { "grid" }) {
|
LibraryStatus.Loading -> items(3, key = { "skeleton-$it" }, contentType = { "grid" }) {
|
||||||
Box(Modifier.fillMaxWidth().height(rowHeight)) {
|
Box(Modifier.fillMaxWidth().height(rowHeight)) {
|
||||||
Row(Modifier.padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
|
Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
|
||||||
repeat(grid.columns) { Box(Modifier.size(K.posterW, K.posterH).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) }
|
repeat(grid.columns) { Box(Modifier.size(K.posterW, K.posterH).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -133,7 +133,7 @@ fun LibraryScreen(
|
||||||
"grid-${if (grid.catalogue) title.catalogueKey else title.id}"
|
"grid-${if (grid.catalogue) title.catalogueKey else title.id}"
|
||||||
}, contentType = { "grid" }) { row ->
|
}, contentType = { "grid" }) { row ->
|
||||||
Box(Modifier.fillMaxWidth().height(rowHeight)) {
|
Box(Modifier.fillMaxWidth().height(rowHeight)) {
|
||||||
Row(Modifier.padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
|
Row(Modifier.padding(start = L.safeSide).rowReveal("grid-$row"), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
|
||||||
for (column in 0 until grid.columns) {
|
for (column in 0 until grid.columns) {
|
||||||
val index = row * grid.columns + column
|
val index = row * grid.columns + column
|
||||||
val title = items.getOrNull(index) ?: break
|
val title = items.getOrNull(index) ?: break
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||||
|
|
||||||
|
/** Discover loads in its actual hero-and-lanes silhouette, not a library grid. */
|
||||||
|
@Composable
|
||||||
|
internal fun DiscoverLoading(modifier: Modifier = Modifier) {
|
||||||
|
Column(modifier.fillMaxSize().testTag("discover-loading")) {
|
||||||
|
Box(Modifier.fillMaxWidth().height(L.row_phero)) { TitleHeaderSkeleton() }
|
||||||
|
Spacer(Modifier.height(L.row_chips))
|
||||||
|
PosterSkeletonRow(WolfskinTokens.Discover.shelfHeadingBand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A person read reserves its portrait/header and the six-column filmography grid. */
|
||||||
|
@Composable
|
||||||
|
internal fun PersonLoading(modifier: Modifier = Modifier) {
|
||||||
|
Column(modifier.fillMaxSize().testTag("person-loading")) {
|
||||||
|
Box(Modifier.fillMaxWidth().height(L.row_phead)) {
|
||||||
|
Row(Modifier.offset(L.safeSide, D.personTop).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(D.personGap)) {
|
||||||
|
Box(Modifier.size(L.personPortrait).background(C.surface2, CircleShape))
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(D.skeletonTitleGap)) {
|
||||||
|
SkeletonBlock(D.skeletonTitleWidth, D.skeletonTitleHeight)
|
||||||
|
SkeletonBlock(D.skeletonFactsWidth, D.skeletonKickerHeight)
|
||||||
|
SkeletonBlock(D.skeletonOverviewWidth, D.skeletonOverviewHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PosterSkeletonRow(L.row_gh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All posters in a visible row share a single pulse layer. */
|
||||||
|
@Composable
|
||||||
|
private fun PosterSkeletonRow(heading: androidx.compose.ui.unit.Dp) {
|
||||||
|
Column {
|
||||||
|
Spacer(Modifier.height(heading))
|
||||||
|
Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
|
||||||
|
repeat(K.posterSlots) { SkeletonBlock(K.posterW, K.posterH) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SkeletonBlock(width: androidx.compose.ui.unit.Dp, height: androidx.compose.ui.unit.Dp) {
|
||||||
|
Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.MotionDurationScale
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
||||||
|
|
||||||
|
/** A host may force reduced motion independently of the platform animator scale. */
|
||||||
|
val LocalReducedMotion = compositionLocalOf { false }
|
||||||
|
|
||||||
|
/** Compose's window duration-scale state follows system settings without UI-thread settings reads. */
|
||||||
|
@Composable
|
||||||
|
fun reducedMotion(): Boolean = LocalReducedMotion.current ||
|
||||||
|
(rememberCoroutineScope().coroutineContext[MotionDurationScale]?.scaleFactor == 0f)
|
||||||
|
|
||||||
|
/** Short fades remain available when movement and continuous animation are disabled. */
|
||||||
|
fun motionDuration(duration: Int, reduced: Boolean): Int = if (reduced) minOf(duration, M.reducedFade) else duration
|
||||||
|
|
||||||
|
/** One alpha layer owns all shapes in a shelf; individual placeholders never animate. */
|
||||||
|
@Composable
|
||||||
|
fun Modifier.skeletonPulse(loading: Boolean = true): Modifier {
|
||||||
|
if (!loading || reducedMotion()) return this
|
||||||
|
val clock = rememberInfiniteTransition(label = "skeleton-shelf")
|
||||||
|
val alpha = clock.animateFloat(M.skeletonLowAlpha, 1f,
|
||||||
|
infiniteRepeatable(tween(M.skeletonCycle / 2, easing = M.breatheEase), RepeatMode.Reverse), label = "breathe")
|
||||||
|
return graphicsLayer { this.alpha = alpha.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Content enters on its row layer, never one animation per card or a full-screen transition. */
|
||||||
|
@Composable
|
||||||
|
fun Modifier.rowReveal(identity: Any, enabled: Boolean = true): Modifier {
|
||||||
|
val reduced = reducedMotion()
|
||||||
|
val progress = remember(identity, enabled) { Animatable(if (enabled) 0f else 1f) }
|
||||||
|
LaunchedEffect(progress, reduced) { if (enabled) progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) }
|
||||||
|
val rise = with(LocalDensity.current) { M.rowRise.toPx() }
|
||||||
|
return if (!enabled) this else graphicsLayer {
|
||||||
|
alpha = progress.value
|
||||||
|
translationY = if (reduced) 0f else rise * (1f - progress.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Late facts occupy an existing slot and fade without a translation. */
|
||||||
|
@Composable
|
||||||
|
internal fun Modifier.lateLineFade(identity: Any): Modifier {
|
||||||
|
val reduced = reducedMotion()
|
||||||
|
val alpha = remember(identity) { Animatable(0f) }
|
||||||
|
LaunchedEffect(alpha, reduced) { alpha.animateTo(1f, tween(motionDuration(M.lateLineFade, reduced), easing = M.ease)) }
|
||||||
|
return graphicsLayer { this.alpha = alpha.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Title copy enters as one bounded block, leaving measured layout unchanged. */
|
||||||
|
@Composable
|
||||||
|
internal fun Modifier.copyEntry(identity: Any): Modifier {
|
||||||
|
val reduced = reducedMotion()
|
||||||
|
val progress = remember(identity) { Animatable(0f) }
|
||||||
|
val rise = with(LocalDensity.current) { M.copyEntryRise.toPx() }
|
||||||
|
LaunchedEffect(progress, reduced) { progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) }
|
||||||
|
return graphicsLayer { alpha = progress.value; translationY = if (reduced) 0f else rise * (1f - progress.value) }
|
||||||
|
}
|
||||||
|
|
@ -58,18 +58,20 @@ fun navBoxes(tabs: List<String>): List<NavBox> {
|
||||||
@Composable
|
@Composable
|
||||||
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
|
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
|
||||||
val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
|
val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
|
||||||
val labelFade = remember { tween<Color>(S.navLabelFade, easing = CubicBezierEasing(0f, 0f, .58f, 1f)) }
|
val reduced = reducedMotion()
|
||||||
|
val labelFade = tween<Color>(motionDuration(S.navLabelFade, reduced), easing = CubicBezierEasing(0f, 0f, .58f, 1f))
|
||||||
val index = if (state.navFocused) state.navIndex else state.currentTab
|
val index = if (state.navFocused) state.navIndex else state.currentTab
|
||||||
val box = boxes[index]
|
val box = boxes[index]
|
||||||
val avatarFocused = state.navFocused && index == state.tabs.size
|
val avatarFocused = state.navFocused && index == state.tabs.size
|
||||||
val pad = if (avatarFocused) L.navBlobAvatarPad else S.zero
|
val pad = if (avatarFocused) L.navBlobAvatarPad else S.zero
|
||||||
val damping = WolfskinTokens.Motion.navBlobDampingRatio
|
val damping = WolfskinTokens.Motion.navBlobDampingRatio
|
||||||
val stiffness = WolfskinTokens.Motion.navBlobStiffness
|
val stiffness = WolfskinTokens.Motion.navBlobStiffness
|
||||||
val x by animateFloatAsState((box.left - pad).value, spring(damping, stiffness), label = "blob-x")
|
val geometry = if (reduced) tween<Float>(0) else spring<Float>(damping, stiffness)
|
||||||
val y by animateFloatAsState((box.top - pad).value, spring(damping, stiffness), label = "blob-y")
|
val x by animateFloatAsState((box.left - pad).value, geometry, label = "blob-x")
|
||||||
val width by animateFloatAsState((box.width + pad * 2).value, spring(damping, stiffness), label = "blob-width")
|
val y by animateFloatAsState((box.top - pad).value, geometry, label = "blob-y")
|
||||||
val height by animateFloatAsState((box.height + pad * 2).value, spring(damping, stiffness), label = "blob-height")
|
val width by animateFloatAsState((box.width + pad * 2).value, geometry, label = "blob-width")
|
||||||
val color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, spring(damping, stiffness), label = "blob-color")
|
val height by animateFloatAsState((box.height + pad * 2).value, geometry, label = "blob-height")
|
||||||
|
val color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, if (reduced) tween(motionDuration(WolfskinTokens.Motion.base, true)) else spring(damping, stiffness), label = "blob-color")
|
||||||
val density = LocalDensity.current.density
|
val density = LocalDensity.current.density
|
||||||
val band = remember {
|
val band = remember {
|
||||||
Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f)))
|
Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f)))
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import coil3.decode.DataSource
|
||||||
|
import coil3.request.ImageResult
|
||||||
|
import coil3.request.SuccessResult
|
||||||
|
import coil3.transition.CrossfadeTransition
|
||||||
|
import coil3.transition.Transition
|
||||||
|
import coil3.transition.TransitionTarget
|
||||||
|
|
||||||
|
/** Both Coil loaders share the same source-aware reveal; cache and local reads never fade. */
|
||||||
|
object NetworkArtTransition : Transition.Factory {
|
||||||
|
@Volatile var reduced = false
|
||||||
|
|
||||||
|
/** The result source, not its URL, decides whether art needs a reveal. */
|
||||||
|
fun duration(source: DataSource, reduced: Boolean): Int = if (source == DataSource.NETWORK)
|
||||||
|
motionDuration(WolfskinTokens.Motion.artReveal, reduced) else 0
|
||||||
|
|
||||||
|
override fun create(target: TransitionTarget, result: ImageResult): Transition {
|
||||||
|
val duration = if (result is SuccessResult) duration(result.dataSource, reduced) else 0
|
||||||
|
return if (duration == 0) Transition.Factory.NONE.create(target, result)
|
||||||
|
else CrossfadeTransition(target, result, duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.animation.core.CubicBezierEasing
|
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.gestures.animateScrollBy
|
import androidx.compose.foundation.gestures.animateScrollBy
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
|
@ -63,7 +62,7 @@ fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount:
|
||||||
val target = if (y >= L.windowAhead) y - L.pinnedLine else S.zero
|
val target = if (y >= L.windowAhead) y - L.pinnedLine else S.zero
|
||||||
val current = if (list.firstVisibleItemIndex == 0) S.zero else L.row_lhead + L.row_srow * (list.firstVisibleItemIndex - 1)
|
val current = if (list.firstVisibleItemIndex == 0) S.zero else L.row_lhead + L.row_srow * (list.firstVisibleItemIndex - 1)
|
||||||
val distance = with(density) { (target - current).toPx() } - list.firstVisibleItemScrollOffset
|
val distance = with(density) { (target - current).toPx() } - list.firstVisibleItemScrollOffset
|
||||||
list.animateScrollBy(distance, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)))
|
list.animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut))
|
||||||
}
|
}
|
||||||
LazyColumn(modifier.fillMaxSize(), state = list,
|
LazyColumn(modifier.fillMaxSize(), state = list,
|
||||||
contentPadding = PaddingValues(bottom = S.stageHeight - L.pinnedLine - L.row_srow)) {
|
contentPadding = PaddingValues(bottom = S.stageHeight - L.pinnedLine - L.row_srow)) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.animation.core.CubicBezierEasing
|
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.gestures.animateScrollBy
|
import androidx.compose.foundation.gestures.animateScrollBy
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
|
@ -16,7 +15,7 @@ internal fun shelfFirst(column: Int, previous: Int, slots: Int, count: Int): Int
|
||||||
/** Reconcile the actual track on every focus transition, including interrupted or restored offsets. */
|
/** Reconcile the actual track on every focus transition, including interrupted or restored offsets. */
|
||||||
internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) {
|
internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) {
|
||||||
val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset
|
val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset
|
||||||
if (distance != 0f) animateScrollBy(distance, tween(WolfskinTokens.Motion.base,
|
if (distance != 0f) animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.base,
|
||||||
easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
|
easing = WolfskinTokens.Motion.ease))
|
||||||
if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first)
|
if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ fun LibraryRatingPlate(ratings: List<LibraryRating>, modifier: Modifier = Modifi
|
||||||
if (ratings.isEmpty()) return
|
if (ratings.isEmpty()) return
|
||||||
val measurer = rememberTextMeasurer()
|
val measurer = rememberTextMeasurer()
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "rating-plate")
|
val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())), label = "rating-plate")
|
||||||
BoxWithConstraints(modifier.fillMaxWidth().graphicsLayer { this.alpha = alpha }
|
BoxWithConstraints(modifier.fillMaxWidth().graphicsLayer { this.alpha = alpha }
|
||||||
.background(Brush.verticalGradient(0f to C.bg.copy(alpha = P.plateOpacity), P.plateMidStop to C.bg.copy(alpha = P.plateMidOpacity), 1f to C.bg.copy(alpha = 0f)))
|
.background(Brush.verticalGradient(0f to C.bg.copy(alpha = P.plateOpacity), P.plateMidStop to C.bg.copy(alpha = P.plateMidOpacity), 1f to C.bg.copy(alpha = 0f)))
|
||||||
.testTag("rating-plate").padding(horizontal = P.plateSide)) {
|
.testTag("rating-plate").padding(horizontal = P.plateSide)) {
|
||||||
|
|
|
||||||
|
|
@ -43,18 +43,23 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||||
/** Local decoded fixture art keeps screenshot tests deterministic and completely offline. */
|
/** Local decoded fixture art keeps screenshot tests deterministic and completely offline. */
|
||||||
val LocalTitleBitmaps = compositionLocalOf<Map<String, ImageBitmap>> { emptyMap() }
|
val LocalTitleBitmaps = compositionLocalOf<Map<String, ImageBitmap>> { emptyMap() }
|
||||||
|
|
||||||
/** Decode at the slot size, with no crossfade or oversized poster allocations. */
|
/** Decode at the slot size; network results reveal through Coil's painter without a new layer. */
|
||||||
@Composable
|
@Composable
|
||||||
fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Modifier, poster: Boolean = false, colorFilter: ColorFilter? = null, onError: () -> Unit = {}) {
|
fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Modifier, poster: Boolean = false, colorFilter: ColorFilter? = null, onError: () -> Unit = {}) {
|
||||||
val bitmap = LocalTitleBitmaps.current[path]
|
val bitmap = LocalTitleBitmaps.current[path]
|
||||||
if (bitmap != null) Image(bitmap, null, modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter)
|
val heroReady = LocalHeroArtReady.current
|
||||||
|
if (bitmap != null) {
|
||||||
|
androidx.compose.runtime.SideEffect { heroReady?.invoke() }
|
||||||
|
Image(bitmap, null, modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter)
|
||||||
|
}
|
||||||
else if (path != null) {
|
else if (path != null) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val request = remember(context, path, width, height, poster) {
|
val request = remember(context, path, width, height, poster) {
|
||||||
WolfskinImages.request(context, path, width, height, poster)
|
WolfskinImages.request(context, path, width, height, poster)
|
||||||
}
|
}
|
||||||
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context),
|
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context),
|
||||||
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter, onError = { onError() })
|
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter,
|
||||||
|
onSuccess = { heroReady?.invoke() }, onError = { onError() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
|
@ -42,7 +41,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
||||||
else -> state.data.playTarget?.takeIf { it.kind == "episode" }
|
else -> state.data.playTarget?.takeIf { it.kind == "episode" }
|
||||||
}
|
}
|
||||||
val type = if (item.kind == "movie") "Movie" else "Series"
|
val type = if (item.kind == "movie") "Movie" else "Series"
|
||||||
CssColumn(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) {
|
CssColumn(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth).copyEntry(item.id)) {
|
||||||
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Glyph(if (item.held) "check" else "sparkle", C.accentText, Modifier.size(D.lineIcon))
|
Glyph(if (item.held) "check" else "sparkle", C.accentText, Modifier.size(D.lineIcon))
|
||||||
Text(buildString {
|
Text(buildString {
|
||||||
|
|
@ -60,14 +59,16 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
||||||
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings)
|
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings)
|
||||||
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
||||||
}
|
}
|
||||||
|
Box(Modifier.fillMaxWidth().height(if (item.kind == "tv" || state.data.requestedStage != null) WolfskinTokens.Motion.readinessSlot else WolfskinTokens.Shell.zero).testTag("title-readiness-slot")) {
|
||||||
state.data.requestedStage?.let { stage ->
|
state.data.requestedStage?.let { stage ->
|
||||||
val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted }
|
val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted }
|
||||||
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap).lateLineFade(stage), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Glyph(titleStageGlyph(stage), color, Modifier.size(D.lineIcon))
|
Glyph(titleStageGlyph(stage), color, Modifier.size(D.lineIcon))
|
||||||
Text(stage, color = color, style = TitleTextStyles.label)
|
Text(stage, color = color, style = TitleTextStyles.label)
|
||||||
Text("for ${state.data.requestFor ?: "you"}" + state.data.requestActor?.let { " · by $it" }.orEmpty(), color = C.muted, style = TitleTextStyles.dLine)
|
Text("for ${state.data.requestFor ?: "you"}" + state.data.requestActor?.let { " · by $it" }.orEmpty(), color = C.muted, style = TitleTextStyles.dLine)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Text((episode ?: item).overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview"))
|
Text((episode ?: item).overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview"))
|
||||||
if (!item.held && state.data.connection != TitleConnection.SignedIn) {
|
if (!item.held && state.data.connection != TitleConnection.SignedIn) {
|
||||||
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
|
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
|
||||||
|
|
@ -105,18 +106,18 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
||||||
/** A no-copy load has no focusable content and never draws a spinner. */
|
/** A no-copy load has no focusable content and never draws a spinner. */
|
||||||
@Composable
|
@Composable
|
||||||
fun TitleHeaderSkeleton(modifier: Modifier = Modifier) {
|
fun TitleHeaderSkeleton(modifier: Modifier = Modifier) {
|
||||||
Column(modifier.offset(L.safeSide, L.detailCopyTop)) {
|
Column(modifier.offset(L.safeSide, L.detailCopyTop).skeletonPulse()) {
|
||||||
Box(Modifier.size(D.skeletonKickerWidth, D.skeletonKickerHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
|
Box(Modifier.size(D.skeletonKickerWidth, D.skeletonKickerHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
|
||||||
Spacer(Modifier.height(D.skeletonTitleGap))
|
Spacer(Modifier.height(D.skeletonTitleGap))
|
||||||
Box(Modifier.size(D.skeletonTitleWidth, D.skeletonTitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
Box(Modifier.size(D.skeletonTitleWidth, D.skeletonTitleHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||||
Spacer(Modifier.height(D.skeletonFactsGap))
|
Spacer(Modifier.height(D.skeletonFactsGap))
|
||||||
Box(Modifier.size(D.skeletonFactsWidth, D.skeletonKickerHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
|
Box(Modifier.size(D.skeletonFactsWidth, D.skeletonKickerHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
|
||||||
Spacer(Modifier.height(D.skeletonOverviewGap))
|
Spacer(Modifier.height(D.skeletonOverviewGap))
|
||||||
Box(Modifier.size(D.skeletonOverviewWidth, D.skeletonOverviewHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
Box(Modifier.size(D.skeletonOverviewWidth, D.skeletonOverviewHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||||
Spacer(Modifier.height(D.overviewGap))
|
Spacer(Modifier.height(D.overviewGap))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||||
Box(Modifier.size(D.skeletonPlayWidth, L.buttonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button)))
|
Box(Modifier.size(D.skeletonPlayWidth, L.buttonHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.button)))
|
||||||
repeat(2) { Box(Modifier.size(L.iconButton).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button))) }
|
repeat(2) { Box(Modifier.size(L.iconButton).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.button))) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.foundation.gestures.animateScrollBy
|
import androidx.compose.foundation.gestures.animateScrollBy
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.core.CubicBezierEasing
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
|
@ -147,8 +146,9 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
||||||
}.focusable()) {
|
}.focusable()) {
|
||||||
// This remains visible behind the scrolled rows; it is not an off-screen header image.
|
// This remains visible behind the scrolled rows; it is not an off-screen header image.
|
||||||
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
|
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
|
||||||
Box(Modifier.fillMaxSize().graphicsLayer { alpha = dim }.background(C.bg))
|
Box(Modifier.fillMaxSize().drawBehind { drawRect(C.bg.copy(alpha = dim)) })
|
||||||
if (state.data.status == TitleReadStatus.Unavailable) TitleReadError(state)
|
if (state.data.status == TitleReadStatus.Unavailable) TitleReadError(state)
|
||||||
|
else if (state.data.status == TitleReadStatus.Loading && state.data.item.kind == "person") PersonLoading()
|
||||||
else LazyColumn(state = list, modifier = Modifier.fillMaxSize()) {
|
else LazyColumn(state = list, modifier = Modifier.fillMaxSize()) {
|
||||||
displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") {
|
displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") {
|
||||||
val active = state.row == id && !shell.navFocused && state.sheet == null
|
val active = state.row == id && !shell.navFocused && state.sheet == null
|
||||||
|
|
@ -185,7 +185,9 @@ private fun TitleSeasons(state: TitleState, active: Boolean) {
|
||||||
val list = androidx.compose.foundation.lazy.rememberLazyListState()
|
val list = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||||
val visibleChip = if (active) state.column else state.data.seasonNumbers.indexOf(state.season)
|
val visibleChip = if (active) state.column else state.data.seasonNumbers.indexOf(state.season)
|
||||||
LaunchedEffect(visibleChip, state.data.seasonNumbers) {
|
LaunchedEffect(visibleChip, state.data.seasonNumbers) {
|
||||||
if (visibleChip >= 0) list.animateScrollToItem(visibleChip)
|
if (visibleChip >= 0) {
|
||||||
|
if (NetworkArtTransition.reduced) list.scrollToItem(visibleChip) else list.animateScrollToItem(visibleChip)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), state = list, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
|
androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), state = list, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
|
||||||
items(state.data.seasonNumbers.size, key = { "season-${state.data.seasonNumbers[it]}" }, contentType = { "chip" }) { index ->
|
items(state.data.seasonNumbers.size, key = { "season-${state.data.seasonNumbers[it]}" }, contentType = { "chip" }) { index ->
|
||||||
|
|
@ -220,4 +222,4 @@ private fun TitleReadError(state: TitleState) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal val titleFrameScrollSpec = tween<Float>(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f))
|
internal val titleFrameScrollSpec get() = tween<Float>(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut)
|
||||||
|
|
|
||||||
|
|
@ -54,11 +54,11 @@ fun TitleCast(state: TitleState, active: Boolean) {
|
||||||
}
|
}
|
||||||
Box(Modifier.height(D.castHeight)) {
|
Box(Modifier.height(D.castHeight)) {
|
||||||
Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
||||||
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2),
|
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2).skeletonPulse(state.data.castStatus == TitleReadStatus.Loading),
|
||||||
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
|
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
|
||||||
if (state.data.castStatus == TitleReadStatus.Loading) {
|
if (state.data.castStatus == TitleReadStatus.Loading) {
|
||||||
items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) {
|
items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) {
|
||||||
Box(Modifier.size(L.castCircle).background(C.surface, CircleShape))
|
Box(Modifier.size(L.castCircle).background(C.surface2, CircleShape))
|
||||||
}
|
}
|
||||||
} else itemsIndexed(state.data.cast, key = { _, person -> person.id }, contentType = { _, _ -> "person" }) { index, person ->
|
} else itemsIndexed(state.data.cast, key = { _, person -> person.id }, contentType = { _, _ -> "person" }) { index, person ->
|
||||||
val focused = active && state.column == index
|
val focused = active && state.column == index
|
||||||
|
|
@ -89,14 +89,14 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
|
||||||
LaunchedEffect(state.column, active, titles.size) {
|
LaunchedEffect(state.column, active, titles.size) {
|
||||||
if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size)
|
if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size)
|
||||||
}
|
}
|
||||||
Box(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) {
|
Box(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand).testTag("title-shelf:$id")) {
|
||||||
Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
||||||
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter)
|
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter)
|
||||||
.height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2),
|
.height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2).skeletonPulse(episodes && state.data.episodesStatus == TitleReadStatus.Loading),
|
||||||
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
||||||
if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) {
|
if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) {
|
||||||
items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) {
|
items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) {
|
||||||
Box(Modifier.size(width, height).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||||
}
|
}
|
||||||
} else itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item ->
|
} else itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item ->
|
||||||
TitlePoster(item, active && state.column == index, Modifier.width(width)
|
TitlePoster(item, active && state.column == index, Modifier.width(width)
|
||||||
|
|
@ -134,7 +134,7 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
|
||||||
Box(Modifier.fillMaxWidth(progress).fillMaxHeight().background(C.accent))
|
Box(Modifier.fillMaxWidth(progress).fillMaxHeight().background(C.accent))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(WolfskinTokens.Motion.base)), exit = fadeOut(tween(WolfskinTokens.Motion.base))) {
|
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()))), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())))) {
|
||||||
LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth())
|
LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ fun TitleArtwork(title: String, type: String, poster: String?, width: Dp, height
|
||||||
val source = if (landscape) backdrop ?: poster else poster
|
val source = if (landscape) backdrop ?: poster else poster
|
||||||
val failed = remember(source) { androidx.compose.runtime.mutableStateOf(false) }
|
val failed = remember(source) { androidx.compose.runtime.mutableStateOf(false) }
|
||||||
val missing = source == null || failed.value
|
val missing = source == null || failed.value
|
||||||
|
val heroReady = LocalHeroArtReady.current
|
||||||
|
if (missing) androidx.compose.runtime.SideEffect { heroReady?.invoke() }
|
||||||
Box(modifier.background(C.surface2)) {
|
Box(modifier.background(C.surface2)) {
|
||||||
if (!missing) {
|
if (!missing) {
|
||||||
if (landscape && (posterOnly || backdrop == null) && poster != null) {
|
if (landscape && (posterOnly || backdrop == null) && poster != null) {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import coil3.ImageLoader
|
||||||
import coil3.disk.DiskCache
|
import coil3.disk.DiskCache
|
||||||
import coil3.intercept.Interceptor
|
import coil3.intercept.Interceptor
|
||||||
import coil3.disk.directory
|
import coil3.disk.directory
|
||||||
import coil3.request.crossfade
|
import coil3.request.transitionFactory
|
||||||
import coil3.memory.MemoryCache
|
import coil3.memory.MemoryCache
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import coil3.request.allowRgb565
|
import coil3.request.allowRgb565
|
||||||
|
|
@ -61,7 +61,7 @@ object WolfskinImages {
|
||||||
networkConfiguration?.invoke(this)
|
networkConfiguration?.invoke(this)
|
||||||
add(Interceptor { chain -> readPermits.withPermit { chain.proceed() } })
|
add(Interceptor { chain -> readPermits.withPermit { chain.proceed() } })
|
||||||
}
|
}
|
||||||
.fetcherCoroutineContext(reads).decoderCoroutineContext(reads).crossfade(false).build()
|
.fetcherCoroutineContext(reads).decoderCoroutineContext(reads).transitionFactory(NetworkArtTransition).build()
|
||||||
}
|
}
|
||||||
return loader!!
|
return loader!!
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ object WolfskinColors {
|
||||||
/** Upstream content inherits tokens without a fork-specific theme enum. */
|
/** Upstream content inherits tokens without a fork-specific theme enum. */
|
||||||
@Composable
|
@Composable
|
||||||
fun WolfskinTheme(content: @Composable () -> Unit) {
|
fun WolfskinTheme(content: @Composable () -> Unit) {
|
||||||
|
val reduced = reducedMotion()
|
||||||
|
androidx.compose.runtime.SideEffect { NetworkArtTransition.reduced = reduced }
|
||||||
androidx.compose.material3.MaterialTheme(colorScheme = WolfskinColors.darkSchemeMaterial) {
|
androidx.compose.material3.MaterialTheme(colorScheme = WolfskinColors.darkSchemeMaterial) {
|
||||||
MaterialTheme(colorScheme = WolfskinColors.darkScheme, content = content)
|
MaterialTheme(colorScheme = WolfskinColors.darkScheme, content = content)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Generated by generate_tokens.py; edit the JSON sources instead.
|
// Generated by generate_tokens.py; edit the JSON sources instead.
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.CubicBezierEasing
|
||||||
import androidx.compose.ui.graphics.Color as ComposeColor
|
import androidx.compose.ui.graphics.Color as ComposeColor
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
@ -208,14 +209,35 @@ object WolfskinTokens {
|
||||||
const val fillInverse = "background color.fg, text color.onFocus"
|
const val fillInverse = "background color.fg, text color.onFocus"
|
||||||
}
|
}
|
||||||
object Motion {
|
object Motion {
|
||||||
const val fast = 140
|
const val skeletonCycle = 1400
|
||||||
|
const val skeletonLowAlpha = 0.55f
|
||||||
|
const val focusMove = 180
|
||||||
|
const val captionDelay = 60
|
||||||
|
const val captionOut = 100
|
||||||
|
const val artReveal = 160
|
||||||
|
const val heroFade = 320
|
||||||
|
const val heroCopyOut = 120
|
||||||
|
const val heroCopyDelay = 140
|
||||||
|
const val heroCopyIn = 200
|
||||||
|
const val rowEnter = 180
|
||||||
|
const val lateLineFade = 200
|
||||||
|
val copyEntryRise = 8.0.dp
|
||||||
|
const val reducedFade = 80
|
||||||
|
val rowRise = 12.0.dp
|
||||||
|
val captionRise = 5.0.dp
|
||||||
|
val copyRise = 7.0.dp
|
||||||
|
val readinessSlot = 28.0.dp
|
||||||
|
val ease = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
|
||||||
|
val easeOut = CubicBezierEasing(0.33f, 1.0f, 0.68f, 1.0f)
|
||||||
|
val easeInOut = CubicBezierEasing(0.65f, 0.0f, 0.35f, 1.0f)
|
||||||
|
val breatheEase = CubicBezierEasing(0.42f, 0.0f, 0.58f, 1.0f)
|
||||||
const val base = 160
|
const val base = 160
|
||||||
const val scroll = 180
|
const val scroll = 180
|
||||||
const val navBlobDampingRatio = 1.0f
|
const val navBlobDampingRatio = 1.0f
|
||||||
const val navBlobStiffness = 650.0f
|
const val navBlobStiffness = 650.0f
|
||||||
const val ratingDwell = 350
|
const val ratingDwell = 350
|
||||||
const val toast = 3200
|
const val toast = 3200
|
||||||
const val reducedMotion = "all transitions instant"
|
const val reducedMotion = "no pulse or movement; fades at most 80ms"
|
||||||
}
|
}
|
||||||
object Image {
|
object Image {
|
||||||
const val posterRequestWidth = 256
|
const val posterRequestWidth = 256
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
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
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** A slow or interrupted artwork read cannot replace the last painted hero with a blank. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class HeroMotionTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun heroWaitsForArtBlendsOnlyItsBoundedRegionAndKeepsTheLatestSelection() {
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
val selected = mutableStateOf("red")
|
||||||
|
val ready = mutableStateOf(true)
|
||||||
|
val reduced = mutableStateOf(false)
|
||||||
|
val colors = mapOf("red" to Color.Red, "blue" to Color.Blue, "green" to Color.Green)
|
||||||
|
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme {
|
||||||
|
Box(Modifier.requiredSize(960.dp, 540.dp)) {
|
||||||
|
HeroArtCrossfade(selected.value, selected.value, 200.dp) { title ->
|
||||||
|
val reportReady = LocalHeroArtReady.current
|
||||||
|
if (ready.value) SideEffect { reportReady?.invoke() }
|
||||||
|
Box(Modifier.fillMaxSize().background(colors.getValue(title)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} } }
|
||||||
|
val directory = kotlin.io.path.createTempDirectory("wolfskin-hero-").toFile()
|
||||||
|
try {
|
||||||
|
rule.mainClock.advanceTimeBy(32)
|
||||||
|
update { selected.value = "blue"; ready.value = false }
|
||||||
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
|
assertEquals(Color.Red.toArgb(), pixel(directory, 100))
|
||||||
|
val probeFile = File(directory, "hero.png")
|
||||||
|
val probe = BitmapFactory.decodeFile(probeFile.absolutePath)
|
||||||
|
try {
|
||||||
|
for (y in listOf(380, 400, 420, 600, 800)) {
|
||||||
|
assertEquals("The outgoing hero must stop at its 400px boundary: y=$y",
|
||||||
|
(if (y < 400) Color.Red else Color.Blue).toArgb(), probe.getPixel(100, y))
|
||||||
|
}
|
||||||
|
assertEquals(400f, rule.onNodeWithTag("hero-outgoing").fetchSemanticsNode().boundsInRoot.height, .01f)
|
||||||
|
} finally { probe.recycle() }
|
||||||
|
update { ready.value = true }
|
||||||
|
rule.mainClock.advanceTimeBy(176)
|
||||||
|
val mixed = pixel(directory, 100)
|
||||||
|
assertNotEquals(Color.Red.toArgb(), mixed)
|
||||||
|
assertNotEquals(Color.Blue.toArgb(), mixed)
|
||||||
|
rule.mainClock.advanceTimeBy(400)
|
||||||
|
assertEquals(Color.Blue.toArgb(), pixel(directory, 100))
|
||||||
|
update { selected.value = "green"; ready.value = false }
|
||||||
|
rule.mainClock.advanceTimeBy(32)
|
||||||
|
update { selected.value = "red"; ready.value = true }
|
||||||
|
rule.mainClock.advanceTimeBy(400)
|
||||||
|
assertEquals(Color.Red.toArgb(), pixel(directory, 100))
|
||||||
|
update { reduced.value = true; selected.value = "green" }
|
||||||
|
rule.mainClock.advanceTimeBy(128)
|
||||||
|
assertEquals(Color.Green.toArgb(), pixel(directory, 100))
|
||||||
|
} finally { directory.deleteRecursively() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun update(block: () -> Unit) {
|
||||||
|
rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pixel(directory: File, y: Int): Int {
|
||||||
|
rule.waitForIdle()
|
||||||
|
val file = File(directory, "hero.png")
|
||||||
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
|
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||||
|
return try { bitmap.getPixel(100, y) } finally { bitmap.recycle() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.os.StrictMode
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.ui.MotionDurationScale
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
import androidx.compose.ui.test.pressKey
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
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
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Delayed metadata and loading animations cannot move content or capture the remote's focus. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class MotionSurfaceTest {
|
||||||
|
private val animatorScale = object : MotionDurationScale { override var scaleFactor by mutableFloatStateOf(1f) }
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(effectContext = animatorScale)
|
||||||
|
private val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||||
|
|
||||||
|
@Test fun lateSeriesReadinessAndRatingsLeaveActionsOverviewAndEpisodesAtTheSameCoordinates() {
|
||||||
|
val shell = shell()
|
||||||
|
val series = TitleItem("series", "A series", "tv", seasons = 2, held = true, overview = "Series overview")
|
||||||
|
val episode = TitleItem("episode", "Pilot", "episode", season = 1, episode = 1, overview = "Episode overview")
|
||||||
|
val state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {}
|
||||||
|
rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } }
|
||||||
|
rule.waitForIdle()
|
||||||
|
val before = bounds()
|
||||||
|
for (stage in listOf("Ready", "Downloading", null)) {
|
||||||
|
rule.runOnIdle { state.update(state.data.copy(requestedStage = stage, requestActor = "Mindel", item = series.copy(ratings = listOf(LibraryRating("rt", 88.0))))) }
|
||||||
|
rule.mainClock.advanceTimeBy(3400)
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertEquals("Late metadata cannot change reserved header geometry", before, bounds())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun titleSkeletonIgnoresDownAndBackPopsWithoutOpeningThePicker() {
|
||||||
|
val shell = shell()
|
||||||
|
val actions = mutableListOf<TitleAction>()
|
||||||
|
val state = TitleState(TitlePageData(TitleItem("loading", "", "movie"), status = TitleReadStatus.Loading), viewer, actions::add)
|
||||||
|
rule.setContent { WolfskinTheme { TitleSurface(state, shell) } }
|
||||||
|
rule.mainClock.advanceTimeBy(2100)
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
|
||||||
|
rule.runOnIdle { assertTrue(shell.navFocused); assertFalse(shell.pickerVisible) }
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.Back) }
|
||||||
|
rule.runOnIdle { assertEquals(listOf(TitleAction.Pop), actions); assertFalse(shell.pickerVisible) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun pendingCastAndEpisodesAreSkippedUntilRealCardsExist() {
|
||||||
|
val shell = shell()
|
||||||
|
val series = TitleItem("series", "Series", "tv", held = true, seasons = 1)
|
||||||
|
val related = TitleItem("related", "Related", "movie")
|
||||||
|
val state = TitleState(TitlePageData(series, castStatus = TitleReadStatus.Loading, episodesStatus = TitleReadStatus.Loading, related = listOf(related)), viewer) {}
|
||||||
|
assertEquals(listOf("header", "seasons", "related"), state.rows)
|
||||||
|
state.focus("seasons", 0)
|
||||||
|
state.key(ShellKey.Down, true, 0, shell)
|
||||||
|
assertEquals("related", state.row)
|
||||||
|
state.update(state.data.copy(castStatus = TitleReadStatus.Ready, cast = listOf(TitlePerson("person", "Actor")), episodesStatus = TitleReadStatus.Ready, episodes = listOf(TitleItem("episode", "Pilot", "episode"))))
|
||||||
|
assertEquals("related", state.row)
|
||||||
|
state.key(ShellKey.Up, true, 0, shell)
|
||||||
|
assertEquals("cast", state.row)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun reducedMotionFreezesPulseWhileNormalMotionBreathesWithoutRelayout() {
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
val reduced = mutableStateOf(true)
|
||||||
|
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme {
|
||||||
|
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
|
||||||
|
Box(Modifier.size(200.dp, 140.dp).skeletonPulse().background(WolfskinTokens.Color.surface2).testTag("shape"))
|
||||||
|
}
|
||||||
|
} } }
|
||||||
|
rule.mainClock.advanceTimeBy(32)
|
||||||
|
val directory = kotlin.io.path.createTempDirectory("wolfskin-motion-").toFile()
|
||||||
|
try {
|
||||||
|
val first = pixel(File(directory, "first.png"))
|
||||||
|
val bounds = rule.onNodeWithTag("shape").fetchSemanticsNode().boundsInRoot
|
||||||
|
rule.mainClock.advanceTimeBy(700)
|
||||||
|
assertEquals(first, pixel(File(directory, "reduced.png")))
|
||||||
|
rule.runOnIdle { reduced.value = false; animatorScale.scaleFactor = 0f; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||||
|
rule.mainClock.advanceTimeBy(32)
|
||||||
|
val systemReduced = pixel(File(directory, "system-reduced.png"))
|
||||||
|
rule.mainClock.advanceTimeBy(700)
|
||||||
|
assertEquals(systemReduced, pixel(File(directory, "system-later.png")))
|
||||||
|
rule.runOnIdle { animatorScale.scaleFactor = 1f; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||||
|
rule.mainClock.advanceTimeBy(32)
|
||||||
|
val low = pixel(File(directory, "low.png"))
|
||||||
|
rule.mainClock.advanceTimeBy(650)
|
||||||
|
assertNotEquals(low, pixel(File(directory, "high.png")))
|
||||||
|
assertEquals(bounds, rule.onNodeWithTag("shape").fetchSemanticsNode().boundsInRoot)
|
||||||
|
} finally { directory.deleteRecursively() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun animatingSkeletonDoesNotPerformMainThreadDiskOrNetworkWork() {
|
||||||
|
val loading = mutableStateOf(false)
|
||||||
|
rule.setContent { WolfskinTheme { Box(Modifier.size(200.dp, 140.dp).skeletonPulse(loading.value).background(WolfskinTokens.Color.surface2)) } }
|
||||||
|
rule.waitForIdle()
|
||||||
|
var previous: StrictMode.ThreadPolicy? = null
|
||||||
|
rule.runOnIdle {
|
||||||
|
previous = StrictMode.getThreadPolicy()
|
||||||
|
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().detectDiskReads().detectDiskWrites().detectNetwork().penaltyDeath().build())
|
||||||
|
loading.value = true
|
||||||
|
}
|
||||||
|
try { rule.mainClock.advanceTimeBy(1500); rule.waitForIdle() }
|
||||||
|
finally { rule.runOnIdle { StrictMode.setThreadPolicy(requireNotNull(previous)) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shell() = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/series") }
|
||||||
|
private fun bounds() = listOf("title-overview", "title-actions", "title-shelf:episodes").map { rule.onNodeWithTag(it).fetchSemanticsNode().boundsInRoot }
|
||||||
|
private fun pixel(file: File): Int {
|
||||||
|
rule.waitForIdle()
|
||||||
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
|
return BitmapFactory.decodeFile(file.absolutePath).let { bitmap -> try { bitmap.getPixel(100, 100) } finally { bitmap.recycle() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.view.View
|
||||||
|
import coil3.asImage
|
||||||
|
import coil3.decode.DataSource
|
||||||
|
import coil3.request.ErrorResult
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.request.SuccessResult
|
||||||
|
import coil3.transition.CrossfadeTransition
|
||||||
|
import coil3.transition.TransitionTarget
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.RuntimeEnvironment
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** Provenance, errors and reduced motion determine the consumer-visible artwork transition. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class)
|
||||||
|
class NetworkArtTransitionTest {
|
||||||
|
private val target = object : TransitionTarget {
|
||||||
|
override val view: View get() = error("Compose transition factories must not read a View")
|
||||||
|
override val drawable: Drawable? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun onlyNetworkSuccessFadesAndReducedMotionCapsItsDuration() {
|
||||||
|
val request = ImageRequest.Builder(RuntimeEnvironment.getApplication()).data("https://example.invalid/poster").build()
|
||||||
|
val bitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888)
|
||||||
|
try {
|
||||||
|
for (reduced in listOf(false, true)) {
|
||||||
|
NetworkArtTransition.reduced = reduced
|
||||||
|
for (source in DataSource.entries) {
|
||||||
|
val transition = NetworkArtTransition.create(target, SuccessResult(bitmap.asImage(), request, source))
|
||||||
|
if (source == DataSource.NETWORK) {
|
||||||
|
assertTrue(transition is CrossfadeTransition)
|
||||||
|
assertEquals(if (reduced) 80 else 160, (transition as CrossfadeTransition).durationMillis)
|
||||||
|
} else assertFalse("$source must paint instantly", transition is CrossfadeTransition)
|
||||||
|
}
|
||||||
|
assertFalse(NetworkArtTransition.create(target, ErrorResult(null, request, IllegalStateException("offline"))) is CrossfadeTransition)
|
||||||
|
}
|
||||||
|
} finally { NetworkArtTransition.reduced = false; bitmap.recycle() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -123,7 +123,27 @@
|
||||||
--layout-castCircle: 200.0px;
|
--layout-castCircle: 200.0px;
|
||||||
--layout-personPortrait: 240.0px;
|
--layout-personPortrait: 240.0px;
|
||||||
--layout-row_chipsGrid: 136.0px;
|
--layout-row_chipsGrid: 136.0px;
|
||||||
--motion-fast: 140ms;
|
--motion-skeletonCycle: 1400ms;
|
||||||
|
--motion-focusMove: 180ms;
|
||||||
|
--motion-captionDelay: 60ms;
|
||||||
|
--motion-captionOut: 100ms;
|
||||||
|
--motion-artReveal: 160ms;
|
||||||
|
--motion-heroFade: 320ms;
|
||||||
|
--motion-heroCopyOut: 120ms;
|
||||||
|
--motion-heroCopyDelay: 140ms;
|
||||||
|
--motion-heroCopyIn: 200ms;
|
||||||
|
--motion-rowEnter: 180ms;
|
||||||
|
--motion-lateLineFade: 200ms;
|
||||||
|
--motion-copyEntryRise: 16.0px;
|
||||||
|
--motion-reducedFade: 80ms;
|
||||||
|
--motion-rowRise: 24.0px;
|
||||||
|
--motion-captionRise: 10.0px;
|
||||||
|
--motion-copyRise: 14.0px;
|
||||||
|
--motion-readinessSlot: 56.0px;
|
||||||
|
--motion-ease: cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||||
|
--motion-easeOut: cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
|
--motion-easeInOut: cubic-bezier(0.65, 0, 0.35, 1);
|
||||||
|
--motion-breatheEase: cubic-bezier(0.42, 0, 0.58, 1);
|
||||||
--motion-base: 160ms;
|
--motion-base: 160ms;
|
||||||
--motion-scroll: 180ms;
|
--motion-scroll: 180ms;
|
||||||
--motion-ratingDwell: 350ms;
|
--motion-ratingDwell: 350ms;
|
||||||
|
|
|
||||||
|
|
@ -1390,19 +1390,28 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"motion": {
|
"motion": {
|
||||||
"fast": {
|
"skeletonCycle": { "$type": "duration", "$value": "1400ms" },
|
||||||
"$type": "duration",
|
"skeletonLowAlpha": { "$type": "number", "$value": 0.55 },
|
||||||
"$value": "140ms",
|
"focusMove": { "$type": "duration", "$value": "180ms" },
|
||||||
"easing": "ease = cubic-bezier(.25,.1,.25,1)",
|
"captionDelay": { "$type": "duration", "$value": "60ms" },
|
||||||
"source": "index.html:125",
|
"captionOut": { "$type": "duration", "$value": "100ms" },
|
||||||
"page": {
|
"artReveal": { "$type": "duration", "$value": "160ms" },
|
||||||
".card/.btn/.chip/.pp/.prow/.crow/.srow/.pgear transform": "140ms",
|
"heroFade": { "$type": "duration", "$value": "320ms" },
|
||||||
".cap opacity (caption fade-in)": "120ms",
|
"heroCopyOut": { "$type": "duration", "$value": "120ms" },
|
||||||
".dots svg opacity": "140ms"
|
"heroCopyDelay": { "$type": "duration", "$value": "140ms" },
|
||||||
},
|
"heroCopyIn": { "$type": "duration", "$value": "200ms" },
|
||||||
"usedBy": "focus scale + ring in and out on every focusable; caption fade under a focused poster (page 120 ms folds in)",
|
"rowEnter": { "$type": "duration", "$value": "180ms" },
|
||||||
"note": "q1, settled by Justin 2026-11-05 (corr=c248698d8546e1ab): the caption fade is 140 ms. Three durations in total (140, 160, 180) plus the blob spring."
|
"lateLineFade": { "$type": "duration", "$value": "200ms" },
|
||||||
},
|
"copyEntryRise": { "$type": "dimension", "$value": "16px", "px": 16, "dp": 8 },
|
||||||
|
"reducedFade": { "$type": "duration", "$value": "80ms" },
|
||||||
|
"rowRise": { "$type": "dimension", "$value": "24px", "px": 24, "dp": 12 },
|
||||||
|
"captionRise": { "$type": "dimension", "$value": "10px", "px": 10, "dp": 5 },
|
||||||
|
"copyRise": { "$type": "dimension", "$value": "14px", "px": 14, "dp": 7 },
|
||||||
|
"readinessSlot": { "$type": "dimension", "$value": "56px", "px": 56, "dp": 28 },
|
||||||
|
"ease": { "$type": "cubicBezier", "$value": [0.25, 0.1, 0.25, 1] },
|
||||||
|
"easeOut": { "$type": "cubicBezier", "$value": [0.33, 1, 0.68, 1] },
|
||||||
|
"easeInOut": { "$type": "cubicBezier", "$value": [0.65, 0, 0.35, 1] },
|
||||||
|
"breatheEase": { "$type": "cubicBezier", "$value": [0.42, 0, 0.58, 1] },
|
||||||
"base": {
|
"base": {
|
||||||
"$type": "duration",
|
"$type": "duration",
|
||||||
"$value": "160ms",
|
"$value": "160ms",
|
||||||
|
|
@ -1475,9 +1484,9 @@
|
||||||
},
|
},
|
||||||
"reducedMotion": {
|
"reducedMotion": {
|
||||||
"$type": "rule",
|
"$type": "rule",
|
||||||
"$value": "all transitions instant",
|
"$value": "no pulse or movement; fades at most 80ms",
|
||||||
"source": "index.html:330",
|
"source": "tv-motion-speed direction A",
|
||||||
"usedBy": "when Settings.Global ANIMATOR_DURATION_SCALE == 0: no scale animation, no scroll animation, blob snaps"
|
"usedBy": "system animator scale zero or Wolfskin reduced-motion flag"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"image": {
|
"image": {
|
||||||
|
|
@ -1536,11 +1545,6 @@
|
||||||
"source": "index.html:550",
|
"source": "index.html:550",
|
||||||
"usedBy": "cast portraits: decode min(source 185x278, slot)"
|
"usedBy": "cast portraits: decode min(source 185x278, slot)"
|
||||||
},
|
},
|
||||||
"crossfade": {
|
|
||||||
"$value": false,
|
|
||||||
"source": "index.html:536; answers p3",
|
|
||||||
"usedBy": "no crossfade on cards (Coil crossfade off)"
|
|
||||||
},
|
|
||||||
"memoryCacheMB": {
|
"memoryCacheMB": {
|
||||||
"$value": 64,
|
"$value": 64,
|
||||||
"source": "index.html:552; answers p3",
|
"source": "index.html:552; answers p3",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
|
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
|
||||||
85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py
|
c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py
|
||||||
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
|
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
|
||||||
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json
|
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json
|
||||||
580ed1710468ccbd97082afaa445e96ae71510ebace711cc735ea389ecb9249d src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
0446c2e85bbda72adde76329958931216fe81c43cebd47bbdb885f5dd59d87f8 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||||
e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json
|
e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json
|
||||||
36d5b2646dff2e4055d2683e4c284111a6886c311e9c924b73061fa785bc863d tokens.css
|
faa4e0c5398d35ecab9b1088815b79c8de26a44d403c4cafae59c7c341992da7 tokens.css
|
||||||
226f4bd07c0b105f8640beff71fe2c0c95d0f8862e624cea511384a4591b44ad tokens.json
|
f4424475291a745400a5daa071cb846838fb5e9ce889022288bcc6a7be758fe0 tokens.json
|
||||||
|
|
|
||||||