@scalemule/nextjs 0.1.43 → 0.1.45

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,59 @@ 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 const WORD_HIGHLIGHT = "sm-narration-word";
77
+ declare const SENTENCE_HIGHLIGHT = "sm-narration-sentence";
78
+ declare function narrationHighlightSupported(): boolean;
79
+ /**
80
+ * Owns the live highlight for one narration player. Build once per
81
+ * (timings, article body) pair; drive with `update(currentTimeMs)`.
82
+ */
83
+ declare class NarrationHighlighter {
84
+ private words;
85
+ private ranges;
86
+ private sentenceRanges;
87
+ private currentWord;
88
+ private currentSentence;
89
+ private follow;
90
+ private detachUserScroll;
91
+ private reducedMotion;
92
+ constructor(root: Element, timings: NarrationTimings);
93
+ /** Fraction of narration words found in the article body. A low
94
+ * ratio means the body diverged from the script; callers may prefer
95
+ * to hide the toggle below ~0.5. */
96
+ matchRatio(): number;
97
+ private attachUserScroll;
98
+ /** Re-engage auto-scroll (the reader pressed the toggle or sought). */
99
+ resumeFollowing(): void;
100
+ update(timeMs: number): void;
101
+ private scrollTo;
102
+ clear(): void;
103
+ destroy(): void;
104
+ }
105
+ /** Parse a fetched timings payload; accepts the raw timings object or
106
+ * common envelopes ({timings}, {data:{timings}}). */
107
+ declare function parseTimingsPayload(body: unknown): NarrationTimings | null;
108
+
109
+ export { AudioPlayer, type AudioPlayerNarration, type AudioPlayerProps, type AudioPlayerSource, type AudioPlayerVariant, NarrationHighlighter, type NarrationTimings, SENTENCE_HIGHLIGHT, WORD_HIGHLIGHT, narrationHighlightSupported, parseTimingsPayload };
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,59 @@ 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 const WORD_HIGHLIGHT = "sm-narration-word";
77
+ declare const SENTENCE_HIGHLIGHT = "sm-narration-sentence";
78
+ declare function narrationHighlightSupported(): boolean;
79
+ /**
80
+ * Owns the live highlight for one narration player. Build once per
81
+ * (timings, article body) pair; drive with `update(currentTimeMs)`.
82
+ */
83
+ declare class NarrationHighlighter {
84
+ private words;
85
+ private ranges;
86
+ private sentenceRanges;
87
+ private currentWord;
88
+ private currentSentence;
89
+ private follow;
90
+ private detachUserScroll;
91
+ private reducedMotion;
92
+ constructor(root: Element, timings: NarrationTimings);
93
+ /** Fraction of narration words found in the article body. A low
94
+ * ratio means the body diverged from the script; callers may prefer
95
+ * to hide the toggle below ~0.5. */
96
+ matchRatio(): number;
97
+ private attachUserScroll;
98
+ /** Re-engage auto-scroll (the reader pressed the toggle or sought). */
99
+ resumeFollowing(): void;
100
+ update(timeMs: number): void;
101
+ private scrollTo;
102
+ clear(): void;
103
+ destroy(): void;
104
+ }
105
+ /** Parse a fetched timings payload; accepts the raw timings object or
106
+ * common envelopes ({timings}, {data:{timings}}). */
107
+ declare function parseTimingsPayload(body: unknown): NarrationTimings | null;
108
+
109
+ export { AudioPlayer, type AudioPlayerNarration, type AudioPlayerProps, type AudioPlayerSource, type AudioPlayerVariant, NarrationHighlighter, type NarrationTimings, SENTENCE_HIGHLIGHT, WORD_HIGHLIGHT, narrationHighlightSupported, parseTimingsPayload };
package/dist/audio.js CHANGED
@@ -22,15 +22,214 @@ 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
+ NarrationHighlighter: () => NarrationHighlighter,
27
+ SENTENCE_HIGHLIGHT: () => SENTENCE_HIGHLIGHT,
28
+ WORD_HIGHLIGHT: () => WORD_HIGHLIGHT,
29
+ narrationHighlightSupported: () => narrationHighlightSupported,
30
+ parseTimingsPayload: () => parseTimingsPayload
26
31
  });
