Implement approved TV motion and shaped loading states #51
|
|
@ -15,7 +15,8 @@ import coil3.network.NetworkResponse
|
|||
import coil3.network.cachecontrol.CacheControlCacheStrategy
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.Options
|
||||
import coil3.request.crossfade
|
||||
import coil3.request.transitionFactory
|
||||
import org.justinvisser.wolfskin.design.NetworkArtTransition
|
||||
import coil3.util.DebugLogger
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
|
|
@ -89,7 +90,7 @@ fun CoilConfig(
|
|||
memoryCache(null)
|
||||
diskCache(null)
|
||||
}
|
||||
}.crossfade(false)
|
||||
}.transitionFactory(NetworkArtTransition)
|
||||
.logger(if (debugLogging) DebugLogger() else null)
|
||||
.components {
|
||||
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
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
## 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. All loading shelves under the screen's theme share one clock, so late arrivals join the current phase. The clock stops when the last loading shelf leaves. 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 only; exit holds its position, including when entry was interrupted |
|
||||
| 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 ratings, genres, overview and readiness | 200 ms, ease | Alpha only, inside existing measured slots; rating plates keep their existing plate fade rather than adding a nested fade |
|
||||
| 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 animated layers 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 animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. 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. Late title metadata adds at most **4** bounded alpha layers: title ratings, play-target ratings, genres and overview. 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
|
||||
|
||||
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. Native follow-up cases compare the pulse pixels of a late-joining shelf, hold caption translation through both settled and interrupted exits, and observe late ratings/genres/overview fading without moving their slots or actions. Manually clocked captures explicitly deliver snapshot changes before advancing animation time; interrupted-exit assertions sample after the hide has committed. 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.
|
||||
|
|
|
|||
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.",
|
||||
"package org.justinvisser.wolfskin.design",
|
||||
"",
|
||||
"import androidx.compose.animation.core.CubicBezierEasing",
|
||||
"import androidx.compose.ui.graphics.Color as ComposeColor",
|
||||
"import androidx.compose.ui.text.TextStyle",
|
||||
"import androidx.compose.ui.text.font.FontWeight",
|
||||
|
|
@ -71,6 +72,10 @@ def render_tokens(tokens: dict) -> tuple[str, str]:
|
|||
elif kind == "duration":
|
||||
lines.append(f" const val {name} = {int(value.removesuffix('ms'))}")
|
||||
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":
|
||||
lines.append(f" const val {name}DampingRatio = {number(value['dampingRatio'])}f")
|
||||
lines.append(f" const val {name}Stiffness = {number(value['stiffness'])}f")
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
|
|
@ -17,16 +17,22 @@ import androidx.compose.ui.draw.clipToBounds
|
|||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.unit.constrainHeight
|
||||
|
||||
/** Captions fade without moving the reserved band under the focused poster. */
|
||||
/** Captions rise inside the reserved band without changing measured geometry. */
|
||||
@Composable
|
||||
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,
|
||||
titleOverflow: TextOverflow = TextOverflow.Ellipsis) {
|
||||
val alpha = remember(title, facts) { Animatable(0f) }
|
||||
LaunchedEffect(alpha, visible) {
|
||||
alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
|
||||
val entryProgress = remember(title, facts) { mutableFloatStateOf(0f) }
|
||||
val reduced = reducedMotion()
|
||||
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)) {
|
||||
if (visible) entryProgress.floatValue = value
|
||||
}
|
||||
}
|
||||
Column(modifier.graphicsLayer { this.alpha = alpha.value }) {
|
||||
Column(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - entryProgress.floatValue) }) {
|
||||
Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = titleOverflow)
|
||||
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.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(
|
||||
model = request,
|
||||
imageLoader = WolfskinImages.loader(context),
|
||||
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = filter,
|
||||
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) {
|
||||
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)) {
|
||||
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) {
|
||||
DiscoverArtwork(title.poster, Modifier.offset(-D.ambientInset, -D.ambientInset)
|
||||
.wrapContentSize(Alignment.TopStart, unbounded = true)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.text.style.TextOverflow
|
||||
import androidx.tv.material3.Text
|
||||
|
|
@ -73,8 +73,8 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
|||
return
|
||||
}
|
||||
if (state.snapshot.status == DiscoverStatus.Loading) {
|
||||
val loading = remember(state) { LibraryGridState("Discover", emptyList(), catalogue = true).apply { status = LibraryStatus.Loading } }
|
||||
LibraryScreen(shell, loading, modifier, offlineArt = LocalDiscoverArt.current, registerFocus = false)
|
||||
LaunchedEffect(state) { if (!shell.navFocused) shell.focusNav(shell.currentTab) }
|
||||
DiscoverLoading(modifier)
|
||||
return
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
|
|
@ -104,7 +104,9 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
|||
}
|
||||
}
|
||||
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()) {
|
||||
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 "")
|
||||
|
|
@ -132,22 +134,23 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
|||
@Composable
|
||||
private fun DiscoverHero(state: DiscoverState) {
|
||||
val title = state.hero ?: return
|
||||
Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) {
|
||||
HeroCopyHandoff(title.key, title) { copy ->
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
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))
|
||||
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)
|
||||
if (title.genres.isNotEmpty()) Text(title.genres.take(2).joinToString(", "), color = C.muted, style = DiscoverType.body)
|
||||
if (state.isSignedIn) DiscoverRatings(title.ratings)
|
||||
if (state.shell.person.key in title.heldBy) Text("In your library", color = C.accentText, style = DiscoverType.body)
|
||||
Text("${copy.typeWord}${copy.year?.let { " · $it" }.orEmpty()}", 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(copy.ratings)
|
||||
if (state.shell.person.key in copy.heldBy) Text("In your library", color = C.accentText, style = DiscoverType.body)
|
||||
}
|
||||
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))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) }
|
||||
|
|
@ -158,6 +161,7 @@ private fun DiscoverHero(state: DiscoverState) {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
|
|||
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),
|
||||
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 ->
|
||||
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)
|
||||
|
|
@ -66,7 +66,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
|
|||
}
|
||||
} else if (lane.pending) {
|
||||
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 {
|
||||
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->
|
||||
|
|
|
|||
|
|
@ -156,7 +156,8 @@ class DiscoverState(
|
|||
}
|
||||
|
||||
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 >= rowCount) return
|
||||
val key = when (target) { 0 -> "phero"; 1 -> "dchips"; else -> lanes[target - firstShelf].id }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -26,9 +25,10 @@ import androidx.compose.ui.unit.Dp
|
|||
/** Only transforms animate: focus never changes measured geometry. */
|
||||
@Composable
|
||||
fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier {
|
||||
val reduced = reducedMotion()
|
||||
val animated by animateFloatAsState(
|
||||
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",
|
||||
)
|
||||
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. */
|
||||
@Composable
|
||||
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,
|
||||
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 shape = remember(radius) { RoundedCornerShape(radius) }
|
||||
val density = LocalDensity.current
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
|
|
@ -11,8 +10,8 @@ internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) {
|
|||
if (distance == 0f) return
|
||||
list.scroll {
|
||||
var previous = 0f
|
||||
animate(0f, distance, animationSpec = tween(WolfskinTokens.Motion.scroll,
|
||||
easing = CubicBezierEasing(.33f, 1f, .68f, 1f))) { value, _ ->
|
||||
animate(0f, distance, animationSpec = tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll,
|
||||
easing = WolfskinTokens.Motion.easeOut)) { value, _ ->
|
||||
scrollBy(value - previous)
|
||||
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) {
|
||||
val title = row.cards.first()
|
||||
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.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)) {
|
||||
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)
|
||||
}
|
||||
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))
|
||||
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)
|
||||
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)
|
||||
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" || 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)
|
||||
}
|
||||
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))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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)) {
|
||||
Box(Modifier.size(H.heroSkeletonWidth, H.heroSkeletonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||
Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).skeletonPulse()) {
|
||||
Box(Modifier.size(H.heroSkeletonWidth, H.heroSkeletonHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||
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))
|
||||
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. */
|
||||
@Composable
|
||||
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
|
||||
val measurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
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()
|
||||
}.coerceAtLeast(0f)
|
||||
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.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)) {
|
||||
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()
|
||||
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 ->
|
||||
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")
|
||||
.then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier)
|
||||
if (title == null && !row.skeleton) {
|
||||
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) {
|
||||
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)
|
||||
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) }
|
||||
|
|
|
|||
|
|
@ -120,8 +120,8 @@ fun LibraryScreen(
|
|||
when (grid.status) {
|
||||
LibraryStatus.Loading -> items(3, key = { "skeleton-$it" }, contentType = { "grid" }) {
|
||||
Box(Modifier.fillMaxWidth().height(rowHeight)) {
|
||||
Row(Modifier.padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
|
||||
repeat(grid.columns) { Box(Modifier.size(K.posterW, K.posterH).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) }
|
||||
Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
|
||||
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}"
|
||||
}, contentType = { "grid" }) { row ->
|
||||
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) {
|
||||
val index = row * grid.columns + column
|
||||
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,107 @@
|
|||
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.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
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
|
||||
|
||||
@Stable
|
||||
private class SkeletonPulseClock {
|
||||
var subscribers by mutableIntStateOf(0)
|
||||
val idle: State<Float> = mutableFloatStateOf(M.skeletonLowAlpha)
|
||||
var alpha by mutableStateOf(idle)
|
||||
}
|
||||
|
||||
private val LocalSkeletonPulseClock = compositionLocalOf<SkeletonPulseClock> {
|
||||
error("Skeleton loading requires WolfskinTheme")
|
||||
}
|
||||
|
||||
/** Share phase across shelves, including late arrivals; stop the clock when no shelf needs it. */
|
||||
@Composable
|
||||
internal fun SkeletonPulseScope(content: @Composable () -> Unit) {
|
||||
val clock = remember { SkeletonPulseClock() }
|
||||
val reduced = reducedMotion()
|
||||
val alpha = if (clock.subscribers > 0 && !reduced) {
|
||||
rememberInfiniteTransition(label = "skeleton-screen").animateFloat(M.skeletonLowAlpha, 1f,
|
||||
infiniteRepeatable(tween(M.skeletonCycle / 2, easing = M.breatheEase), RepeatMode.Reverse), label = "breathe")
|
||||
} else clock.idle
|
||||
SideEffect { clock.alpha = alpha }
|
||||
CompositionLocalProvider(LocalSkeletonPulseClock provides clock, content = content)
|
||||
}
|
||||
|
||||
/** 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 = LocalSkeletonPulseClock.current
|
||||
DisposableEffect(clock) {
|
||||
clock.subscribers++
|
||||
onDispose { clock.subscribers-- }
|
||||
}
|
||||
return graphicsLayer { alpha = clock.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
|
||||
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
|
||||
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 box = boxes[index]
|
||||
val avatarFocused = state.navFocused && index == state.tabs.size
|
||||
val pad = if (avatarFocused) L.navBlobAvatarPad else S.zero
|
||||
val damping = WolfskinTokens.Motion.navBlobDampingRatio
|
||||
val stiffness = WolfskinTokens.Motion.navBlobStiffness
|
||||
val x by animateFloatAsState((box.left - pad).value, spring(damping, stiffness), label = "blob-x")
|
||||
val y by animateFloatAsState((box.top - pad).value, spring(damping, stiffness), label = "blob-y")
|
||||
val width by animateFloatAsState((box.width + pad * 2).value, spring(damping, stiffness), label = "blob-width")
|
||||
val height by animateFloatAsState((box.height + pad * 2).value, spring(damping, stiffness), label = "blob-height")
|
||||
val color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, spring(damping, stiffness), label = "blob-color")
|
||||
val geometry = if (reduced) tween<Float>(0) else spring<Float>(damping, stiffness)
|
||||
val x by animateFloatAsState((box.left - pad).value, geometry, label = "blob-x")
|
||||
val y by animateFloatAsState((box.top - pad).value, geometry, label = "blob-y")
|
||||
val width by animateFloatAsState((box.width + pad * 2).value, geometry, label = "blob-width")
|
||||
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 band = remember {
|
||||
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
|
||||
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
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 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
|
||||
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,
|
||||
contentPadding = PaddingValues(bottom = S.stageHeight - L.pinnedLine - L.row_srow)) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
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. */
|
||||
internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) {
|
||||
val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset
|
||||
if (distance != 0f) animateScrollBy(distance, tween(WolfskinTokens.Motion.base,
|
||||
easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
|
||||
if (distance != 0f) animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.base,
|
||||
easing = WolfskinTokens.Motion.ease))
|
||||
if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ fun TitleRatings(ratings: List<LibraryRating>, modifier: Modifier = Modifier, st
|
|||
val numberStyle = remember(textStyle) { textStyle.copy(fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold) }
|
||||
val gap = if (plate) with(density) { (style.fontSize * P.plateGapEm).toDp() } else D.ratingGap
|
||||
val markGap = if (plate) with(density) { (style.fontSize * P.markGapEm).toDp() } else D.ratingMarkGap
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(if (plate) modifier else modifier.lateLineFade(ratings), horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) {
|
||||
ratings.forEach { rating ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
val resource = when (rating.source) {
|
||||
|
|
@ -70,7 +70,7 @@ fun LibraryRatingPlate(ratings: List<LibraryRating>, modifier: Modifier = Modifi
|
|||
if (ratings.isEmpty()) return
|
||||
val measurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "rating-plate")
|
||||
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 }
|
||||
.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)) {
|
||||
|
|
|
|||
|
|
@ -43,18 +43,23 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
|||
/** Local decoded fixture art keeps screenshot tests deterministic and completely offline. */
|
||||
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
|
||||
fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Modifier, poster: Boolean = false, colorFilter: ColorFilter? = null, onError: () -> Unit = {}) {
|
||||
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) {
|
||||
val context = LocalContext.current
|
||||
val request = remember(context, path, width, height, poster) {
|
||||
WolfskinImages.request(context, path, width, height, poster)
|
||||
}
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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" }
|
||||
}
|
||||
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) {
|
||||
Glyph(if (item.held) "check" else "sparkle", C.accentText, Modifier.size(D.lineIcon))
|
||||
Text(buildString {
|
||||
|
|
@ -58,17 +57,19 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
|||
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
||||
if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
||||
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).lateLineFade(item.genres))
|
||||
}
|
||||
state.data.requestedStage?.let { stage ->
|
||||
val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted }
|
||||
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph(titleStageGlyph(stage), color, Modifier.size(D.lineIcon))
|
||||
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)
|
||||
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 ->
|
||||
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).lateLineFade(stage), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph(titleStageGlyph(stage), color, Modifier.size(D.lineIcon))
|
||||
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((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).lateLineFade((episode ?: item).overview).testTag("title-overview"))
|
||||
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.",
|
||||
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap))
|
||||
|
|
@ -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. */
|
||||
@Composable
|
||||
fun TitleHeaderSkeleton(modifier: Modifier = Modifier) {
|
||||
Column(modifier.offset(L.safeSide, L.detailCopyTop)) {
|
||||
Box(Modifier.size(D.skeletonKickerWidth, D.skeletonKickerHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
|
||||
Column(modifier.offset(L.safeSide, L.detailCopyTop).skeletonPulse()) {
|
||||
Box(Modifier.size(D.skeletonKickerWidth, D.skeletonKickerHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
|
||||
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))
|
||||
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))
|
||||
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))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
Box(Modifier.size(D.skeletonPlayWidth, L.buttonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button)))
|
||||
repeat(2) { Box(Modifier.size(L.iconButton).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.surface2, RoundedCornerShape(WolfskinTokens.Radius.button))) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
|
|
@ -147,8 +146,9 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
|||
}.focusable()) {
|
||||
// This remains visible behind the scrolled rows; it is not an off-screen header image.
|
||||
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
|
||||
Box(Modifier.fillMaxSize().graphicsLayer { alpha = dim }.background(C.bg))
|
||||
Box(Modifier.fillMaxSize().drawBehind { drawRect(C.bg.copy(alpha = dim)) })
|
||||
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()) {
|
||||
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
|
||||
|
|
@ -185,7 +185,9 @@ private fun TitleSeasons(state: TitleState, active: Boolean) {
|
|||
val list = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
val visibleChip = if (active) state.column else state.data.seasonNumbers.indexOf(state.season)
|
||||
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)) {
|
||||
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)) {
|
||||
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)) {
|
||||
if (state.data.castStatus == TitleReadStatus.Loading) {
|
||||
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 ->
|
||||
val focused = active && state.column == index
|
||||
|
|
@ -89,14 +89,14 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
|
|||
LaunchedEffect(state.column, active, 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))
|
||||
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)) {
|
||||
if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) {
|
||||
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 ->
|
||||
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))
|
||||
}
|
||||
}
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ fun TitleArtwork(title: String, type: String, poster: String?, width: Dp, height
|
|||
val source = if (landscape) backdrop ?: poster else poster
|
||||
val failed = remember(source) { androidx.compose.runtime.mutableStateOf(false) }
|
||||
val missing = source == null || failed.value
|
||||
val heroReady = LocalHeroArtReady.current
|
||||
if (missing) androidx.compose.runtime.SideEffect { heroReady?.invoke() }
|
||||
Box(modifier.background(C.surface2)) {
|
||||
if (!missing) {
|
||||
if (landscape && (posterOnly || backdrop == null) && poster != null) {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import coil3.ImageLoader
|
|||
import coil3.disk.DiskCache
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.disk.directory
|
||||
import coil3.request.crossfade
|
||||
import coil3.request.transitionFactory
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.allowRgb565
|
||||
|
|
@ -61,7 +61,7 @@ object WolfskinImages {
|
|||
networkConfiguration?.invoke(this)
|
||||
add(Interceptor { chain -> readPermits.withPermit { chain.proceed() } })
|
||||
}
|
||||
.fetcherCoroutineContext(reads).decoderCoroutineContext(reads).crossfade(false).build()
|
||||
.fetcherCoroutineContext(reads).decoderCoroutineContext(reads).transitionFactory(NetworkArtTransition).build()
|
||||
}
|
||||
return loader!!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,11 @@ object WolfskinColors {
|
|||
/** Upstream content inherits tokens without a fork-specific theme enum. */
|
||||
@Composable
|
||||
fun WolfskinTheme(content: @Composable () -> Unit) {
|
||||
val reduced = reducedMotion()
|
||||
androidx.compose.runtime.SideEffect { NetworkArtTransition.reduced = reduced }
|
||||
androidx.compose.material3.MaterialTheme(colorScheme = WolfskinColors.darkSchemeMaterial) {
|
||||
MaterialTheme(colorScheme = WolfskinColors.darkScheme, content = content)
|
||||
MaterialTheme(colorScheme = WolfskinColors.darkScheme) {
|
||||
SkeletonPulseScope(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Generated by generate_tokens.py; edit the JSON sources instead.
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.ui.graphics.Color as ComposeColor
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
|
@ -208,14 +209,35 @@ object WolfskinTokens {
|
|||
const val fillInverse = "background color.fg, text color.onFocus"
|
||||
}
|
||||
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 scroll = 180
|
||||
const val navBlobDampingRatio = 1.0f
|
||||
const val navBlobStiffness = 650.0f
|
||||
const val ratingDwell = 350
|
||||
const val toast = 3200
|
||||
const val reducedMotion = "all transitions instant"
|
||||
const val reducedMotion = "no pulse or movement; fades at most 80ms"
|
||||
}
|
||||
object Image {
|
||||
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,230 @@
|
|||
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.offset
|
||||
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.onNodeWithText
|
||||
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 shelvesJoiningAnActiveLoadShareTheSameBreathingPhase() {
|
||||
rule.mainClock.autoAdvance = false
|
||||
val second = mutableStateOf(false)
|
||||
rule.setContent { WolfskinTheme {
|
||||
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
|
||||
Box(Modifier.size(200.dp, 140.dp).skeletonPulse().background(WolfskinTokens.Color.surface2))
|
||||
if (second.value) Box(Modifier.offset(y = 200.dp).size(200.dp, 140.dp).skeletonPulse().background(WolfskinTokens.Color.surface2))
|
||||
}
|
||||
} }
|
||||
rule.mainClock.advanceTimeBy(600)
|
||||
update { second.value = true }
|
||||
val directory = kotlin.io.path.createTempDirectory("wolfskin-shared-pulse-").toFile()
|
||||
try {
|
||||
var previous: Int? = null
|
||||
repeat(2) { sample ->
|
||||
val file = File(directory, "$sample.png")
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||
try {
|
||||
val first = bitmap.getPixel(100, 100)
|
||||
assertEquals("Late shelves must join the existing phase", first, bitmap.getPixel(100, 500))
|
||||
if (previous != null) assertNotEquals("The shared clock must still breathe", previous, first)
|
||||
previous = first
|
||||
} finally { bitmap.recycle() }
|
||||
rule.mainClock.advanceTimeBy(450)
|
||||
}
|
||||
} finally { directory.deleteRecursively() }
|
||||
}
|
||||
|
||||
@Test fun captionExitKeepsTranslationFixedAtRestAndDuringInterruptedEntry() {
|
||||
rule.mainClock.autoAdvance = false
|
||||
val title = mutableStateOf("Settled caption")
|
||||
val visible = mutableStateOf(true)
|
||||
rule.setContent { WolfskinTheme { Caption(title.value, "Facts", visible.value, Modifier.offset(40.dp, 80.dp)) } }
|
||||
rule.mainClock.advanceTimeBy(400)
|
||||
fun top() = rule.onNodeWithText(title.value).fetchSemanticsNode().boundsInRoot.top
|
||||
val settled = top()
|
||||
update { visible.value = false }
|
||||
rule.mainClock.advanceTimeBy(48)
|
||||
assertEquals("Exit cannot sink a settled caption", settled, top(), 0.01f)
|
||||
update { title.value = "Interrupted caption"; visible.value = true }
|
||||
rule.mainClock.advanceTimeBy(112)
|
||||
val interrupted = top()
|
||||
assertTrue("Entry must still be partway through its rise", interrupted > settled)
|
||||
update { visible.value = false }
|
||||
val exitTop = top()
|
||||
assertTrue("Exit must retain the unfinished rise, not snap to its baseline", exitTop > settled)
|
||||
rule.mainClock.advanceTimeBy(48)
|
||||
assertEquals("An interrupted entry must fade exactly where it stopped", exitTop, top(), 0.01f)
|
||||
}
|
||||
|
||||
@Test fun lateRatingsGenresAndOverviewFadeInsideTheirExistingSlots() {
|
||||
rule.mainClock.autoAdvance = false
|
||||
val series = TitleItem("series", "A series", "tv", held = true)
|
||||
val state = TitleState(TitlePageData(series, connection = TitleConnection.SignedIn), viewer) {}
|
||||
val shell = shell()
|
||||
rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } }
|
||||
rule.mainClock.advanceTimeBy(500)
|
||||
val actions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
|
||||
update { state.update(state.data.copy(item = series.copy(ratings = listOf(LibraryRating("rt", 88.0)), genres = listOf("Drama"), overview = "Late overview"))) }
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
val regions = listOf("88%", "Drama", "Late overview").map { rule.onNodeWithText(it).fetchSemanticsNode().boundsInRoot }
|
||||
val directory = kotlin.io.path.createTempDirectory("wolfskin-late-fade-").toFile()
|
||||
fun brightness(name: String): List<Long> {
|
||||
val file = File(directory, name)
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||
return try { regions.map { bounds ->
|
||||
var sum = 0L
|
||||
for (y in bounds.top.toInt() until bounds.bottom.toInt()) for (x in bounds.left.toInt() until bounds.right.toInt()) {
|
||||
val pixel = bitmap.getPixel(x, y)
|
||||
sum += android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
|
||||
}
|
||||
sum
|
||||
} } finally { bitmap.recycle() }
|
||||
}
|
||||
try {
|
||||
val entering = brightness("entering.png")
|
||||
rule.mainClock.advanceTimeBy(240)
|
||||
brightness("settled.png").zip(entering).forEach { (settled, early) -> assertTrue("Late text must fade rather than pop", settled > early) }
|
||||
assertEquals(regions, listOf("88%", "Drama", "Late overview").map { rule.onNodeWithText(it).fetchSemanticsNode().boundsInRoot })
|
||||
assertEquals(actions, rule.onNodeWithTag("title-actions").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 update(block: () -> Unit) {
|
||||
rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
}
|
||||
|
||||
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-personPortrait: 240.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-scroll: 180ms;
|
||||
--motion-ratingDwell: 350ms;
|
||||
|
|
|
|||
|
|
@ -1390,19 +1390,28 @@
|
|||
}
|
||||
},
|
||||
"motion": {
|
||||
"fast": {
|
||||
"$type": "duration",
|
||||
"$value": "140ms",
|
||||
"easing": "ease = cubic-bezier(.25,.1,.25,1)",
|
||||
"source": "index.html:125",
|
||||
"page": {
|
||||
".card/.btn/.chip/.pp/.prow/.crow/.srow/.pgear transform": "140ms",
|
||||
".cap opacity (caption fade-in)": "120ms",
|
||||
".dots svg opacity": "140ms"
|
||||
},
|
||||
"usedBy": "focus scale + ring in and out on every focusable; caption fade under a focused poster (page 120 ms folds in)",
|
||||
"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."
|
||||
},
|
||||
"skeletonCycle": { "$type": "duration", "$value": "1400ms" },
|
||||
"skeletonLowAlpha": { "$type": "number", "$value": 0.55 },
|
||||
"focusMove": { "$type": "duration", "$value": "180ms" },
|
||||
"captionDelay": { "$type": "duration", "$value": "60ms" },
|
||||
"captionOut": { "$type": "duration", "$value": "100ms" },
|
||||
"artReveal": { "$type": "duration", "$value": "160ms" },
|
||||
"heroFade": { "$type": "duration", "$value": "320ms" },
|
||||
"heroCopyOut": { "$type": "duration", "$value": "120ms" },
|
||||
"heroCopyDelay": { "$type": "duration", "$value": "140ms" },
|
||||
"heroCopyIn": { "$type": "duration", "$value": "200ms" },
|
||||
"rowEnter": { "$type": "duration", "$value": "180ms" },
|
||||
"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": {
|
||||
"$type": "duration",
|
||||
"$value": "160ms",
|
||||
|
|
@ -1475,9 +1484,9 @@
|
|||
},
|
||||
"reducedMotion": {
|
||||
"$type": "rule",
|
||||
"$value": "all transitions instant",
|
||||
"source": "index.html:330",
|
||||
"usedBy": "when Settings.Global ANIMATOR_DURATION_SCALE == 0: no scale animation, no scroll animation, blob snaps"
|
||||
"$value": "no pulse or movement; fades at most 80ms",
|
||||
"source": "tv-motion-speed direction A",
|
||||
"usedBy": "system animator scale zero or Wolfskin reduced-motion flag"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
|
|
@ -1536,11 +1545,6 @@
|
|||
"source": "index.html:550",
|
||||
"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": {
|
||||
"$value": 64,
|
||||
"source": "index.html:552; answers p3",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
|
||||
85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py
|
||||
c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py
|
||||
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-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
|
||||
36d5b2646dff2e4055d2683e4c284111a6886c311e9c924b73061fa785bc863d tokens.css
|
||||
226f4bd07c0b105f8640beff71fe2c0c95d0f8862e624cea511384a4591b44ad tokens.json
|
||||
faa4e0c5398d35ecab9b1088815b79c8de26a44d403c4cafae59c7c341992da7 tokens.css
|
||||
f4424475291a745400a5daa071cb846838fb5e9ce889022288bcc6a7be758fe0 tokens.json
|
||||
|
|
|
|||