@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 +27 -1
- package/dist/audio.d.mts +73 -1
- package/dist/audio.d.ts +73 -1
- package/dist/audio.js +307 -3
- package/dist/audio.mjs +301 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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