@scalemule/nextjs 0.1.42 → 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,7 +33,12 @@ interface AudioPlayerProps {
17
33
  label?: string;
18
34
  className?: string;
19
35
  style?: CSSProperties;
20
- /** Defaults to none: lists do not download every recording on page load. */
36
+ narration?: AudioPlayerNarration;
37
+ /**
38
+ * Defaults to none so a list does not download every recording.
39
+ * A player that does not already know its length uses metadata anyway,
40
+ * so the clock can show before play.
41
+ */
21
42
  preload?: 'none' | 'metadata';
22
43
  /** Called at most once automatically per play attempt. Honor signal to cancel network work. */
23
44
  onRefresh?: (signal: AbortSignal) => Promise<AudioPlayerSource>;
@@ -30,4 +51,28 @@ interface AudioPlayerProps {
30
51
  /** Three layouts, one playback controller. No provider, credentials, polling, or Next.js runtime imports. */
31
52
  declare function AudioPlayer(props: AudioPlayerProps): react_jsx_runtime.JSX.Element | null;
32
53
 
33
- 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,7 +33,12 @@ interface AudioPlayerProps {
17
33
  label?: string;
18
34
  className?: string;
19
35
  style?: CSSProperties;
20
- /** Defaults to none: lists do not download every recording on page load. */
36
+ narration?: AudioPlayerNarration;
37
+ /**
38
+ * Defaults to none so a list does not download every recording.
39
+ * A player that does not already know its length uses metadata anyway,
40
+ * so the clock can show before play.
41
+ */
21
42
  preload?: 'none' | 'metadata';
22
43
  /** Called at most once automatically per play attempt. Honor signal to cancel network work. */
23
44
  onRefresh?: (signal: AbortSignal) => Promise<AudioPlayerSource>;
@@ -30,4 +51,28 @@ interface AudioPlayerProps {
30
51
  /** Three layouts, one playback controller. No provider, credentials, polling, or Next.js runtime imports. */
31
52
  declare function AudioPlayer(props: AudioPlayerProps): react_jsx_runtime.JSX.Element | null;
32
53
 
33
- 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
  }
@@ -225,6 +507,7 @@ function PlayerSession({
225
507
  return Number.isFinite(value) ? Math.min(1, Math.max(0.08, Math.abs(value))) : 0.08;
226
508
  });
227
509
  }, [source.waveform_peaks]);
510
+ const mediaPreload = durationOf(source) > 0 ? preload : "metadata";
228
511
  const current = Math.min(positive(position), duration || Infinity);
229
512
  const progress = duration ? Math.max(0, Math.min(100, current / duration * 100)) : 0;
230
513
  const remaining = duration ? `${time((duration - current) / speed)} remaining` : "Duration available when played";
@@ -243,7 +526,7 @@ function PlayerSession({
243
526
  {
244
527
  ref: media,
245
528
  src: source.url ?? void 0,
246
- preload,
529
+ preload: mediaPreload,
247
530
  onLoadedMetadata: (e) => {
248
531
  const element = e.currentTarget;
249
532
  const total = positive(element.duration) || durationOf(source);
@@ -352,6 +635,19 @@ function PlayerSession({
352
635
  )
353
636
  ] }),
354
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
+ ),
355
651
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
356
652
  "button",
357
653
  {
@@ -383,5 +679,6 @@ function PlayerSession({
383
679
  }
384
680
  // Annotate the CommonJS export names for ESM import in node:
385
681
  0 && (module.exports = {
386
- AudioPlayer
682
+ AudioPlayer,
683
+ narrationHighlightSupported
387
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
  }
@@ -206,6 +487,7 @@ function PlayerSession({
206
487
  return Number.isFinite(value) ? Math.min(1, Math.max(0.08, Math.abs(value))) : 0.08;
207
488
  });
208
489
  }, [source.waveform_peaks]);
490
+ const mediaPreload = durationOf(source) > 0 ? preload : "metadata";
209
491
  const current = Math.min(positive(position), duration || Infinity);
210
492
  const progress = duration ? Math.max(0, Math.min(100, current / duration * 100)) : 0;
211
493
  const remaining = duration ? `${time((duration - current) / speed)} remaining` : "Duration available when played";
@@ -224,7 +506,7 @@ function PlayerSession({
224
506
  {
225
507
  ref: media,
226
508
  src: source.url ?? void 0,
227
- preload,
509
+ preload: mediaPreload,
228
510
  onLoadedMetadata: (e) => {
229
511
  const element = e.currentTarget;
230
512
  const total = positive(element.duration) || durationOf(source);
@@ -333,6 +615,19 @@ function PlayerSession({
333
615
  )
334
616
  ] }),
335
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
+ ),
336
631
  /* @__PURE__ */ jsxs(
337
632
  "button",
338
633
  {
@@ -363,5 +658,6 @@ function PlayerSession({
363
658
  );
364
659
  }
365
660
  export {
366
- AudioPlayer
661
+ AudioPlayer,
662
+ narrationHighlightSupported
367
663
  };
@@ -516,7 +516,7 @@ declare class ScaleMuleError extends Error {
516
516
  * through the route handler to the platform service.
517
517
  */
518
518
  readonly requestId?: string | undefined;
519
- constructor(code: string, message: string, status?: number, details?: Record<string, unknown> | undefined,
519
+ constructor(code: string, message: string, status?: number, details?: Record<string, unknown> | undefined,
520
520
  /**
521
521
  * Correlation id of the upstream platform request that produced this
522
522
  * error, when there was one. `apiHandler()` echoes it back to the browser
@@ -516,7 +516,7 @@ declare class ScaleMuleError extends Error {
516
516
  * through the route handler to the platform service.
517
517
  */
518
518
  readonly requestId?: string | undefined;
519
- constructor(code: string, message: string, status?: number, details?: Record<string, unknown> | undefined,
519
+ constructor(code: string, message: string, status?: number, details?: Record<string, unknown> | undefined,
520
520
  /**
521
521
  * Correlation id of the upstream platform request that produced this
522
522
  * error, when there was one. `apiHandler()` echoes it back to the browser
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalemule/nextjs",
3
- "version": "0.1.42",
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",