openaudio-suite 2.5.6 → 2.6.3
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/CHANGELOG.md +169 -109
- package/CONTRIBUTING.md +39 -324
- package/OpenAudio.js +192 -284
- package/OpenAudio_r.js +211 -164
- package/OpenAudio_s.js +420 -204
- package/README.md +142 -122
- package/docs/OPENAUDIO_R.md +135 -143
- package/docs/OPENAUDIO_S.md +162 -377
- package/package.json +17 -27
package/OpenAudio_s.js
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file OpenAudio_s.js
|
|
3
3
|
* @author Rexore
|
|
4
|
-
* @version 1.
|
|
5
|
-
* @license
|
|
4
|
+
* @version 1.3.0
|
|
5
|
+
* @license Apache-2.0
|
|
6
|
+
*
|
|
7
|
+
* Copyright 2025 Rexore
|
|
8
|
+
*
|
|
9
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
10
|
+
* you may not use this file except in compliance with the License.
|
|
11
|
+
* You may obtain a copy of the License at
|
|
12
|
+
*
|
|
13
|
+
* https://www.apache.org/licenses/LICENSE-2.0
|
|
14
|
+
*
|
|
15
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
16
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
17
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
18
|
+
* See the License for the specific language governing permissions and
|
|
19
|
+
* limitations under the License.
|
|
6
20
|
*
|
|
7
21
|
* Sequential audio player: plays clips one at a time, advances on demand.
|
|
8
22
|
* Perfect for interactive narratives, tutorials, quizzes, and guided tours.
|
|
@@ -12,13 +26,12 @@
|
|
|
12
26
|
* - User controls when next clip plays (manual or auto-advance)
|
|
13
27
|
* - Tracks current clip index and progress
|
|
14
28
|
* - Can loop the entire sequence
|
|
15
|
-
* - Optional shuffle on sequence restart
|
|
16
29
|
*
|
|
17
30
|
* Inherited from parent architecture:
|
|
18
|
-
* - Silent MP3 unlock: satisfies
|
|
31
|
+
* - Silent MP3 unlock on the shared Audio element: satisfies autoplay policy
|
|
19
32
|
* - #isUnlocking guard: prevents duplicate unlock attempts
|
|
20
|
-
* - Callbacks: onPlay, onEnd — wrapped in try/catch
|
|
21
|
-
* - destroy(): removes listeners; safe for SPA teardown
|
|
33
|
+
* - Callbacks: onPlay, onEnd, onComplete — all wrapped in try/catch
|
|
34
|
+
* - destroy(): removes listeners and clears timers; safe for SPA teardown
|
|
22
35
|
* - canPlay() static: check browser format support before constructing
|
|
23
36
|
*
|
|
24
37
|
* ============================================================================
|
|
@@ -26,167 +39,327 @@
|
|
|
26
39
|
* ============================================================================
|
|
27
40
|
*
|
|
28
41
|
* const player = new SequentialAudio([
|
|
29
|
-
* { src: 'intro.mp3',
|
|
30
|
-
* { src: 'chapter1.mp3', label: 'Chapter 1'
|
|
31
|
-
* { src: 'chapter2.mp3', label: 'Chapter 2'
|
|
42
|
+
* { src: 'intro.mp3', label: 'Introduction' },
|
|
43
|
+
* { src: 'chapter1.mp3', label: 'Chapter 1' },
|
|
44
|
+
* { src: 'chapter2.mp3', label: 'Chapter 2' }
|
|
32
45
|
* ], {
|
|
33
|
-
* autoAdvance: false,
|
|
46
|
+
* autoAdvance: false,
|
|
34
47
|
* onPlay: (clip) => console.log(`Playing: ${clip.label}`),
|
|
35
48
|
* onEnd: (clip) => console.log(`Finished: ${clip.label}`),
|
|
36
|
-
* onComplete: ()
|
|
49
|
+
* onComplete: () => console.log('Sequence complete!')
|
|
37
50
|
* });
|
|
38
51
|
*
|
|
39
|
-
* //
|
|
40
|
-
* document.getElementById('play-btn').addEventListener('click', () =>
|
|
41
|
-
* player.play();
|
|
42
|
-
* });
|
|
52
|
+
* // First play — must be inside a user gesture
|
|
53
|
+
* document.getElementById('play-btn').addEventListener('click', () => player.play());
|
|
43
54
|
*
|
|
44
55
|
* // Advance to next clip
|
|
45
|
-
* document.getElementById('next-btn').addEventListener('click', () =>
|
|
46
|
-
* player.next();
|
|
47
|
-
* });
|
|
56
|
+
* document.getElementById('next-btn').addEventListener('click', () => player.next());
|
|
48
57
|
*
|
|
49
58
|
* ============================================================================
|
|
50
59
|
* BROWSER AUTOPLAY POLICY
|
|
51
60
|
* ============================================================================
|
|
52
61
|
*
|
|
53
62
|
* First call to play() must be inside a user gesture (click, keydown, etc.).
|
|
54
|
-
*
|
|
63
|
+
* The shared Audio element is unlocked by playing a silent base64 MP3
|
|
64
|
+
* synchronously within the gesture context. All subsequent play() calls on
|
|
65
|
+
* the same element (including next(), goto(), resume()) are permitted without
|
|
66
|
+
* further user interaction.
|
|
67
|
+
*
|
|
68
|
+
* ============================================================================
|
|
69
|
+
* CHANGELOG
|
|
70
|
+
* ============================================================================
|
|
71
|
+
*
|
|
72
|
+
* 1.3.0
|
|
73
|
+
* - #playCancelled flag: stop() and reset() now plant a cancellation token
|
|
74
|
+
* that the in-flight silent-MP3 unlock .then() checks before calling
|
|
75
|
+
* #playClip(). Previously, calling reset() (or stop()) during the ~50–200ms
|
|
76
|
+
* unlock window was ignored: .then() would fire unconditionally and start
|
|
77
|
+
* the first clip regardless. The fix mirrors the pattern introduced in
|
|
78
|
+
* OpenAudio.js 1.3.0.
|
|
79
|
+
* - play() doc comment updated to explain that #isStarted becomes true inside
|
|
80
|
+
* #playClip() (after the unlock), not before. This is a deliberate design
|
|
81
|
+
* choice that differs from OpenAudio_r.js, which sets #isStarted before the
|
|
82
|
+
* unlock. Both approaches are valid; the difference is documented so readers
|
|
83
|
+
* of both files are not surprised.
|
|
84
|
+
* - Usage example: goto(prev) now uses Math.max(0, index - 1) guard,
|
|
85
|
+
* matching the published API docs and avoiding a spurious out-of-range
|
|
86
|
+
* console.warn when the user is already on the first clip.
|
|
87
|
+
*
|
|
88
|
+
* 1.2.0
|
|
89
|
+
* - isPlaying and isStarted are now private fields (#isPlaying, #isStarted)
|
|
90
|
+
* exposed via read-only getters. Previously they were plain public
|
|
91
|
+
* properties, allowing callers to silently corrupt the state machine.
|
|
92
|
+
* - #isPlaying and #isStarted are now set true synchronously before the
|
|
93
|
+
* #audio.play() call in #playClip(), closing the double-play race window
|
|
94
|
+
* where rapid next()/goto() calls could bypass guards and abort a clip
|
|
95
|
+
* mid-start. #isPlaying is reverted in .catch() on real failures.
|
|
96
|
+
* - #advanceTimer: the auto-advance setTimeout handle is now stored and
|
|
97
|
+
* cleared by stop(), reset(), and destroy(). Previously stop() or reset()
|
|
98
|
+
* called during the advance delay would not cancel the pending next(),
|
|
99
|
+
* causing the next clip to start after an explicit stop.
|
|
100
|
+
* - resume(): #isPlaying now set synchronously before #audio.play(), then
|
|
101
|
+
* reverted in .catch(). Previously the .then() could overwrite a
|
|
102
|
+
* concurrent stop()'s isPlaying = false.
|
|
103
|
+
* - destroy() now uses removeAttribute('src') + load() per the WHATWG
|
|
104
|
+
* HTMLMediaElement resource-release spec, replacing src = ''.
|
|
105
|
+
* - getCurrentClip() now returns a shallow copy ({ ...clip }) instead of a
|
|
106
|
+
* live reference. getClips() now deep-copies inner objects. Prevents
|
|
107
|
+
* callers from mutating the internal playlist.
|
|
108
|
+
* - Removed unreachable this.#audio?.pause() from #playClip() .then().
|
|
109
|
+
*
|
|
110
|
+
* 1.1.0
|
|
111
|
+
* - Unlock now plays the silent MP3 on the shared #audio element rather than
|
|
112
|
+
* creating a throwaway new Audio(). Previously the throwaway element was
|
|
113
|
+
* blessed but #audio was not, causing NotAllowedError on iOS Safari for
|
|
114
|
+
* every clip after the first.
|
|
115
|
+
* - play() guard extended: now checks isStarted as well as isPlaying and
|
|
116
|
+
* #isUnlocking. Previously calling play() after a clip ended (isPlaying
|
|
117
|
+
* false) triggered a redundant second unlock attempt mid-sequence.
|
|
118
|
+
* - next() now guards against being called before play() has ever been
|
|
119
|
+
* invoked. Calling next() on an uninitialised player previously bypassed
|
|
120
|
+
* the unlock and threw NotAllowedError on first use.
|
|
121
|
+
* - pause() and resume() now guard on isStarted to prevent silent state
|
|
122
|
+
* corruption before the sequence begins.
|
|
123
|
+
* - destroy() now removes the 'ended' event listener before nulling #audio,
|
|
124
|
+
* preventing a potential callback-into-null race on teardown.
|
|
125
|
+
* - advanceDelay option (default 500ms): replaces the hardcoded 500ms gap
|
|
126
|
+
* between auto-advance clips. Configurable at construction time.
|
|
127
|
+
* - #isDestroyed flag: all public methods check this and return immediately
|
|
128
|
+
* after destroy() has been called, making post-destroy calls safe rather
|
|
129
|
+
* than throwing on the nulled #audio element.
|
|
130
|
+
*
|
|
131
|
+
* 1.0.0
|
|
132
|
+
* - Initial release.
|
|
133
|
+
* - Sequential playback with manual or auto-advance.
|
|
134
|
+
* - goto() / gotoLabel() navigation.
|
|
135
|
+
* - pause() / resume() / stop() / reset() transport controls.
|
|
136
|
+
* - loop option for continuous cycling.
|
|
137
|
+
* - onPlay / onEnd / onComplete callbacks.
|
|
138
|
+
*
|
|
139
|
+
* ============================================================================
|
|
140
|
+
* CONFIGURATION OPTIONS
|
|
141
|
+
* ============================================================================
|
|
142
|
+
*
|
|
143
|
+
* autoAdvance {boolean} Auto-play next clip when current ends default: false
|
|
144
|
+
* advanceDelay {number} Seconds to wait before auto-advance default: 0.5
|
|
145
|
+
* loop {boolean} Loop sequence when complete default: false
|
|
146
|
+
* volume {number} Playback volume 0.0–1.0 default: 1.0
|
|
147
|
+
* onPlay {Function} Called when a clip starts default: null
|
|
148
|
+
* onEnd {Function} Called when a clip ends default: null
|
|
149
|
+
* onComplete {Function} Called when sequence finishes (no loop) default: null
|
|
150
|
+
*
|
|
151
|
+
* ============================================================================
|
|
152
|
+
* PUBLIC API
|
|
153
|
+
* ============================================================================
|
|
154
|
+
*
|
|
155
|
+
* player.play() Start the sequence. Must be inside a gesture handler
|
|
156
|
+
* on first call. Safe to call if already playing —
|
|
157
|
+
* ignored once the sequence has started.
|
|
158
|
+
*
|
|
159
|
+
* player.next() Advance to the next clip and play it.
|
|
160
|
+
* Calls onComplete (or loops) at end of sequence.
|
|
161
|
+
* No-op if sequence has not been started via play().
|
|
162
|
+
*
|
|
163
|
+
* player.goto(index) Jump to clip by zero-based index and play it.
|
|
164
|
+
* player.gotoLabel(label) Jump to clip by label (exact match) and play it.
|
|
165
|
+
*
|
|
166
|
+
* player.pause() Pause at current position.
|
|
167
|
+
* player.resume() Resume from paused position.
|
|
168
|
+
* player.stop() Pause, rewind current clip, and cancel any pending
|
|
169
|
+
* auto-advance timer.
|
|
170
|
+
* player.reset() Stop and return sequence to clip 0.
|
|
171
|
+
*
|
|
172
|
+
* player.destroy() Stop, clear timers, remove event listeners, release
|
|
173
|
+
* Audio element. Call on SPA component unmount.
|
|
174
|
+
*
|
|
175
|
+
* player.getCurrentClip() Returns a copy of the current clip { src, label }.
|
|
176
|
+
* player.getCurrentIndex() Returns current clip index (number).
|
|
177
|
+
* player.getClipCount() Returns total number of clips (number).
|
|
178
|
+
* player.getClips() Returns a deep copy of the clips array.
|
|
179
|
+
*
|
|
180
|
+
* player.isPlaying {boolean} Read-only. True while a clip is playing.
|
|
181
|
+
* player.isStarted {boolean} Read-only. True after first play().
|
|
182
|
+
*
|
|
183
|
+
* ── STATIC UTILITY ──────────────────────────────────────────────────────────
|
|
184
|
+
*
|
|
185
|
+
* SequentialAudio.canPlay(type) Returns true if the browser can likely play
|
|
186
|
+
* the given MIME type. Wraps canPlayType().
|
|
55
187
|
*
|
|
56
188
|
* ============================================================================
|
|
57
189
|
*/
|
|
58
190
|
|
|
59
|
-
|
|
191
|
+
// Silent 1-second MP3 — used to unlock the shared Audio element within the
|
|
192
|
+
// gesture context before the first real clip is played.
|
|
193
|
+
const _SEQUENTIAL_SILENT_MP3 =
|
|
194
|
+
'data:audio/mpeg;base64,SUQzBAAAAAABEVRYWFgAAAAtAAADY29tbWVudABCaWdTb3VuZEJhbmsgU291bmQgRWZmZWN0cyBMaWJyYXJ5Ly8v' +
|
|
195
|
+
'VFNTTQAAAAALAAADAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAA' +
|
|
196
|
+
'AP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP//' +
|
|
197
|
+
'//8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8A' +
|
|
198
|
+
'AAAAAAAAAP////8=';
|
|
60
199
|
|
|
61
|
-
|
|
62
|
-
static #SILENT_MP3 =
|
|
63
|
-
'data:audio/mpeg;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4LjI5LjEwMAAAAAAA' +
|
|
64
|
-
'AAAAAP/7kGQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAADhgCg' +
|
|
65
|
-
'oKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKD///////' +
|
|
66
|
-
'///////////////////////////////////////////////////////////AAAAAExhdmM1OC41' +
|
|
67
|
-
'NQAAAAAAAAAAAAAAA//uQZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWluZwAAAA8A' +
|
|
68
|
-
'AAAkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAv' +
|
|
69
|
-
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==';
|
|
200
|
+
class SequentialAudio {
|
|
70
201
|
|
|
71
202
|
// ── Private fields ──────────────────────────────────────────────────────────
|
|
72
203
|
#clips;
|
|
73
204
|
#currentIndex;
|
|
74
205
|
#audio;
|
|
75
|
-
#
|
|
206
|
+
#endedHandler; // stored reference for removeEventListener in destroy()
|
|
207
|
+
#isUnlocking;
|
|
208
|
+
#isDestroyed;
|
|
76
209
|
#autoAdvance;
|
|
210
|
+
#advanceDelay;
|
|
211
|
+
// Stored handle for the auto-advance setTimeout — cleared by stop(),
|
|
212
|
+
// reset(), and destroy() to prevent phantom next() calls after an explicit
|
|
213
|
+
// stop during the advance delay window.
|
|
214
|
+
#advanceTimer;
|
|
77
215
|
#loop;
|
|
216
|
+
#volume;
|
|
78
217
|
#onPlay;
|
|
79
218
|
#onEnd;
|
|
80
219
|
#onComplete;
|
|
220
|
+
// Backing fields for the read-only isPlaying / isStarted getters.
|
|
221
|
+
#isPlaying;
|
|
222
|
+
#isStarted;
|
|
223
|
+
// Written by stop()/reset() and read by the async unlock .then() to prevent
|
|
224
|
+
// #playClip() from running after an explicit stop during the unlock window.
|
|
225
|
+
// Mirrors the pattern used in OpenAudio.js (#playCancelled).
|
|
226
|
+
#playCancelled = false;
|
|
81
227
|
|
|
82
228
|
/**
|
|
83
|
-
* @param {Array
|
|
84
|
-
* @param {
|
|
85
|
-
* @param {boolean}
|
|
86
|
-
* @param {
|
|
87
|
-
* @param {
|
|
88
|
-
* @param {
|
|
89
|
-
* @param {Function} [options.
|
|
229
|
+
* @param {Array<{ src: string, label?: string }>} clips
|
|
230
|
+
* @param {Object} [options={}]
|
|
231
|
+
* @param {boolean} [options.autoAdvance=false]
|
|
232
|
+
* @param {number} [options.advanceDelay=0.5] Seconds before auto-advance fires.
|
|
233
|
+
* @param {boolean} [options.loop=false]
|
|
234
|
+
* @param {number} [options.volume=1.0]
|
|
235
|
+
* @param {Function} [options.onPlay]
|
|
236
|
+
* @param {Function} [options.onEnd]
|
|
237
|
+
* @param {Function} [options.onComplete]
|
|
90
238
|
*/
|
|
91
239
|
constructor(clips, options = {}) {
|
|
92
240
|
if (!Array.isArray(clips) || clips.length === 0) {
|
|
93
241
|
throw new TypeError('SequentialAudio: clips must be a non-empty array.');
|
|
94
242
|
}
|
|
243
|
+
const badClip = clips.findIndex(c => !c || typeof c.src !== 'string' || !c.src.trim());
|
|
244
|
+
if (badClip !== -1) {
|
|
245
|
+
throw new TypeError(`SequentialAudio: clips[${badClip}].src must be a non-empty string.`);
|
|
246
|
+
}
|
|
95
247
|
|
|
96
|
-
|
|
97
|
-
clips.forEach((clip, i) => {
|
|
98
|
-
if (!clip || typeof clip.src !== 'string' || !clip.src.trim()) {
|
|
99
|
-
throw new TypeError(`SequentialAudio: clips[${i}].src must be a non-empty string.`);
|
|
100
|
-
}
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
const {
|
|
104
|
-
autoAdvance = false,
|
|
105
|
-
loop = false,
|
|
106
|
-
onPlay = null,
|
|
107
|
-
onEnd = null,
|
|
108
|
-
onComplete = null,
|
|
109
|
-
} = options;
|
|
110
|
-
|
|
111
|
-
this.#clips = clips;
|
|
248
|
+
this.#clips = clips.map(c => ({ src: c.src, label: c.label || c.src }));
|
|
112
249
|
this.#currentIndex = 0;
|
|
113
|
-
this.#
|
|
114
|
-
this.#
|
|
115
|
-
this.#
|
|
116
|
-
this.#
|
|
117
|
-
this.#
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
this.#
|
|
250
|
+
this.#isUnlocking = false;
|
|
251
|
+
this.#isDestroyed = false;
|
|
252
|
+
this.#advanceTimer = null;
|
|
253
|
+
this.#isPlaying = false;
|
|
254
|
+
this.#isStarted = false;
|
|
255
|
+
|
|
256
|
+
this.#autoAdvance = options.autoAdvance ?? false;
|
|
257
|
+
this.#advanceDelay = options.advanceDelay ?? 0.5;
|
|
258
|
+
this.#loop = options.loop ?? false;
|
|
259
|
+
this.#volume = Math.min(1, Math.max(0, options.volume ?? 1.0));
|
|
260
|
+
this.#onPlay = options.onPlay || null;
|
|
261
|
+
this.#onEnd = options.onEnd || null;
|
|
262
|
+
this.#onComplete = options.onComplete || null;
|
|
263
|
+
|
|
264
|
+
// Single shared Audio element — created here so mobile browsers keep it
|
|
265
|
+
// within the gesture activation context for all subsequent play() calls.
|
|
266
|
+
this.#audio = new Audio();
|
|
121
267
|
this.#audio.preload = 'auto';
|
|
268
|
+
this.#audio.volume = this.#volume;
|
|
122
269
|
|
|
123
|
-
|
|
124
|
-
|
|
270
|
+
// Store the handler reference so destroy() can remove it cleanly.
|
|
271
|
+
this.#endedHandler = () => {
|
|
272
|
+
if (this.#isDestroyed) return;
|
|
273
|
+
this.#isPlaying = false;
|
|
125
274
|
const clip = this.#clips[this.#currentIndex];
|
|
126
|
-
|
|
127
|
-
try { if (this.#onEnd) this.#onEnd(clip); } catch (e) {
|
|
275
|
+
try { if (this.#onEnd) this.#onEnd({ ...clip }); } catch (e) {
|
|
128
276
|
console.warn(`SequentialAudio: onEnd callback error (${clip.label}):`, e);
|
|
129
277
|
}
|
|
130
|
-
|
|
131
|
-
// Auto-advance to next clip if enabled
|
|
132
278
|
if (this.#autoAdvance) {
|
|
133
|
-
|
|
279
|
+
// Store handle so stop()/reset()/destroy() can cancel before it fires.
|
|
280
|
+
this.#advanceTimer = setTimeout(() => {
|
|
281
|
+
this.#advanceTimer = null;
|
|
282
|
+
if (!this.#isDestroyed) this.next();
|
|
283
|
+
}, this.#advanceDelay * 1000);
|
|
134
284
|
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
285
|
+
};
|
|
137
286
|
|
|
138
|
-
|
|
287
|
+
this.#audio.addEventListener('ended', this.#endedHandler);
|
|
288
|
+
}
|
|
139
289
|
|
|
140
|
-
|
|
141
|
-
isPlaying = false;
|
|
290
|
+
// ── PUBLIC API ──────────────────────────────────────────────────────────────
|
|
142
291
|
|
|
143
|
-
/** True
|
|
144
|
-
|
|
292
|
+
/** @returns {boolean} True while a clip is actively playing. Read-only. */
|
|
293
|
+
get isPlaying() { return this.#isPlaying; }
|
|
145
294
|
|
|
146
|
-
|
|
295
|
+
/** @returns {boolean} True after the first successful play(). Read-only. */
|
|
296
|
+
get isStarted() { return this.#isStarted; }
|
|
147
297
|
|
|
148
298
|
/**
|
|
149
|
-
* Unlock the
|
|
150
|
-
* Must be called synchronously inside a user-gesture event handler on first use.
|
|
299
|
+
* Unlock the Audio element and play the current clip.
|
|
151
300
|
*
|
|
152
|
-
*
|
|
301
|
+
* Must be called synchronously inside a user gesture on first use.
|
|
302
|
+
* Ignored if the sequence has already started (isStarted), is currently
|
|
303
|
+
* playing, or the unlock is already in progress.
|
|
304
|
+
*
|
|
305
|
+
* Design note (D3): #isStarted becomes true inside #playClip(), which runs
|
|
306
|
+
* after the unlock resolves. This differs from OpenAudio_r.js, which sets
|
|
307
|
+
* #isStarted = true at the top of start() before the unlock. Both choices
|
|
308
|
+
* are valid; the difference is intentional and documented here to avoid
|
|
309
|
+
* confusion when reading both files side-by-side.
|
|
153
310
|
*/
|
|
154
311
|
play() {
|
|
155
|
-
if (this
|
|
312
|
+
if (this.#isDestroyed || this.#isStarted || this.#isPlaying || this.#isUnlocking) return;
|
|
313
|
+
|
|
314
|
+
// Reset the cancellation token on every fresh play() invocation.
|
|
315
|
+
this.#playCancelled = false;
|
|
156
316
|
|
|
157
317
|
this.#isUnlocking = true;
|
|
158
318
|
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
319
|
+
// Unlock the SHARED element — not a throwaway — so that all future
|
|
320
|
+
// play() calls on #audio are permitted by the browser's autoplay policy.
|
|
321
|
+
this.#audio.src = _SEQUENTIAL_SILENT_MP3;
|
|
322
|
+
this.#audio.volume = 0;
|
|
323
|
+
this.#audio.play()
|
|
324
|
+
.then(() => {
|
|
325
|
+
this.#isUnlocking = false;
|
|
326
|
+
// Honour a stop() or reset() call that arrived during the unlock.
|
|
327
|
+
if (this.#isDestroyed || this.#playCancelled) return;
|
|
328
|
+
this.#audio.volume = this.#volume;
|
|
329
|
+
this.#playClip();
|
|
330
|
+
})
|
|
331
|
+
.catch(err => {
|
|
332
|
+
this.#isUnlocking = false;
|
|
333
|
+
if (this.#isDestroyed) return;
|
|
334
|
+
if (err.name === 'NotAllowedError') {
|
|
335
|
+
console.warn(
|
|
336
|
+
'SequentialAudio: autoplay blocked during unlock. ' +
|
|
337
|
+
'play() must be called synchronously inside a user gesture handler ' +
|
|
338
|
+
'(click / keydown / touchstart).'
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
// AbortError or other — do not attempt to play; leave state clean.
|
|
342
|
+
});
|
|
168
343
|
}
|
|
169
344
|
|
|
170
345
|
/**
|
|
171
346
|
* Advance to the next clip and play it.
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
* Safe to call even if nothing is playing.
|
|
347
|
+
* No-op if the sequence has not been started via play() first.
|
|
348
|
+
* At end of sequence: loops if loop:true, otherwise fires onComplete.
|
|
175
349
|
*/
|
|
176
350
|
next() {
|
|
177
|
-
|
|
351
|
+
if (this.#isDestroyed || !this.#isStarted) return;
|
|
352
|
+
|
|
178
353
|
this.#currentIndex++;
|
|
179
354
|
|
|
180
|
-
// Handle end-of-sequence
|
|
181
355
|
if (this.#currentIndex >= this.#clips.length) {
|
|
182
356
|
if (this.#loop) {
|
|
183
|
-
// Loop: restart sequence
|
|
184
357
|
this.#currentIndex = 0;
|
|
185
358
|
this.#playClip();
|
|
186
359
|
} else {
|
|
187
|
-
//
|
|
188
|
-
this.#currentIndex = this.#clips.length - 1;
|
|
189
|
-
this
|
|
360
|
+
// Stay on the last clip index so getCurrentClip() remains valid.
|
|
361
|
+
this.#currentIndex = this.#clips.length - 1;
|
|
362
|
+
this.#isPlaying = false;
|
|
190
363
|
try { if (this.#onComplete) this.#onComplete(); } catch (e) {
|
|
191
364
|
console.warn('SequentialAudio: onComplete callback error:', e);
|
|
192
365
|
}
|
|
@@ -198,158 +371,192 @@ class SequentialAudio {
|
|
|
198
371
|
}
|
|
199
372
|
|
|
200
373
|
/**
|
|
201
|
-
* Jump to a
|
|
202
|
-
*
|
|
203
|
-
* @param {number} index - Clip index (0-based)
|
|
374
|
+
* Jump to a clip by zero-based index and play it.
|
|
375
|
+
* @param {number} index
|
|
204
376
|
*/
|
|
205
377
|
goto(index) {
|
|
378
|
+
if (this.#isDestroyed || !this.#isStarted) return;
|
|
206
379
|
if (index < 0 || index >= this.#clips.length) {
|
|
207
|
-
console.warn(`SequentialAudio: goto() index out of range [0, ${this.#clips.length - 1}]
|
|
380
|
+
console.warn(`SequentialAudio: goto() index ${index} out of range [0, ${this.#clips.length - 1}].`);
|
|
208
381
|
return;
|
|
209
382
|
}
|
|
210
|
-
|
|
211
383
|
this.#currentIndex = index;
|
|
212
384
|
this.#playClip();
|
|
213
385
|
}
|
|
214
386
|
|
|
215
387
|
/**
|
|
216
|
-
* Jump to a clip by label.
|
|
217
|
-
*
|
|
218
|
-
* @param {string} label - Clip label (exact match)
|
|
388
|
+
* Jump to a clip by label (exact match) and play it.
|
|
389
|
+
* @param {string} label
|
|
219
390
|
*/
|
|
220
391
|
gotoLabel(label) {
|
|
392
|
+
if (this.#isDestroyed || !this.#isStarted) return;
|
|
221
393
|
const index = this.#clips.findIndex(c => c.label === label);
|
|
222
394
|
if (index === -1) {
|
|
223
|
-
console.warn(`SequentialAudio: no clip with label "${label}"
|
|
395
|
+
console.warn(`SequentialAudio: no clip with label "${label}".`);
|
|
224
396
|
return;
|
|
225
397
|
}
|
|
226
398
|
this.goto(index);
|
|
227
399
|
}
|
|
228
400
|
|
|
229
401
|
/**
|
|
230
|
-
* Pause
|
|
402
|
+
* Pause at current playback position.
|
|
231
403
|
*/
|
|
232
404
|
pause() {
|
|
405
|
+
if (this.#isDestroyed || !this.#isStarted) return;
|
|
233
406
|
this.#audio.pause();
|
|
234
|
-
this
|
|
407
|
+
this.#isPlaying = false;
|
|
235
408
|
}
|
|
236
409
|
|
|
237
410
|
/**
|
|
238
|
-
* Resume
|
|
239
|
-
*
|
|
411
|
+
* Resume from paused position. No-op if not paused.
|
|
412
|
+
*
|
|
413
|
+
* #isPlaying is set true synchronously before the Promise, then reverted in
|
|
414
|
+
* .catch() if play() fails — closes the race where a concurrent stop() call
|
|
415
|
+
* in the .then() window would leave isPlaying incorrectly true.
|
|
240
416
|
*/
|
|
241
417
|
resume() {
|
|
242
|
-
if (!this.#audio.paused) return;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
418
|
+
if (this.#isDestroyed || !this.#isStarted || !this.#audio.paused) return;
|
|
419
|
+
// Set before the async boundary; revert in .catch() on failure.
|
|
420
|
+
this.#isPlaying = true;
|
|
421
|
+
this.#audio.play()
|
|
422
|
+
.catch(err => {
|
|
423
|
+
this.#isPlaying = false;
|
|
424
|
+
if (err.name !== 'AbortError') {
|
|
425
|
+
console.warn('SequentialAudio: resume() failed:', err);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
246
428
|
}
|
|
247
429
|
|
|
248
430
|
/**
|
|
249
|
-
*
|
|
431
|
+
* Pause and rewind current clip to its start.
|
|
432
|
+
* Also cancels any pending auto-advance timer so the next clip does not
|
|
433
|
+
* start after stop() returns. Sets #playCancelled so that any in-flight
|
|
434
|
+
* silent-MP3 unlock will not proceed to #playClip() when it resolves.
|
|
250
435
|
*/
|
|
251
436
|
stop() {
|
|
437
|
+
if (this.#isDestroyed) return;
|
|
438
|
+
// Signal the async unlock not to proceed if it is still in flight.
|
|
439
|
+
this.#playCancelled = true;
|
|
440
|
+
// Cancel any pending auto-advance before pausing.
|
|
441
|
+
if (this.#advanceTimer !== null) {
|
|
442
|
+
clearTimeout(this.#advanceTimer);
|
|
443
|
+
this.#advanceTimer = null;
|
|
444
|
+
}
|
|
252
445
|
this.#audio.pause();
|
|
253
446
|
this.#audio.currentTime = 0;
|
|
254
|
-
this
|
|
447
|
+
this.#isPlaying = false;
|
|
255
448
|
}
|
|
256
449
|
|
|
257
450
|
/**
|
|
258
|
-
*
|
|
451
|
+
* Stop and reset sequence to clip 0.
|
|
452
|
+
* isStarted is cleared — next play() will re-run the unlock.
|
|
453
|
+
* Also cancels any pending auto-advance timer.
|
|
259
454
|
*/
|
|
260
455
|
reset() {
|
|
261
|
-
this
|
|
456
|
+
if (this.#isDestroyed) return;
|
|
457
|
+
this.stop(); // stop() clears #advanceTimer
|
|
262
458
|
this.#currentIndex = 0;
|
|
263
|
-
this
|
|
459
|
+
this.#isStarted = false;
|
|
264
460
|
}
|
|
265
461
|
|
|
266
462
|
/**
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
463
|
+
* Release the Audio element and remove all event listeners.
|
|
464
|
+
* Cancels any pending auto-advance timer.
|
|
465
|
+
* Call this on SPA component unmount to prevent memory leaks.
|
|
466
|
+
* All method calls after destroy() are safe no-ops.
|
|
270
467
|
*/
|
|
271
|
-
|
|
272
|
-
|
|
468
|
+
destroy() {
|
|
469
|
+
if (this.#isDestroyed) return;
|
|
470
|
+
this.#isDestroyed = true;
|
|
471
|
+
// Cancel any pending auto-advance before teardown.
|
|
472
|
+
if (this.#advanceTimer !== null) {
|
|
473
|
+
clearTimeout(this.#advanceTimer);
|
|
474
|
+
this.#advanceTimer = null;
|
|
475
|
+
}
|
|
476
|
+
this.#audio.pause();
|
|
477
|
+
this.#audio.removeEventListener('ended', this.#endedHandler);
|
|
478
|
+
// WHATWG-specified resource-release sequence: removeAttribute('src') +
|
|
479
|
+
// load() aborts any in-progress network fetch and resets the media element
|
|
480
|
+
// state machine cleanly, avoiding the spurious 'error' events that
|
|
481
|
+
// src = '' can fire on some browsers.
|
|
482
|
+
this.#audio.removeAttribute('src');
|
|
483
|
+
this.#audio.load();
|
|
484
|
+
this.#audio = null;
|
|
273
485
|
}
|
|
274
486
|
|
|
275
487
|
/**
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
* @returns {
|
|
488
|
+
* Returns a copy of the current clip object { src, label }.
|
|
489
|
+
* A copy is returned to prevent callers from mutating the internal playlist.
|
|
490
|
+
* @returns {{ src: string, label: string }}
|
|
279
491
|
*/
|
|
280
|
-
|
|
281
|
-
return this.#currentIndex;
|
|
282
|
-
}
|
|
492
|
+
getCurrentClip() { return { ...this.#clips[this.#currentIndex] }; }
|
|
283
493
|
|
|
284
|
-
/**
|
|
285
|
-
|
|
286
|
-
*
|
|
287
|
-
* @returns {number}
|
|
288
|
-
*/
|
|
289
|
-
getClipCount() {
|
|
290
|
-
return this.#clips.length;
|
|
291
|
-
}
|
|
494
|
+
/** @returns {number} */
|
|
495
|
+
getCurrentIndex() { return this.#currentIndex; }
|
|
292
496
|
|
|
293
|
-
/**
|
|
294
|
-
|
|
295
|
-
*
|
|
296
|
-
* @returns {Array}
|
|
297
|
-
*/
|
|
298
|
-
getClips() {
|
|
299
|
-
return [...this.#clips];
|
|
300
|
-
}
|
|
497
|
+
/** @returns {number} */
|
|
498
|
+
getClipCount() { return this.#clips.length; }
|
|
301
499
|
|
|
302
500
|
/**
|
|
303
|
-
*
|
|
501
|
+
* Returns a deep copy of the clips array.
|
|
502
|
+
* Inner objects are copied to prevent callers from mutating the playlist.
|
|
503
|
+
* @returns {Array<{ src: string, label: string }>}
|
|
304
504
|
*/
|
|
305
|
-
|
|
306
|
-
this.stop();
|
|
307
|
-
this.#audio.src = '';
|
|
308
|
-
this.#audio = null;
|
|
309
|
-
}
|
|
505
|
+
getClips() { return this.#clips.map(c => ({ ...c })); }
|
|
310
506
|
|
|
311
507
|
/**
|
|
312
|
-
* Check browser
|
|
313
|
-
*
|
|
314
|
-
* @param {string} type - MIME type (e.g., 'audio/mpeg')
|
|
508
|
+
* Check whether the browser can likely play a given audio MIME type.
|
|
509
|
+
* @param {string} type e.g. 'audio/mpeg', 'audio/ogg', 'audio/wav'
|
|
315
510
|
* @returns {boolean}
|
|
316
511
|
*/
|
|
317
512
|
static canPlay(type) {
|
|
318
|
-
|
|
319
|
-
return
|
|
513
|
+
if (typeof type !== 'string' || !type.trim()) return false;
|
|
514
|
+
return new Audio().canPlayType(type) !== '';
|
|
320
515
|
}
|
|
321
516
|
|
|
322
|
-
// ──
|
|
517
|
+
// ── PRIVATE ─────────────────────────────────────────────────────────────────
|
|
323
518
|
|
|
519
|
+
/**
|
|
520
|
+
* Load and play the clip at #currentIndex on the shared Audio element.
|
|
521
|
+
* Called only after the element has been unlocked via play().
|
|
522
|
+
*
|
|
523
|
+
* #isPlaying and #isStarted are set true synchronously before #audio.play()
|
|
524
|
+
* to close the race window where a rapid next()/goto() call between the
|
|
525
|
+
* .play() call and its .then() resolution could bypass guards and attempt
|
|
526
|
+
* concurrent playback. #isPlaying is reverted in .catch() on real failures.
|
|
527
|
+
*/
|
|
324
528
|
#playClip() {
|
|
325
|
-
if (this.#
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
529
|
+
if (this.#isDestroyed) return;
|
|
328
530
|
|
|
329
531
|
const clip = this.#clips[this.#currentIndex];
|
|
330
532
|
|
|
331
|
-
|
|
533
|
+
this.#audio.src = clip.src;
|
|
332
534
|
this.#audio.currentTime = 0;
|
|
333
|
-
this.#audio.
|
|
535
|
+
this.#audio.volume = this.#volume;
|
|
334
536
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
);
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
537
|
+
// Set before the async boundary to close the double-play race window.
|
|
538
|
+
this.#isPlaying = true;
|
|
539
|
+
this.#isStarted = true;
|
|
540
|
+
|
|
541
|
+
this.#audio.play()
|
|
542
|
+
.then(() => {
|
|
543
|
+
if (this.#isDestroyed) return;
|
|
544
|
+
try { if (this.#onPlay) this.#onPlay({ ...clip }); } catch (e) {
|
|
545
|
+
console.warn(`SequentialAudio: onPlay callback error (${clip.label}):`, e);
|
|
546
|
+
}
|
|
547
|
+
})
|
|
548
|
+
.catch(err => {
|
|
549
|
+
this.#isPlaying = false; // revert the optimistic flag on failure
|
|
550
|
+
if (err.name === 'AbortError' || this.#isDestroyed) return;
|
|
551
|
+
if (err.name === 'NotAllowedError') {
|
|
552
|
+
console.warn(
|
|
553
|
+
`SequentialAudio: play() blocked by autoplay policy for "${clip.label}". ` +
|
|
554
|
+
`Call play() again inside a user gesture to resume.`
|
|
555
|
+
);
|
|
556
|
+
} else {
|
|
557
|
+
console.warn(`SequentialAudio: play() failed for "${clip.label}".\nError:`, err);
|
|
558
|
+
}
|
|
559
|
+
});
|
|
353
560
|
}
|
|
354
561
|
}
|
|
355
562
|
|
|
@@ -373,31 +580,32 @@ document.getElementById('next-btn').addEventListener('click', () => player.next(
|
|
|
373
580
|
// ── With options ──────────────────────────────────────────────────────────────
|
|
374
581
|
|
|
375
582
|
const player = new SequentialAudio(clips, {
|
|
376
|
-
autoAdvance:
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
583
|
+
autoAdvance: true, // Auto-play next clip when current finishes
|
|
584
|
+
advanceDelay: 1.0, // 1 second gap between auto-advance clips
|
|
585
|
+
loop: true, // Loop back to beginning when sequence ends
|
|
586
|
+
volume: 0.9,
|
|
587
|
+
onPlay: (clip) => console.log(`Playing: ${clip.label}`),
|
|
588
|
+
onEnd: (clip) => console.log(`Finished: ${clip.label}`),
|
|
589
|
+
onComplete: () => console.log('Sequence complete!')
|
|
381
590
|
});
|
|
382
591
|
|
|
383
592
|
|
|
384
593
|
// ── Guided tour ───────────────────────────────────────────────────────────────
|
|
385
594
|
|
|
386
595
|
const tour = new SequentialAudio([
|
|
387
|
-
{ src: 'welcome.mp3',
|
|
596
|
+
{ src: 'welcome.mp3', label: 'Welcome' },
|
|
388
597
|
{ src: 'feature1.mp3', label: 'Feature 1' },
|
|
389
598
|
{ src: 'feature2.mp3', label: 'Feature 2' },
|
|
390
|
-
{ src: 'goodbye.mp3',
|
|
599
|
+
{ src: 'goodbye.mp3', label: 'Goodbye' }
|
|
391
600
|
], {
|
|
392
|
-
onPlay:
|
|
393
|
-
|
|
394
|
-
onComplete: () => showCompletionMessage()
|
|
601
|
+
onPlay: (clip) => showStep(clip.label),
|
|
602
|
+
onComplete: () => showCompletionMessage()
|
|
395
603
|
});
|
|
396
604
|
|
|
397
605
|
document.addEventListener('click', () => tour.play(), { once: true });
|
|
398
606
|
document.getElementById('next-btn').addEventListener('click', () => tour.next());
|
|
399
607
|
document.getElementById('prev-btn').addEventListener('click', () => {
|
|
400
|
-
tour.goto(tour.getCurrentIndex() - 1);
|
|
608
|
+
tour.goto(Math.max(0, tour.getCurrentIndex() - 1));
|
|
401
609
|
});
|
|
402
610
|
|
|
403
611
|
|
|
@@ -408,29 +616,27 @@ const story = new SequentialAudio([
|
|
|
408
616
|
{ src: 'chapter2.mp3', label: 'Chapter 2' },
|
|
409
617
|
{ src: 'chapter3.mp3', label: 'Chapter 3' }
|
|
410
618
|
], {
|
|
411
|
-
autoAdvance: false,
|
|
412
|
-
onPlay:
|
|
413
|
-
onComplete:
|
|
619
|
+
autoAdvance: false,
|
|
620
|
+
onPlay: (clip) => updateUI(`Now reading: ${clip.label}`),
|
|
621
|
+
onComplete: () => showCongratulations()
|
|
414
622
|
});
|
|
415
623
|
|
|
416
|
-
|
|
417
|
-
document.getElementById('
|
|
418
|
-
document.getElementById('
|
|
419
|
-
document.getElementById('pause-btn').addEventListener('click', () => story.pause());
|
|
624
|
+
document.getElementById('play-btn').addEventListener('click', () => story.play());
|
|
625
|
+
document.getElementById('next-btn').addEventListener('click', () => story.next());
|
|
626
|
+
document.getElementById('pause-btn').addEventListener('click', () => story.pause());
|
|
420
627
|
document.getElementById('resume-btn').addEventListener('click', () => story.resume());
|
|
421
628
|
|
|
422
629
|
|
|
423
630
|
// ── Jump to clip by label ─────────────────────────────────────────────────────
|
|
424
631
|
|
|
425
|
-
player.gotoLabel('Chapter 2');
|
|
632
|
+
player.gotoLabel('Chapter 2');
|
|
426
633
|
|
|
427
634
|
|
|
428
635
|
// ── Get current progress ──────────────────────────────────────────────────────
|
|
429
636
|
|
|
430
637
|
const current = player.getCurrentClip();
|
|
431
|
-
const index
|
|
432
|
-
const total
|
|
433
|
-
|
|
638
|
+
const index = player.getCurrentIndex();
|
|
639
|
+
const total = player.getClipCount();
|
|
434
640
|
console.log(`Playing clip ${index + 1} of ${total}: ${current.label}`);
|
|
435
641
|
|
|
436
642
|
|
|
@@ -442,4 +648,14 @@ console.log(`Playing clip ${index + 1} of ${total}: ${current.label}`);
|
|
|
442
648
|
// return () => player.destroy();
|
|
443
649
|
// }, []);
|
|
444
650
|
|
|
651
|
+
// Vue:
|
|
652
|
+
// onUnmounted(() => player.destroy());
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
// ── Check format support ──────────────────────────────────────────────────────
|
|
656
|
+
|
|
657
|
+
if (!SequentialAudio.canPlay('audio/ogg')) {
|
|
658
|
+
console.warn('OGG not supported — use MP3 sources instead');
|
|
659
|
+
}
|
|
660
|
+
|
|
445
661
|
*/
|