Skip to content

Commit d204709

Browse files
committed
Polish teleprompter reader motion and test contracts
1 parent fbf0bec commit d204709

13 files changed

Lines changed: 287 additions & 259 deletions

File tree

‎src/PrompterOne.Shared/Contracts/TpsVisualCueContracts.cs‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ public static class TpsVisualCueContracts
66
public const string CueOpacityVariableName = "--tps-cue-opacity";
77
public const string CueScaleVariableName = "--tps-cue-scale";
88
public const string CueWeightVariableName = "--tps-cue-weight";
9-
public const string WordDurationVariableName = "--tps-word-duration";
109
public const string PauseDurationVariableName = "--tps-pause-duration";
1110
public const string PauseKindAttributeName = "data-pause-kind";
1211
public const string PauseKindShort = "short";

‎src/PrompterOne.Shared/Contracts/UiDomIds.cs‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@ public static class Teleprompter
9090
public const string FontValue = "rd-font-val";
9191
public const string FocalGuide = "rd-guide-h";
9292
public const string HeaderSegment = "rd-header-segment";
93-
public const string PauseFill = "rd-pause-fill";
9493
public const string ProgressFill = "rd-progress-fill";
9594
public const string SpeedValue = "rd-speed-val";
9695
public const string Stage = "rd-stage";

