@scalemule/nextjs 0.1.43 → 0.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/audio.css CHANGED
@@ -131,7 +131,8 @@
131
131
  gap: 8px;
132
132
  }
133
133
  .sm-audio__speed,
134
- .sm-audio__refresh {
134
+ .sm-audio__refresh,
135
+ .sm-audio__highlight {
135
136
  min-width: 44px;
136
137
  min-height: 44px;
137
138
  padding: 6px 10px;
@@ -141,6 +142,24 @@
141
142
  color: var(--sm-audio-ink);
142
143
  font-weight: 600 !important;
143
144
  }
145
+ .sm-audio__highlight {
146
+ display: grid;
147
+ place-items: center;
148
+ }
149
+ .sm-audio__highlight svg {
150
+ width: 18px;
151
+ height: 18px;
152
+ fill: currentColor;
153
+ }
154
+ .sm-audio__highlight--on {
155
+ background: var(--sm-audio-accent);
156
+ border-color: var(--sm-audio-accent);
157
+ color: #fff;
158
+ }
159
+ .sm-audio__highlight:disabled {
160
+ opacity: 0.6;
161
+ cursor: wait;
162
+ }
144
163
  .sm-audio__refresh:disabled {
145
164
  opacity: 0.6;
146
165
  cursor: wait;
@@ -204,3 +223,10 @@
204
223
  flex-basis: 120px;
205
224
  }
206
225
  }
226
+ ::highlight(sm-narration-sentence) {
227
+ background-color: var(--sm-narration-sentence, rgba(36, 99, 70, 0.12));
228
+ }
229
+ ::highlight(sm-narration-word) {
230
+ background-color: var(--sm-narration-word, rgba(36, 99, 70, 0.32));
231
+ color: inherit;
232
+ }
package/dist/audio.d.mts CHANGED
@@ -7,6 +7,22 @@ interface AudioPlayerSource {
7
7
  expires_at?: string | null;
8
8
  waveform_peaks?: number[] | null;
9
9
  ai_generated?: boolean;
10
+ /** Whether narration word timings exist for this recording. */
11
+ has_word_timings?: boolean;
12
+ }
13
+ /**
14
+ * Follow-along highlighting for article narration. When set (and the
15
+ * recording has word timings), the player shows a highlight toggle —
16
+ * off by default; the reader's choice persists per browser. Enabled,
17
+ * the word and sentence being read are painted via the CSS Custom
18
+ * Highlight API on the element `targetId`, and the page scrolls along
19
+ * gently. `timingsUrl` is fetched lazily on first enable and must
20
+ * return the timings JSON (raw or in a {timings}/{data:{timings}}
21
+ * envelope).
22
+ */
23
+ interface AudioPlayerNarration {
24
+ timingsUrl: string;
25
+ targetId: string;
10
26
  }
11
27
  type AudioPlayerVariant = 'waveform' | 'compact' | 'inline';
