|
1 | | -// Kinetic reader — thin JS bridge used by Blazor playback. |
| 1 | +// Kinetic reader — JS bridge for word-envelope timing and the focus lens. |
2 | 2 | // |
3 | | -// All cue kinetic animations (loud, soft, whisper, urgent, stress, |
4 | | -// staccato, energetic, excited, building, calm, legato, aside) are |
5 | | -// pure CSS `@keyframes` defined on `.rd-stage.rd-reading-active |
6 | | -// .rd-w.rd-now.tps-*` in 10-reading-states.css. CSS fires them |
7 | | -// automatically on class match, every engine repaints. No WAAPI, |
8 | | -// no `@property` custom-property interpolation, no hacks. |
| 3 | +// Word cue motion still lives in CSS `@keyframes`, but JS owns the |
| 4 | +// exact wall-clock duration. On every new active word we: |
| 5 | +// 1. resolve the actual spoken duration for that cue; |
| 6 | +// 2. write CSS variables for the kinetic/beam timing; |
| 7 | +// 3. toggle `.rd-kinetic-active` so the CSS animation restarts cleanly. |
9 | 8 | // |
10 | | -// This module is now only responsible for the focus lens (the soft |
11 | | -// warm aura that glides BETWEEN words) and the `commitFrame()` helper |
12 | | -// used by card transitions to force a paint between snap and animate. |
| 9 | +// This keeps the visual envelope aligned with the reader loop without |
| 10 | +// pushing layout-affecting work into JS. |
13 | 11 | (function () { |
14 | 12 | const kineticReaderNamespace = "KineticReaderInterop"; |
| 13 | + const ACTIVE_WORD_SELECTOR = ".rd-stage.rd-reading-active .rd-w.rd-now"; |
| 14 | + const ACTIVE_WORD_CLASS = "rd-kinetic-active"; |
| 15 | + const KINETIC_TIMING = { |
| 16 | + staccato: { ratio: 0.42, floor: 180, cap: 260 }, |
| 17 | + stress: { ratio: 0.5, floor: 220, cap: 340 }, |
| 18 | + loud: { ratio: 0.7, floor: 260, cap: 480 }, |
| 19 | + urgent: { ratio: 0.72, floor: 260, cap: 460 }, |
| 20 | + energetic:{ ratio: 0.78, floor: 300, cap: 520 }, |
| 21 | + excited: { ratio: 0.78, floor: 300, cap: 520 }, |
| 22 | + building: { ratio: 0.9, floor: 340, cap: 640 }, |
| 23 | + calm: { ratio: 0.95, floor: 380, cap: 700 }, |
| 24 | + legato: { ratio: 1.0, floor: 420, cap: 760 }, |
| 25 | + aside: { ratio: 0.88, floor: 340, cap: 620 }, |
| 26 | + soft: { ratio: 0.92, floor: 360, cap: 680 }, |
| 27 | + whisper: { ratio: 0.92, floor: 360, cap: 680 }, |
| 28 | + slow: { ratio: 0.92, floor: 360, cap: 680 }, |
| 29 | + xslow: { ratio: 1.0, floor: 400, cap: 760 }, |
| 30 | + sad: { ratio: 0.95, floor: 380, cap: 700 } |
| 31 | + }; |
| 32 | + const KINETIC_DEFAULT = { ratio: 0.82, floor: 260, cap: 560 }; |
| 33 | + const KINETIC_PRIORITY = [ |
| 34 | + "staccato", "stress", "loud", "urgent", "energetic", "excited", |
| 35 | + "building", "legato", "calm", "aside", "soft", "whisper", |
| 36 | + "xslow", "slow", "sad" |
| 37 | + ]; |
15 | 38 |
|
16 | 39 | // Cue → lens transition character. Easing captures the "feel" |
17 | 40 | // (snap vs glide vs linear flow); the DURATION is derived from |
|
41 | 64 | "whisper", "soft", "calm", "sad", "fast", "aside" |
42 | 65 | ]; |
43 | 66 |
|
| 67 | + function resolveKineticTiming(cueTags) { |
| 68 | + if (Array.isArray(cueTags) && cueTags.length > 0) { |
| 69 | + for (const candidate of KINETIC_PRIORITY) { |
| 70 | + if (cueTags.includes(candidate) && KINETIC_TIMING[candidate]) { |
| 71 | + return KINETIC_TIMING[candidate]; |
| 72 | + } |
| 73 | + } |
| 74 | + } |
| 75 | + return KINETIC_DEFAULT; |
| 76 | + } |
| 77 | + |
| 78 | + function resolveKineticDuration(cueTags, durationMs, playbackRate) { |
| 79 | + const timing = resolveKineticTiming(cueTags); |
| 80 | + const safeDuration = Number(durationMs) > 0 ? Number(durationMs) : 400; |
| 81 | + const safePlaybackRate = Number(playbackRate) > 0 ? Number(playbackRate) : 1; |
| 82 | + const adjustedDuration = safeDuration / safePlaybackRate; |
| 83 | + const raw = Math.round(adjustedDuration * timing.ratio); |
| 84 | + return Math.max(timing.floor, Math.min(timing.cap, raw)); |
| 85 | + } |
| 86 | + |
| 87 | + function clearWordEnvelopes() { |
| 88 | + for (const word of document.querySelectorAll(`.${ACTIVE_WORD_CLASS}`)) { |
| 89 | + if (!(word instanceof HTMLElement)) { |
| 90 | + continue; |
| 91 | + } |
| 92 | + |
| 93 | + word.classList.remove(ACTIVE_WORD_CLASS); |
| 94 | + word.style.removeProperty("--rd-kinetic-duration"); |
| 95 | + word.style.removeProperty("--rd-beam-duration"); |
| 96 | + } |
| 97 | + } |
| 98 | + |
44 | 99 | function resolveLensCharacter(cueTags) { |
45 | 100 | if (Array.isArray(cueTags) && cueTags.length > 0) { |
46 | 101 | for (const candidate of LENS_CUE_PRIORITY) { |
|
103 | 158 | } |
104 | 159 |
|
105 | 160 | window[kineticReaderNamespace] = { |
106 | | - // Kept as an API surface for `ActivateReaderWordAsync` in C#. |
107 | | - // Cue kinetics are CSS-only now, so this is a no-op beyond |
108 | | - // giving the C# side a single awaitable round-trip for the |
109 | | - // word-activation lifecycle. |
110 | | - activateWord() { |
111 | | - /* CSS @keyframes handles it */ |
| 161 | + // Restart the active word's CSS envelope with runtime-derived |
| 162 | + // timing so cue motion tracks the reader loop instead of a |
| 163 | + // fixed stylesheet duration. |
| 164 | + activateWord(durationMs, cueTags, playbackRate) { |
| 165 | + const word = document.querySelector(ACTIVE_WORD_SELECTOR); |
| 166 | + if (!(word instanceof HTMLElement)) { |
| 167 | + clearWordEnvelopes(); |
| 168 | + return; |
| 169 | + } |
| 170 | + |
| 171 | + const kineticDuration = resolveKineticDuration(cueTags, durationMs, playbackRate); |
| 172 | + const beamDuration = Math.max(160, Math.round(kineticDuration * 0.92)); |
| 173 | + |
| 174 | + clearWordEnvelopes(); |
| 175 | + word.style.setProperty("--rd-kinetic-duration", `${kineticDuration}ms`); |
| 176 | + word.style.setProperty("--rd-beam-duration", `${beamDuration}ms`); |
| 177 | + // Reflow between remove/add guarantees the CSS animation |
| 178 | + // restarts even when the same DOM node becomes active again. |
| 179 | + void word.offsetWidth; |
| 180 | + word.classList.add(ACTIVE_WORD_CLASS); |
112 | 181 | }, |
113 | 182 |
|
114 | 183 | // Fade every focus lens out. Called on playback stop / reset. |
115 | 184 | clearAll() { |
| 185 | + clearWordEnvelopes(); |
116 | 186 | for (const lens of document.querySelectorAll(".rd-focus-lens")) { |
117 | 187 | lens.classList.remove("rd-focus-lens-active"); |
118 | 188 | } |
|
0 commit comments