‎src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderPlayback.cs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ public partial class TeleprompterPage
99
{
1010
private const string AttachReaderCameraOperation = "Teleprompter camera attach";
1111
private const int MinimumReaderLoopDelayMilliseconds = 120;
12-
private const int ReaderCardTransitionMilliseconds = 760;
12+
// Keep the C# wait budget locked to `.rd-card` transition timing in
13+
// `10-reading-states.css` so card normalization never lags behind the
14+
// actual visual handoff.
15+
private const int ReaderCardTransitionMilliseconds = 700;
1316

1417
private Task DecreaseReaderPlaybackSpeedAsync() => ChangeReaderPlaybackSpeedAsync(-ReaderPlaybackSpeedStepWpm);
1518

‎src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderWordStyling.cs‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,6 @@ public partial class TeleprompterPage
3131
{
3232
var styles = new List<string>(4);
3333

34-
if (wordDurationMs > 0)
35-
{
36-
var durationSeconds = Math.Max(0.18d, wordDurationMs / 1000d);
37-
styles.Add(FormattableString.Invariant($"{TpsVisualCueContracts.WordDurationVariableName}:{durationSeconds:0.###}s;"));
38-
}
39-
4034
if (metadata is null)
4135
{
4236
return styles.Count == 0 ? null : string.Concat(styles);

‎src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.razor‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,6 @@
157157
}
158158
</div>
159159

160-
<div class="rd-pause-indicator" id="rd-pause-bar">
161-
<div class="rd-pause-fill" id="@UiDomIds.Teleprompter.PauseFill"></div>
162-
</div>
163160
</div>
164161

165162
<TeleprompterReaderTransport BlockIndicatorLabel="@BuildBlockIndicatorLabel()" CameraButtonCssClass="@BuildCameraButtonCssClass()"

‎src/PrompterOne.Shared/wwwroot/design/modules/reader/10-reading-states.css‎

Lines changed: 61 additions & 125 deletions
Large diffs are not rendered by default.

‎src/PrompterOne.Shared/wwwroot/design/modules/reader/20-controls.css‎

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,6 @@
11
/* Module: 20-controls.css */
22
/* Reader controls, guides, sliders, previews, and ambient overlays. */
33

4-
/* Next cluster */
5-
.rd-cluster-next {
6-
opacity: .1;
7-
}
8-
.rd-cluster-next p {
9-
font-size: 20px;
10-
color: var(--t3);
11-
}
12-
.rd-cluster-next .rd-cluster-emotion { margin-bottom: 10px; }
13-
144
/* Center guide — subtle horizontal line where eyes focus */
155
.rd-center-guide {
166
display: none;
@@ -61,27 +51,6 @@
6151
background: linear-gradient(to right, transparent, rgba(232,213,176,.04), transparent);
6252
}
6353

64-
/* Pause indicator */
65-
.rd-pause-indicator {
66-
position: absolute;
67-
bottom: 20%;
68-
left: 50%;
69-
transform: translateX(-50%);
70-
width: 120px;
71-
height: 3px;
72-
background: var(--gold-07);
73-
border-radius: 2px;
74-
overflow: hidden;
75-
opacity: 0;
76-
}
77-
.rd-pause-indicator.active { opacity: 1; }
78-
.rd-pause-fill {
79-
height: 100%;
80-
background: white;
81-
border-radius: 2px;
82-
width: 0%;
83-
}
84-
8554
/* Edge progress bar — full width at bottom */
8655
.rd-edge-progress {
8756
position: absolute;

‎src/PrompterOne.Shared/wwwroot/teleprompter/kinetic-reader.js‎

Lines changed: 86 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,40 @@
1-
// Kinetic reader — thin JS bridge used by Blazor playback.
1+
// Kinetic reader — JS bridge for word-envelope timing and the focus lens.
22
//
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.
98
//
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.
1311
(function () {
1412
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+
];
1538

1639
// Cue → lens transition character. Easing captures the "feel"
1740
// (snap vs glide vs linear flow); the DURATION is derived from
@@ -41,6 +64,38 @@
4164
"whisper", "soft", "calm", "sad", "fast", "aside"
4265
];
4366

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+
4499
function resolveLensCharacter(cueTags) {
45100
if (Array.isArray(cueTags) && cueTags.length > 0) {
46101
for (const candidate of LENS_CUE_PRIORITY) {
@@ -103,16 +158,31 @@
103158
}
104159

105160
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);
112181
},
113182

114183
// Fade every focus lens out. Called on playback stop / reset.
115184
clearAll() {
185+
clearWordEnvelopes();
116186
for (const lens of document.querySelectorAll(".rd-focus-lens")) {
117187
lens.classList.remove("rd-focus-lens-active");
118188
}

‎tests/PrompterOne.Web.Tests/Library/LibraryBootstrapStateTests.cs‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@ public void LibraryPage_RendersRuntimeStartupSeeds_WhenHarnessSkipsTestFixtures(
1919
Assert.NotNull(cut.FindByTestId(UiTestIds.Library.Page));
2020
Assert.NotNull(cut.FindByTestId(UiTestIds.Library.CreateScript));
2121
Assert.NotNull(cut.FindByTestId(UiTestIds.Library.FolderCreateTile));
22-
Assert.Contains(AppTestData.Scripts.DemoTitle, cut.Markup, StringComparison.Ordinal);
23-
Assert.Contains(AppTestData.Scripts.TedLeadershipTitle, cut.Markup, StringComparison.Ordinal);
2422
Assert.Contains(AppTestData.Scripts.TpsCueMatrixTitle, cut.Markup, StringComparison.Ordinal);
2523
Assert.DoesNotContain(UiTestIds.Library.FolderChips, cut.Markup, StringComparison.Ordinal);
2624
});

‎tests/PrompterOne.Web.Tests/Support/TestSupport.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ public static AppHarness Create(
162162
context.Services.AddSingleton<LearnRsvpLayoutInterop>();
163163
context.Services.AddSingleton<MicrophoneLevelInterop>();
164164
context.Services.AddSingleton<TeleprompterReaderInterop>();
165+
context.Services.AddSingleton<KineticReaderInterop>();
165166
context.Services.AddSingleton<GoLiveOutputInterop>();
166167
context.Services.AddSingleton<GoLiveOutputRuntimeService>();
167168
context.Services.AddSingleton<GoLiveRemoteSourceInterop>();

0 commit comments

Comments
 (0)