12
28
  interface AudioPlayerProps {
@@ -17,6 +33,7 @@ interface AudioPlayerProps {
17
33
  label?: string;
18
34
  className?: string;
19
35
  style?: CSSProperties;
36
+ narration?: AudioPlayerNarration;
20
37
  /**
21
38
  * Defaults to none so a list does not download every recording.
22
39
  * A player that does not already know its length uses metadata anyway,
@@ -34,4 +51,28 @@ interface AudioPlayerProps {
34
51
  /** Three layouts, one playback controller. No provider, credentials, polling, or Next.js runtime imports. */
35
52
  declare function AudioPlayer(props: AudioPlayerProps): react_jsx_runtime.JSX.Element | null;
36
53
 
37
- export { AudioPlayer, type AudioPlayerProps, type AudioPlayerSource, type AudioPlayerVariant };
54
+ /**
55
+ * Follow-along narration highlighting.
56
+ *
57
+ * The platform's TTS pipeline stores word timings with every narration
58
+ * it synthesizes: `{version: 1, duration_ms, words}` where each word is
59
+ * `[text, start_ms, end_ms, sentence_index]` over the narration script
60
+ * (the article's markdown rendered to plain text). The article body a
61
+ * site renders is HTML from that same markdown, so the two word
62
+ * sequences correspond nearly one-to-one; this module aligns them and
63
+ * paints the spoken word and sentence with the CSS Custom Highlight
64
+ * API — no DOM mutation, no per-word spans.
65
+ *
66
+ * Everything degrades to nothing: unsupported browser (no
67
+ * `CSS.highlights`), missing timings, or a body that no longer matches
68
+ * the narration simply means no highlight.
69
+ */
70
+ interface NarrationTimings {
71
+ version: number;
72
+ duration_ms?: number;
73
+ /** [text, start_ms, end_ms, sentence_index] */
74
+ words: [string, number, number, number][];
75
+ }
76
+ declare function narrationHighlightSupported(): boolean;
77
+
78
+ export { AudioPlayer, type AudioPlayerNarration, type AudioPlayerProps, type AudioPlayerSource, type AudioPlayerVariant, type NarrationTimings, narrationHighlightSupported };
package/dist/audio.d.ts CHANGED
@@ -7,6 +7,22 @@ interface AudioPlayerSource {
7
7
  expires_at?: string | null;
8
8
  waveform_peaks?: number[] | null;
9
9
  ai_generated?: boolean;
10
+ /** Whether narration word timings exist for this recording. */
11
+ has_word_timings?: boolean;
12
+ }
13
+ /**
14
+ * Follow-along highlighting for article narration. When set (and the
15
+ * recording has word timings), the player shows a highlight toggle —
16
+ * off by default; the reader's choice persists per browser. Enabled,
17
+ * the word and sentence being read are painted via the CSS Custom
18
+ * Highlight API on the element `targetId`, and the page scrolls along
19
+ * gently. `timingsUrl` is fetched lazily on first enable and must
20
+ * return the timings JSON (raw or in a {timings}/{data:{timings}}
21
+ * envelope).
22
+ */
23
+ interface AudioPlayerNarration {
24
+ timingsUrl: string;
25
+ targetId: string;
10
26
  }
11
27
  type AudioPlayerVariant = 'waveform' | 'compact' | 'inline';
12
28
  interface AudioPlayerProps {
@@ -17,6 +33,7 @@ interface AudioPlayerProps {
17
33
  label?: string;
18
34
  className?: string;
19
35
  style?: CSSProperties;
36
+ narration?: AudioPlayerNarration;
20
37
  /**
21
38
  * Defaults to none so a list does not download every recording.
22
39
  * A player that does not already know its length uses metadata anyway,
@@ -34,4 +51,28 @@ interface AudioPlayerProps {
34
51
  /** Three layouts, one playback controller. No provider, credentials, polling, or Next.js runtime imports. */
35
52
  declare function AudioPlayer(props: AudioPlayerProps): react_jsx_runtime.JSX.Element | null;
36
53
 
37
- export { AudioPlayer, type AudioPlayerProps, type AudioPlayerSource, type AudioPlayerVariant };
54
+ /**
55
+ * Follow-along narration highlighting.
56
+ *
57
+ * The platform's TTS pipeline stores word timings with every narration
58
+ * it synthesizes: `{version: 1, duration_ms, words}` where each word is
59
+ * `[text, start_ms, end_ms, sentence_index]` over the narration script
60
+ * (the article's markdown rendered to plain text). The article body a
61
+ * site renders is HTML from that same markdown, so the two word
62
+ * sequences correspond nearly one-to-one; this module aligns them and
63
+ * paints the spoken word and sentence with the CSS Custom Highlight
64
+ * API — no DOM mutation, no per-word spans.
65
+ *
66
+ * Everything degrades to nothing: unsupported browser (no
67
+ * `CSS.highlights`), missing timings, or a body that no longer matches
68
+ * the narration simply means no highlight.
69
+ */
70
+ interface NarrationTimings {
71
+ version: number;
72
+ duration_ms?: number;
73
+ /** [text, start_ms, end_ms, sentence_index] */
74
+ words: [string, number, number, number][];
75
+ }
76
+ declare function narrationHighlightSupported(): boolean;
77
+
78
+ export { AudioPlayer, type AudioPlayerNarration, type AudioPlayerProps, type AudioPlayerSource, type AudioPlayerVariant, type NarrationTimings, narrationHighlightSupported };
package/dist/audio.js CHANGED
@@ -22,15 +22,210 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
22
22
  // src/audio.ts
23
23
  var audio_exports = {};
24
24
  __export(audio_exports, {
25
- AudioPlayer: () => AudioPlayer
25
+ AudioPlayer: () => AudioPlayer,
26
+ narrationHighlightSupported: () => narrationHighlightSupported
26
27
  });
27
28
  module.exports = __toCommonJS(audio_exports);
28
29
 
29
30
  // src/components/audio-player.tsx
30
31
  var import_react = require("react");
32
+
33
+ // src/components/narration-highlight.ts
34
+ var WORD_HIGHLIGHT = "sm-narration-word";
35
+ var SENTENCE_HIGHLIGHT = "sm-narration-sentence";
36
+ function normalizeWord(raw) {
37
+ return raw.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
38
+ }
39
+ var SKIPPED_TAGS = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE"]);
40
+ function collectDomWords(root) {
41
+ const doc = root.ownerDocument ?? root;
42
+ const walker = doc.createTreeWalker(root, 4, {
43
+ acceptNode(node) {
44
+ const parent = node.parentElement;
45
+ if (parent && SKIPPED_TAGS.has(parent.tagName)) return 2;
46
+ return 1;
47
+ }
48
+ });
49
+ const out = [];
50
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
51
+ const text = node.data;
52
+ const matcher = /\S+/g;
53
+ for (let m = matcher.exec(text); m; m = matcher.exec(text)) {
54
+ const norm = normalizeWord(m[0]);
55
+ if (norm) {
56
+ out.push({ node, start: m.index, end: m.index + m[0].length, norm });
57
+ }
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+ function alignWords(timingNorms, domNorms, window2 = 6) {
63
+ const out = new Array(timingNorms.length).fill(-1);
64
+ let j = 0;
65
+ for (let i = 0; i < timingNorms.length; i++) {
66
+ const target = timingNorms[i];
67
+ if (!target) continue;
68
+ let found = -1;
69
+ for (let k = j; k < Math.min(domNorms.length, j + window2); k++) {
70
+ if (domNorms[k] === target) {
71
+ found = k;
72
+ break;
73
+ }
74
+ }
75
+ if (found === -1) {
76
+ continue;
77
+ }
78
+ out[i] = found;
79
+ j = found + 1;
80
+ }
81
+ return out;
82
+ }
83
+ function wordIndexAt(words, timeMs) {
84
+ let lo = 0;
85
+ let hi = words.length - 1;
86
+ let ans = -1;
87
+ while (lo <= hi) {
88
+ const mid = lo + hi >> 1;
89
+ if (words[mid][1] <= timeMs) {
90
+ ans = mid;
91
+ lo = mid + 1;
92
+ } else {
93
+ hi = mid - 1;
94
+ }
95
+ }
96
+ return ans;
97
+ }
98
+ function narrationHighlightSupported() {
99
+ return typeof window !== "undefined" && typeof CSS !== "undefined" && "highlights" in CSS && typeof globalThis.Highlight === "function";
100
+ }
101
+ var NarrationHighlighter = class {
102
+ constructor(root, timings) {
103
+ this.currentWord = -2;
104
+ this.currentSentence = -2;
105
+ this.follow = true;
106
+ this.detachUserScroll = null;
107
+ this.words = timings.words;
108
+ const domWords = collectDomWords(root);
109
+ const matches = alignWords(
110
+ this.words.map((w) => normalizeWord(w[0])),
111
+ domWords.map((w) => w.norm)
112
+ );
113
+ const doc = root.ownerDocument;
114
+ this.ranges = matches.map((m) => {
115
+ if (m === -1) return null;
116
+ const w = domWords[m];
117
+ try {
118
+ const range = doc.createRange();
119
+ range.setStart(w.node, w.start);
120
+ range.setEnd(w.node, w.end);
121
+ return range;
122
+ } catch {
123
+ return null;
124
+ }
125
+ });
126
+ this.sentenceRanges = /* @__PURE__ */ new Map();
127
+ this.words.forEach((w, i) => {
128
+ const range = this.ranges[i];
129
+ if (!range) return;
130
+ const list = this.sentenceRanges.get(w[3]);
131
+ if (list) list.push(range);
132
+ else this.sentenceRanges.set(w[3], [range]);
133
+ });
134
+ this.reducedMotion = typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
135
+ this.attachUserScroll(doc);
136
+ }
137
+ /** Fraction of narration words found in the article body. A low
138
+ * ratio means the body diverged from the script; callers may prefer
139
+ * to hide the toggle below ~0.5. */
140
+ matchRatio() {
141
+ if (this.ranges.length === 0) return 0;
142
+ return this.ranges.filter(Boolean).length / this.ranges.length;
143
+ }
144
+ attachUserScroll(doc) {
145
+ const stopFollowing = () => {
146
+ this.follow = false;
147
+ };
148
+ const opts = { passive: true };
149
+ doc.addEventListener("wheel", stopFollowing, opts);
150
+ doc.addEventListener("touchmove", stopFollowing, opts);
151
+ this.detachUserScroll = () => {
152
+ doc.removeEventListener("wheel", stopFollowing, opts);
153
+ doc.removeEventListener("touchmove", stopFollowing, opts);
154
+ };
155
+ }
156
+ /** Re-engage auto-scroll (the reader pressed the toggle or sought). */
157
+ resumeFollowing() {
158
+ this.follow = true;
159
+ this.currentSentence = -2;
160
+ }
161
+ update(timeMs) {
162
+ if (!narrationHighlightSupported()) return;
163
+ const idx = wordIndexAt(this.words, timeMs);
164
+ if (idx === this.currentWord) return;
165
+ this.currentWord = idx;
166
+ const registry = CSS.highlights;
167
+ if (idx < 0) {
168
+ registry.delete(WORD_HIGHLIGHT);
169
+ registry.delete(SENTENCE_HIGHLIGHT);
170
+ this.currentSentence = -2;
171
+ return;
172
+ }
173
+ const wordRange = this.ranges[idx];
174
+ if (wordRange) registry.set(WORD_HIGHLIGHT, new Highlight(wordRange));
175
+ else registry.delete(WORD_HIGHLIGHT);
176
+ const sentence = this.words[idx][3];
177
+ if (sentence !== this.currentSentence) {
178
+ this.currentSentence = sentence;
179
+ const ranges = this.sentenceRanges.get(sentence) ?? [];
180
+ if (ranges.length > 0) {
181
+ registry.set(SENTENCE_HIGHLIGHT, new Highlight(...ranges));
182
+ this.scrollTo(ranges[0]);
183
+ } else {
184
+ registry.delete(SENTENCE_HIGHLIGHT);
185
+ }
186
+ }
187
+ }
188
+ scrollTo(range) {
189
+ if (!this.follow) return;
190
+ try {
191
+ const rect = range.getBoundingClientRect();
192
+ const viewport = window.innerHeight || 0;
193
+ if (rect.top < viewport * 0.15 || rect.bottom > viewport * 0.7) {
194
+ const target = window.scrollY + rect.top - viewport * 0.3;
195
+ window.scrollTo({ top: target, behavior: this.reducedMotion ? "auto" : "smooth" });
196
+ }
197
+ } catch {
198
+ }
199
+ }
200
+ clear() {
201
+ if (!narrationHighlightSupported()) return;
202
+ const registry = CSS.highlights;
203
+ registry.delete(WORD_HIGHLIGHT);
204
+ registry.delete(SENTENCE_HIGHLIGHT);
205
+ this.currentWord = -2;
206
+ this.currentSentence = -2;
207
+ }
208
+ destroy() {
209
+ this.clear();
210
+ this.detachUserScroll?.();
211
+ this.detachUserScroll = null;
212
+ }
213
+ };
214
+ function parseTimingsPayload(body) {
215
+ const candidate = body?.timings ?? body?.data?.timings ?? body;
216
+ const t = candidate;
217
+ if (!t || t.version !== 1 || !Array.isArray(t.words) || t.words.length === 0) return null;
218
+ const valid = t.words.every(
219
+ (w) => Array.isArray(w) && w.length === 4 && typeof w[0] === "string" && typeof w[1] === "number" && typeof w[2] === "number" && typeof w[3] === "number"
220
+ );
221
+ return valid ? t : null;
222
+ }
223
+
224
+ // src/components/audio-player.tsx
31
225
  var import_jsx_runtime = require("react/jsx-runtime");
32
226
  var SPEEDS = [1, 1.25, 1.5, 2, 3];
33
227
  var PLAY_EVENT = "scalemule:audio:play";
228
+ var HIGHLIGHT_PREF_KEY = "scalemule:audio:highlight";
34
229
  var positive = (n) => n != null && Number.isFinite(n) && n > 0 ? n : 0;
35
230
  var durationOf = (audio) => positive(audio.duration_ms) / 1e3;
36
231
  function time(seconds) {
@@ -52,7 +247,8 @@ function PlayerSession({
52
247
  showRefreshButton = false,
53
248
  onPlaybackError,
54
249
  playbackRateStorageKey = "scalemule:audio:playback-rate",
55
- exclusivePlayback = true
250
+ exclusivePlayback = true,
251
+ narration
56
252
  }) {
57
253
  const media = (0, import_react.useRef)(null);
58
254
  const alive = (0, import_react.useRef)(true);
@@ -71,6 +267,91 @@ function PlayerSession({
71
267
  const sliderId = (0, import_react.useId)();
72
268
  const labelId = (0, import_react.useId)();
73
269
  const rate = (0, import_react.useRef)(1);
270
+ const narrationOffered = !!narration && audio.has_word_timings !== false && narrationHighlightSupported();
271
+ const [highlightOn, setHighlightOn] = (0, import_react.useState)(false);
272
+ const [highlightBusy, setHighlightBusy] = (0, import_react.useState)(false);
273
+ const highlighter = (0, import_react.useRef)(null);
274
+ const highlightAbort = (0, import_react.useRef)(null);
275
+ (0, import_react.useEffect)(() => {
276
+ if (!narrationOffered) return;
277
+ try {
278
+ if (window.localStorage.getItem(HIGHLIGHT_PREF_KEY) === "1") {
279
+ void enableHighlight(false);
280
+ }
281
+ } catch {
282
+ }
283
+ }, [narrationOffered]);
284
+ (0, import_react.useEffect)(() => {
285
+ return () => {
286
+ highlightAbort.current?.abort();
287
+ highlighter.current?.destroy();
288
+ highlighter.current = null;
289
+ };
290
+ }, []);
291
+ async function enableHighlight(persist) {
292
+ if (!narration) return;
293
+ if (persist) {
294
+ try {
295
+ window.localStorage.setItem(HIGHLIGHT_PREF_KEY, "1");
296
+ } catch {
297
+ }
298
+ }
299
+ if (highlighter.current) {
300
+ highlighter.current.resumeFollowing();
301
+ setHighlightOn(true);
302
+ return;
303
+ }
304
+ if (highlightBusy) return;
305
+ const controller = new AbortController();
306
+ highlightAbort.current = controller;
307
+ setHighlightBusy(true);
308
+ try {
309
+ const resp = await fetch(narration.timingsUrl, { signal: controller.signal });
310
+ if (!resp.ok) throw new Error(`timings ${resp.status}`);
311
+ const timings = parseTimingsPayload(await resp.json());
312
+ const target = document.getElementById(narration.targetId);
313
+ if (!timings || !target) throw new Error("timings or target missing");
314
+ const instance = new NarrationHighlighter(target, timings);
315
+ if (instance.matchRatio() < 0.5) {
316
+ instance.destroy();
317
+ throw new Error("article text does not match narration");
318
+ }
319
+ highlighter.current = instance;
320
+ setHighlightOn(true);
321
+ const element = media.current;
322
+ if (element) instance.update(element.currentTime * 1e3);
323
+ } catch {
324
+ setHighlightOn(false);
325
+ } finally {
326
+ if (highlightAbort.current === controller) highlightAbort.current = null;
327
+ setHighlightBusy(false);
328
+ }
329
+ }
330
+ function disableHighlight() {
331
+ try {
332
+ window.localStorage.setItem(HIGHLIGHT_PREF_KEY, "0");
333
+ } catch {
334
+ }
335
+ highlightAbort.current?.abort();
336
+ highlighter.current?.clear();
337
+ setHighlightOn(false);
338
+ }
339
+ (0, import_react.useEffect)(() => {
340
+ if (!highlightOn || !highlighter.current) return;
341
+ const element = media.current;
342
+ if (!element) return;
343
+ if (!playing) {
344
+ highlighter.current.update(element.currentTime * 1e3);
345
+ return;
346
+ }
347
+ let frame = 0;
348
+ const tick = () => {
349
+ highlighter.current?.update(element.currentTime * 1e3);
350
+ frame = requestAnimationFrame(tick);
351
+ };
352
+ frame = requestAnimationFrame(tick);
353
+ return () => cancelAnimationFrame(frame);
354
+ }, [highlightOn, playing, position]);
74
355
  (0, import_react.useEffect)(() => {
75
356
  alive.current = true;
76
357
  const element = media.current;
@@ -202,6 +483,7 @@ function PlayerSession({
202
483
  media.current.currentTime = target;
203
484
  resume.current = target;
204
485
  setPosition(target);
486
+ highlighter.current?.resumeFollowing();
205
487
  } catch {
206
488
  }
207
489
  }
@@ -353,6 +635,19 @@ function PlayerSession({
353
635
  )
354
636
  ] }),
355
637
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "sm-audio__actions", children: [
638
+ narrationOffered && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
639
+ "button",
640
+ {
641
+ type: "button",
642
+ className: `sm-audio__highlight${highlightOn ? " sm-audio__highlight--on" : ""}`,
643
+ "aria-pressed": highlightOn,
644
+ disabled: highlightBusy,
645
+ onClick: () => highlightOn ? disableHighlight() : void enableHighlight(true),
646
+ "aria-label": highlightOn ? "Turn off follow-along highlighting" : "Highlight the text as it is read",
647
+ title: highlightOn ? "Turn off follow-along highlighting" : "Highlight the text as it is read",
648
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M4 5h16v2.5H4zm0 5.75h16v2.5H4zM4 16.5h9v2.5H4z" }) })
649
+ }
650
+ ),
356
651
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
357
652
  "button",
358
653
  {
@@ -384,5 +679,6 @@ function PlayerSession({
384
679
  }
385
680
  // Annotate the CommonJS export names for ESM import in node:
386
681
  0 && (module.exports = {
387
- AudioPlayer
682
+ AudioPlayer,
683
+ narrationHighlightSupported
388
684
  });
package/dist/audio.mjs CHANGED
@@ -9,9 +9,203 @@ import {
9
9
  useRef,
10
10
  useState
11
11
  } from "react";
12
+
13
+ // src/components/narration-highlight.ts
14
+ var WORD_HIGHLIGHT = "sm-narration-word";
15
+ var SENTENCE_HIGHLIGHT = "sm-narration-sentence";
16
+ function normalizeWord(raw) {
17
+ return raw.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
18
+ }
19
+ var SKIPPED_TAGS = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE"]);
20
+ function collectDomWords(root) {
21
+ const doc = root.ownerDocument ?? root;
22
+ const walker = doc.createTreeWalker(root, 4, {
23
+ acceptNode(node) {
24
+ const parent = node.parentElement;
25
+ if (parent && SKIPPED_TAGS.has(parent.tagName)) return 2;
26
+ return 1;
27
+ }
28
+ });
29
+ const out = [];
30
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
31
+ const text = node.data;
32
+ const matcher = /\S+/g;
33
+ for (let m = matcher.exec(text); m; m = matcher.exec(text)) {
34
+ const norm = normalizeWord(m[0]);
35
+ if (norm) {
36
+ out.push({ node, start: m.index, end: m.index + m[0].length, norm });
37
+ }
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+ function alignWords(timingNorms, domNorms, window2 = 6) {
43
+ const out = new Array(timingNorms.length).fill(-1);
44
+ let j = 0;
45
+ for (let i = 0; i < timingNorms.length; i++) {
46
+ const target = timingNorms[i];
47
+ if (!target) continue;
48
+ let found = -1;
49
+ for (let k = j; k < Math.min(domNorms.length, j + window2); k++) {
50
+ if (domNorms[k] === target) {
51
+ found = k;
52
+ break;
53
+ }
54
+ }
55
+ if (found === -1) {
56
+ continue;
57
+ }
58
+ out[i] = found;
59
+ j = found + 1;
60
+ }
61
+ return out;
62
+ }
63
+ function wordIndexAt(words, timeMs) {
64
+ let lo = 0;
65
+ let hi = words.length - 1;
66
+ let ans = -1;
67
+ while (lo <= hi) {
68
+ const mid = lo + hi >> 1;
69
+ if (words[mid][1] <= timeMs) {
70
+ ans = mid;
71
+ lo = mid + 1;
72
+ } else {
73
+ hi = mid - 1;
74
+ }
75
+ }
76
+ return ans;
77
+ }
78
+ function narrationHighlightSupported() {
79
+ return typeof window !== "undefined" && typeof CSS !== "undefined" && "highlights" in CSS && typeof globalThis.Highlight === "function";
80
+ }
81
+ var NarrationHighlighter = class {
82
+ constructor(root, timings) {
83
+ this.currentWord = -2;
84
+ this.currentSentence = -2;
85
+ this.follow = true;
86
+ this.detachUserScroll = null;
87
+ this.words = timings.words;
88
+ const domWords = collectDomWords(root);
89
+ const matches = alignWords(
90
+ this.words.map((w) => normalizeWord(w[0])),
91
+ domWords.map((w) => w.norm)
92
+ );
93
+ const doc = root.ownerDocument;
94
+ this.ranges = matches.map((m) => {
95
+ if (m === -1) return null;
96
+ const w = domWords[m];
97
+ try {
98
+ const range = doc.createRange();
99
+ range.setStart(w.node, w.start);
100
+ range.setEnd(w.node, w.end);
101
+ return range;
102
+ } catch {
103
+ return null;
104
+ }
105
+ });
106
+ this.sentenceRanges = /* @__PURE__ */ new Map();
107
+ this.words.forEach((w, i) => {
108
+ const range = this.ranges[i];
109
+ if (!range) return;
110
+ const list = this.sentenceRanges.get(w[3]);
111
+ if (list) list.push(range);
112
+ else this.sentenceRanges.set(w[3], [range]);
113
+ });
114
+ this.reducedMotion = typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
115
+ this.attachUserScroll(doc);
116
+ }
117
+ /** Fraction of narration words found in the article body. A low
118
+ * ratio means the body diverged from the script; callers may prefer
119
+ * to hide the toggle below ~0.5. */
120
+ matchRatio() {
121
+ if (this.ranges.length === 0) return 0;
122
+ return this.ranges.filter(Boolean).length / this.ranges.length;
123
+ }
124
+ attachUserScroll(doc) {
125
+ const stopFollowing = () => {
126
+ this.follow = false;
127
+ };
128
+ const opts = { passive: true };
129
+ doc.addEventListener("wheel", stopFollowing, opts);
130
+ doc.addEventListener("touchmove", stopFollowing, opts);
131
+ this.detachUserScroll = () => {
132
+ doc.removeEventListener("wheel", stopFollowing, opts);
133
+ doc.removeEventListener("touchmove", stopFollowing, opts);
134
+ };
135
+ }
136
+ /** Re-engage auto-scroll (the reader pressed the toggle or sought). */
137
+ resumeFollowing() {
138
+ this.follow = true;
139
+ this.currentSentence = -2;
140
+ }
141
+ update(timeMs) {
142
+ if (!narrationHighlightSupported()) return;
143
+ const idx = wordIndexAt(this.words, timeMs);
144
+ if (idx === this.currentWord) return;
145
+ this.currentWord = idx;
146
+ const registry = CSS.highlights;
147
+ if (idx < 0) {
148
+ registry.delete(WORD_HIGHLIGHT);
149
+ registry.delete(SENTENCE_HIGHLIGHT);
150
+ this.currentSentence = -2;
151
+ return;
152
+ }
153
+ const wordRange = this.ranges[idx];
154
+ if (wordRange) registry.set(WORD_HIGHLIGHT, new Highlight(wordRange));
155
+ else registry.delete(WORD_HIGHLIGHT);
156
+ const sentence = this.words[idx][3];
157
+ if (sentence !== this.currentSentence) {
158
+ this.currentSentence = sentence;
159
+ const ranges = this.sentenceRanges.get(sentence) ?? [];
160
+ if (ranges.length > 0) {
161
+ registry.set(SENTENCE_HIGHLIGHT, new Highlight(...ranges));
162
+ this.scrollTo(ranges[0]);
163
+ } else {
164
+ registry.delete(SENTENCE_HIGHLIGHT);
165
+ }
166
+ }
167
+ }
168
+ scrollTo(range) {
169
+ if (!this.follow) return;
170
+ try {
171
+ const rect = range.getBoundingClientRect();
172
+ const viewport = window.innerHeight || 0;
173
+ if (rect.top < viewport * 0.15 || rect.bottom > viewport * 0.7) {
174
+ const target = window.scrollY + rect.top - viewport * 0.3;
175
+ window.scrollTo({ top: target, behavior: this.reducedMotion ? "auto" : "smooth" });
176
+ }
177
+ } catch {
178
+ }
179
+ }
180
+ clear() {
181
+ if (!narrationHighlightSupported()) return;
182
+ const registry = CSS.highlights;
183
+ registry.delete(WORD_HIGHLIGHT);
184
+ registry.delete(SENTENCE_HIGHLIGHT);
185
+ this.currentWord = -2;
186
+ this.currentSentence = -2;
187
+ }
188
+ destroy() {
189
+ this.clear();
190
+ this.detachUserScroll?.();
191
+ this.detachUserScroll = null;
192
+ }
193
+ };
194
+ function parseTimingsPayload(body) {
195
+ const candidate = body?.timings ?? body?.data?.timings ?? body;
196
+ const t = candidate;
197
+ if (!t || t.version !== 1 || !Array.isArray(t.words) || t.words.length === 0) return null;
198
+ const valid = t.words.every(
199
+ (w) => Array.isArray(w) && w.length === 4 && typeof w[0] === "string" && typeof w[1] === "number" && typeof w[2] === "number" && typeof w[3] === "number"
200
+ );
201
+ return valid ? t : null;
202
+ }
203
+
204
+ // src/components/audio-player.tsx
12
205
  import { jsx, jsxs } from "react/jsx-runtime";
13
206
  var SPEEDS = [1, 1.25, 1.5, 2, 3];
14
207
  var PLAY_EVENT = "scalemule:audio:play";
208
+ var HIGHLIGHT_PREF_KEY = "scalemule:audio:highlight";
15
209
  var positive = (n) => n != null && Number.isFinite(n) && n > 0 ? n : 0;
16
210
  var durationOf = (audio) => positive(audio.duration_ms) / 1e3;
17
211
  function time(seconds) {
@@ -33,7 +227,8 @@ function PlayerSession({
33
227
  showRefreshButton = false,
34
228
  onPlaybackError,
35
229
  playbackRateStorageKey = "scalemule:audio:playback-rate",
36
- exclusivePlayback = true
230
+ exclusivePlayback = true,
231
+ narration
37
232
  }) {
38
233
  const media = useRef(null);
39
234
  const alive = useRef(true);
@@ -52,6 +247,91 @@ function PlayerSession({
52
247
  const sliderId = useId();
53
248
  const labelId = useId();
54
249
  const rate = useRef(1);
250
+ const narrationOffered = !!narration && audio.has_word_timings !== false && narrationHighlightSupported();
251
+ const [highlightOn, setHighlightOn] = useState(false);
252
+ const [highlightBusy, setHighlightBusy] = useState(false);
253
+ const highlighter = useRef(null);
254
+ const highlightAbort = useRef(null);
255
+ useEffect(() => {
256
+ if (!narrationOffered) return;
257
+ try {
258
+ if (window.localStorage.getItem(HIGHLIGHT_PREF_KEY) === "1") {
259
+ void enableHighlight(false);
260
+ }
261
+ } catch {
262
+ }
263
+ }, [narrationOffered]);
264
+ useEffect(() => {
265
+ return () => {
266
+ highlightAbort.current?.abort();
267
+ highlighter.current?.destroy();
268
+ highlighter.current = null;
269
+ };
270
+ }, []);
271
+ async function enableHighlight(persist) {
272
+ if (!narration) return;
273
+ if (persist) {
274
+ try {
275
+ window.localStorage.setItem(HIGHLIGHT_PREF_KEY, "1");
276
+ } catch {
277
+ }
278
+ }
279
+ if (highlighter.current) {
280
+ highlighter.current.resumeFollowing();
281
+ setHighlightOn(true);
282
+ return;
283
+ }
284
+ if (highlightBusy) return;
285
+ const controller = new AbortController();
286
+ highlightAbort.current = controller;
287
+ setHighlightBusy(true);
288
+ try {
289
+ const resp = await fetch(narration.timingsUrl, { signal: controller.signal });
290
+ if (!resp.ok) throw new Error(`timings ${resp.status}`);
291
+ const timings = parseTimingsPayload(await resp.json());
292
+ const target = document.getElementById(narration.targetId);
293
+ if (!timings || !target) throw new Error("timings or target missing");
294
+ const instance = new NarrationHighlighter(target, timings);
295
+ if (instance.matchRatio() < 0.5) {
296
+ instance.destroy();
297
+ throw new Error("article text does not match narration");
298
+ }
299
+ highlighter.current = instance;
300
+ setHighlightOn(true);
301
+ const element = media.current;
302
+ if (element) instance.update(element.currentTime * 1e3);
303
+ } catch {
304
+ setHighlightOn(false);
305
+ } finally {
306
+ if (highlightAbort.current === controller) highlightAbort.current = null;
307
+ setHighlightBusy(false);
308
+ }
309
+ }
310
+ function disableHighlight() {
311
+ try {
312
+ window.localStorage.setItem(HIGHLIGHT_PREF_KEY, "0");
313
+ } catch {
314
+ }
315
+ highlightAbort.current?.abort();
316
+ highlighter.current?.clear();
317
+ setHighlightOn(false);
318
+ }
319
+ useEffect(() => {
320
+ if (!highlightOn || !highlighter.current) return;
321
+ const element = media.current;
322
+ if (!element) return;
323
+ if (!playing) {
324
+ highlighter.current.update(element.currentTime * 1e3);
325
+ return;
326
+ }
327
+ let frame = 0;
328
+ const tick = () => {
329
+ highlighter.current?.update(element.currentTime * 1e3);
330
+ frame = requestAnimationFrame(tick);
331
+ };
332
+ frame = requestAnimationFrame(tick);
333
+ return () => cancelAnimationFrame(frame);
334
+ }, [highlightOn, playing, position]);
55
335
  useEffect(() => {
56
336
  alive.current = true;
57
337
  const element = media.current;
@@ -183,6 +463,7 @@ function PlayerSession({
183
463
  media.current.currentTime = target;
184
464
  resume.current = target;
185
465
  setPosition(target);
466
+ highlighter.current?.resumeFollowing();
186
467
  } catch {
187
468
  }
188
469
  }
@@ -334,6 +615,19 @@ function PlayerSession({
334
615
  )
335
616
  ] }),
336
617
  /* @__PURE__ */ jsxs("div", { className: "sm-audio__actions", children: [
618
+ narrationOffered && /* @__PURE__ */ jsx(
619
+ "button",
620
+ {
621
+ type: "button",
622
+ className: `sm-audio__highlight${highlightOn ? " sm-audio__highlight--on" : ""}`,
623
+ "aria-pressed": highlightOn,
624
+ disabled: highlightBusy,
625
+ onClick: () => highlightOn ? disableHighlight() : void enableHighlight(true),
626
+ "aria-label": highlightOn ? "Turn off follow-along highlighting" : "Highlight the text as it is read",
627
+ title: highlightOn ? "Turn off follow-along highlighting" : "Highlight the text as it is read",
628
+ children: /* @__PURE__ */ jsx("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M4 5h16v2.5H4zm0 5.75h16v2.5H4zM4 16.5h9v2.5H4z" }) })
629
+ }
630
+ ),
337
631
  /* @__PURE__ */ jsxs(
338
632
  "button",
339
633
  {
@@ -364,5 +658,6 @@ function PlayerSession({
364
658
  );
365
659
  }
366
660
  export {
367
- AudioPlayer
661
+ AudioPlayer,
662
+ narrationHighlightSupported
368
663
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalemule/nextjs",
3
- "version": "0.1.43",
3
+ "version": "0.1.44",
4
4
  "description": "ScaleMule SDK for Next.js applications - authentication, storage, and user management",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",