@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 +27 -1
- package/dist/audio.d.mts +47 -2
- package/dist/audio.d.ts +47 -2
- package/dist/audio.js +301 -4
- package/dist/audio.mjs +299 -3
- package/dist/server/index.d.mts +1 -1
- package/dist/server/index.d.ts +1 -1
- 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,7 +33,12 @@ interface AudioPlayerProps {
|
|
|
17
33
|
label?: string;
|
|
18
34
|
className?: string;
|
|
19
35
|
style?: CSSProperties;
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/dist/server/index.d.mts
CHANGED
|
@@ -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/dist/server/index.d.ts
CHANGED
|
@@ -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