27
32
  module.exports = __toCommonJS(audio_exports);
28
33
 
29
34
  // src/components/audio-player.tsx
30
35
  var import_react = require("react");
36
+
37
+ // src/components/narration-highlight.ts
38
+ var WORD_HIGHLIGHT = "sm-narration-word";
39
+ var SENTENCE_HIGHLIGHT = "sm-narration-sentence";
40
+ function normalizeWord(raw) {
41
+ return raw.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
42
+ }
43
+ var SKIPPED_TAGS = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE"]);
44
+ function collectDomWords(root) {
45
+ const doc = root.ownerDocument ?? root;
46
+ const walker = doc.createTreeWalker(root, 4, {
47
+ acceptNode(node) {
48
+ const parent = node.parentElement;
49
+ if (parent && SKIPPED_TAGS.has(parent.tagName)) return 2;
50
+ return 1;
51
+ }
52
+ });
53
+ const out = [];
54
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
55
+ const text = node.data;
56
+ const matcher = /\S+/g;
57
+ for (let m = matcher.exec(text); m; m = matcher.exec(text)) {
58
+ const norm = normalizeWord(m[0]);
59
+ if (norm) {
60
+ out.push({ node, start: m.index, end: m.index + m[0].length, norm });
61
+ }
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+ function alignWords(timingNorms, domNorms, window2 = 6) {
67
+ const out = new Array(timingNorms.length).fill(-1);
68
+ let j = 0;
69
+ for (let i = 0; i < timingNorms.length; i++) {
70
+ const target = timingNorms[i];
71
+ if (!target) continue;
72
+ let found = -1;
73
+ for (let k = j; k < Math.min(domNorms.length, j + window2); k++) {
74
+ if (domNorms[k] === target) {
75
+ found = k;
76
+ break;
77
+ }
78
+ }
79
+ if (found === -1) {
80
+ continue;
81
+ }
82
+ out[i] = found;
83
+ j = found + 1;
84
+ }
85
+ return out;
86
+ }
87
+ function wordIndexAt(words, timeMs) {
88
+ let lo = 0;
89
+ let hi = words.length - 1;
90
+ let ans = -1;
91
+ while (lo <= hi) {
92
+ const mid = lo + hi >> 1;
93
+ if (words[mid][1] <= timeMs) {
94
+ ans = mid;
95
+ lo = mid + 1;
96
+ } else {
97
+ hi = mid - 1;
98
+ }
99
+ }
100
+ return ans;
101
+ }
102
+ function narrationHighlightSupported() {
103
+ return typeof window !== "undefined" && typeof CSS !== "undefined" && "highlights" in CSS && typeof globalThis.Highlight === "function";
104
+ }
105
+ var NarrationHighlighter = class {
106
+ constructor(root, timings) {
107
+ this.currentWord = -2;
108
+ this.currentSentence = -2;
109
+ this.follow = true;
110
+ this.detachUserScroll = null;
111
+ this.words = timings.words;
112
+ const domWords = collectDomWords(root);
113
+ const matches = alignWords(
114
+ this.words.map((w) => normalizeWord(w[0])),
115
+ domWords.map((w) => w.norm)
116
+ );
117
+ const doc = root.ownerDocument;
118
+ this.ranges = matches.map((m) => {
119
+ if (m === -1) return null;
120
+ const w = domWords[m];
121
+ try {
122
+ const range = doc.createRange();
123
+ range.setStart(w.node, w.start);
124
+ range.setEnd(w.node, w.end);
125
+ return range;
126
+ } catch {
127
+ return null;
128
+ }
129
+ });
130
+ this.sentenceRanges = /* @__PURE__ */ new Map();
131
+ this.words.forEach((w, i) => {
132
+ const range = this.ranges[i];
133
+ if (!range) return;
134
+ const list = this.sentenceRanges.get(w[3]);
135
+ if (list) list.push(range);
136
+ else this.sentenceRanges.set(w[3], [range]);
137
+ });
138
+ this.reducedMotion = typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
139
+ this.attachUserScroll(doc);
140
+ }
141
+ /** Fraction of narration words found in the article body. A low
142
+ * ratio means the body diverged from the script; callers may prefer
143
+ * to hide the toggle below ~0.5. */
144
+ matchRatio() {
145
+ if (this.ranges.length === 0) return 0;
146
+ return this.ranges.filter(Boolean).length / this.ranges.length;
147
+ }
148
+ attachUserScroll(doc) {
149
+ const stopFollowing = () => {
150
+ this.follow = false;
151
+ };
152
+ const opts = { passive: true };
153
+ doc.addEventListener("wheel", stopFollowing, opts);
154
+ doc.addEventListener("touchmove", stopFollowing, opts);
155
+ this.detachUserScroll = () => {
156
+ doc.removeEventListener("wheel", stopFollowing, opts);
157
+ doc.removeEventListener("touchmove", stopFollowing, opts);
158
+ };
159
+ }
160
+ /** Re-engage auto-scroll (the reader pressed the toggle or sought). */
161
+ resumeFollowing() {
162
+ this.follow = true;
163
+ this.currentSentence = -2;
164
+ }
165
+ update(timeMs) {
166
+ if (!narrationHighlightSupported()) return;
167
+ const idx = wordIndexAt(this.words, timeMs);
168
+ if (idx === this.currentWord) return;
169
+ this.currentWord = idx;
170
+ const registry = CSS.highlights;
171
+ if (idx < 0) {
172
+ registry.delete(WORD_HIGHLIGHT);
173
+ registry.delete(SENTENCE_HIGHLIGHT);
174
+ this.currentSentence = -2;
175
+ return;
176
+ }
177
+ const wordRange = this.ranges[idx];
178
+ if (wordRange) registry.set(WORD_HIGHLIGHT, new Highlight(wordRange));
179
+ else registry.delete(WORD_HIGHLIGHT);
180
+ const sentence = this.words[idx][3];
181
+ if (sentence !== this.currentSentence) {
182
+ this.currentSentence = sentence;
183
+ const ranges = this.sentenceRanges.get(sentence) ?? [];
184
+ if (ranges.length > 0) {
185
+ registry.set(SENTENCE_HIGHLIGHT, new Highlight(...ranges));
186
+ this.scrollTo(ranges[0]);
187
+ } else {
188
+ registry.delete(SENTENCE_HIGHLIGHT);
189
+ }
190
+ }
191
+ }
192
+ scrollTo(range) {
193
+ if (!this.follow) return;
194
+ try {
195
+ const rect = range.getBoundingClientRect();
196
+ const viewport = window.innerHeight || 0;
197
+ if (rect.top < viewport * 0.15 || rect.bottom > viewport * 0.7) {
198
+ const target = window.scrollY + rect.top - viewport * 0.3;
199
+ window.scrollTo({ top: target, behavior: this.reducedMotion ? "auto" : "smooth" });
200
+ }
201
+ } catch {
202
+ }
203
+ }
204
+ clear() {
205
+ if (!narrationHighlightSupported()) return;
206
+ const registry = CSS.highlights;
207
+ registry.delete(WORD_HIGHLIGHT);
208
+ registry.delete(SENTENCE_HIGHLIGHT);
209
+ this.currentWord = -2;
210
+ this.currentSentence = -2;
211
+ }
212
+ destroy() {
213
+ this.clear();
214
+ this.detachUserScroll?.();
215
+ this.detachUserScroll = null;
216
+ }
217
+ };
218
+ function parseTimingsPayload(body) {
219
+ const candidate = body?.timings ?? body?.data?.timings ?? body;
220
+ const t = candidate;
221
+ if (!t || t.version !== 1 || !Array.isArray(t.words) || t.words.length === 0) return null;
222
+ const valid = t.words.every(
223
+ (w) => Array.isArray(w) && w.length === 4 && typeof w[0] === "string" && typeof w[1] === "number" && typeof w[2] === "number" && typeof w[3] === "number"
224
+ );
225
+ return valid ? t : null;
226
+ }
227
+
228
+ // src/components/audio-player.tsx
31
229
  var import_jsx_runtime = require("react/jsx-runtime");
32
230
  var SPEEDS = [1, 1.25, 1.5, 2, 3];
33
231
  var PLAY_EVENT = "scalemule:audio:play";
232
+ var HIGHLIGHT_PREF_KEY = "scalemule:audio:highlight";
34
233
  var positive = (n) => n != null && Number.isFinite(n) && n > 0 ? n : 0;
35
234
  var durationOf = (audio) => positive(audio.duration_ms) / 1e3;
36
235
  function time(seconds) {
@@ -52,7 +251,8 @@ function PlayerSession({
52
251
  showRefreshButton = false,
53
252
  onPlaybackError,
54
253
  playbackRateStorageKey = "scalemule:audio:playback-rate",
55
- exclusivePlayback = true
254
+ exclusivePlayback = true,
255
+ narration
56
256
  }) {
57
257
  const media = (0, import_react.useRef)(null);
58
258
  const alive = (0, import_react.useRef)(true);
@@ -71,6 +271,91 @@ function PlayerSession({
71
271
  const sliderId = (0, import_react.useId)();
72
272
  const labelId = (0, import_react.useId)();
73
273
  const rate = (0, import_react.useRef)(1);
274
+ const narrationOffered = !!narration && audio.has_word_timings !== false && narrationHighlightSupported();
275
+ const [highlightOn, setHighlightOn] = (0, import_react.useState)(false);
276
+ const [highlightBusy, setHighlightBusy] = (0, import_react.useState)(false);
277
+ const highlighter = (0, import_react.useRef)(null);
278
+ const highlightAbort = (0, import_react.useRef)(null);
279
+ (0, import_react.useEffect)(() => {
280
+ if (!narrationOffered) return;
281
+ try {
282
+ if (window.localStorage.getItem(HIGHLIGHT_PREF_KEY) === "1") {
283
+ void enableHighlight(false);
284
+ }
285
+ } catch {
286
+ }
287
+ }, [narrationOffered]);
288
+ (0, import_react.useEffect)(() => {
289
+ return () => {
290
+ highlightAbort.current?.abort();
291
+ highlighter.current?.destroy();
292
+ highlighter.current = null;
293
+ };
294
+ }, []);
295
+ async function enableHighlight(persist) {
296
+ if (!narration) return;
297
+ if (persist) {
298
+ try {
299
+ window.localStorage.setItem(HIGHLIGHT_PREF_KEY, "1");
300
+ } catch {
301
+ }
302
+ }
303
+ if (highlighter.current) {
304
+ highlighter.current.resumeFollowing();
305
+ setHighlightOn(true);
306
+ return;
307
+ }
308
+ if (highlightBusy) return;
309
+ const controller = new AbortController();
310
+ highlightAbort.current = controller;
311
+ setHighlightBusy(true);
312
+ try {
313
+ const resp = await fetch(narration.timingsUrl, { signal: controller.signal });
314
+ if (!resp.ok) throw new Error(`timings ${resp.status}`);
315
+ const timings = parseTimingsPayload(await resp.json());
316
+ const target = document.getElementById(narration.targetId);
317
+ if (!timings || !target) throw new Error("timings or target missing");
318
+ const instance = new NarrationHighlighter(target, timings);
319
+ if (instance.matchRatio() < 0.5) {
320
+ instance.destroy();
321
+ throw new Error("article text does not match narration");
322
+ }
323
+ highlighter.current = instance;
324
+ setHighlightOn(true);
325
+ const element = media.current;
326
+ if (element) instance.update(element.currentTime * 1e3);
327
+ } catch {
328
+ setHighlightOn(false);
329
+ } finally {
330
+ if (highlightAbort.current === controller) highlightAbort.current = null;
331
+ setHighlightBusy(false);
332
+ }
333
+ }
334
+ function disableHighlight() {
335
+ try {
336
+ window.localStorage.setItem(HIGHLIGHT_PREF_KEY, "0");
337
+ } catch {
338
+ }
339
+ highlightAbort.current?.abort();
340
+ highlighter.current?.clear();
341
+ setHighlightOn(false);
342
+ }
343
+ (0, import_react.useEffect)(() => {
344
+ if (!highlightOn || !highlighter.current) return;
345
+ const element = media.current;
346
+ if (!element) return;
347
+ if (!playing) {
348
+ highlighter.current.update(element.currentTime * 1e3);
349
+ return;
350
+ }
351
+ let frame = 0;
352
+ const tick = () => {
353
+ highlighter.current?.update(element.currentTime * 1e3);
354
+ frame = requestAnimationFrame(tick);
355
+ };
356
+ frame = requestAnimationFrame(tick);
357
+ return () => cancelAnimationFrame(frame);
358
+ }, [highlightOn, playing, position]);
74
359
  (0, import_react.useEffect)(() => {
75
360
  alive.current = true;
76
361
  const element = media.current;
@@ -202,6 +487,7 @@ function PlayerSession({
202
487
  media.current.currentTime = target;
203
488
  resume.current = target;
204
489
  setPosition(target);
490
+ highlighter.current?.resumeFollowing();
205
491
  } catch {
206
492
  }
207
493
  }
@@ -353,6 +639,19 @@ function PlayerSession({
353
639
  )
354
640
  ] }),
355
641
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "sm-audio__actions", children: [
642
+ narrationOffered && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
643
+ "button",
644
+ {
645
+ type: "button",
646
+ className: `sm-audio__highlight${highlightOn ? " sm-audio__highlight--on" : ""}`,
647
+ "aria-pressed": highlightOn,
648
+ disabled: highlightBusy,
649
+ onClick: () => highlightOn ? disableHighlight() : void enableHighlight(true),
650
+ "aria-label": highlightOn ? "Turn off follow-along highlighting" : "Highlight the text as it is read",
651
+ title: highlightOn ? "Turn off follow-along highlighting" : "Highlight the text as it is read",
652
+ 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" }) })
653
+ }
654
+ ),
356
655
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
357
656
  "button",
358
657
  {
@@ -384,5 +683,10 @@ function PlayerSession({
384
683
  }
385
684
  // Annotate the CommonJS export names for ESM import in node:
386
685
  0 && (module.exports = {
387
- AudioPlayer
686
+ AudioPlayer,
687
+ NarrationHighlighter,
688
+ SENTENCE_HIGHLIGHT,
689
+ WORD_HIGHLIGHT,
690
+ narrationHighlightSupported,
691
+ parseTimingsPayload
388
692
  });
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,10 @@ function PlayerSession({
364
658
  );
365
659
  }
366
660
  export {
367
- AudioPlayer
661
+ AudioPlayer,
662
+ NarrationHighlighter,
663
+ SENTENCE_HIGHLIGHT,
664
+ WORD_HIGHLIGHT,
665
+ narrationHighlightSupported,
666
+ parseTimingsPayload
368
667
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalemule/nextjs",
3
- "version": "0.1.43",
3
+ "version": "0.1.45",
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",