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/OpenAudio_s.js CHANGED
@@ -1,8 +1,22 @@
1
1
  /**
2
2
  * @file OpenAudio_s.js
3
3
  * @author Rexore
4
- * @version 1.0.0
5
- * @license GPL-3.0-or-later
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 browser autoplay policy
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', label: 'Introduction' },
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, // Require click to go to next clip
46
+ * autoAdvance: false,
34
47
  * onPlay: (clip) => console.log(`Playing: ${clip.label}`),
35
48
  * onEnd: (clip) => console.log(`Finished: ${clip.label}`),
36
- * onComplete: () => console.log('Sequence complete!')
49
+ * onComplete: () => console.log('Sequence complete!')
37
50
  * });
38
51
  *
39
- * // Start sequence
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
- * Subsequent calls to next() can happen anytime after the first unlock.
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
- class SequentialAudio {
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
- // ── Silent 1-second MP3 used only to unlock the audio element ──────────────
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
- #isUnlocking = false;
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} clips - Array of clip objects with src and label.
84
- * @param {object} [options]
85
- * @param {boolean} [options.autoAdvance=false] - Auto-play next clip after current ends.
86
- * @param {boolean} [options.loop=false] - Loop sequence when complete.
87
- * @param {Function} [options.onPlay] - Called when clip starts.
88
- * @param {Function} [options.onEnd] - Called when clip ends.
89
- * @param {Function} [options.onComplete] - Called when sequence finishes.
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
- // Validate all clips have src
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.#autoAdvance = autoAdvance;
114
- this.#loop = loop;
115
- this.#onPlay = onPlay;
116
- this.#onEnd = onEnd;
117
- this.#onComplete = onComplete;
118
-
119
- // Single shared Audio element
120
- this.#audio = new Audio();
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
- this.#audio.addEventListener('ended', () => {
124
- this.isPlaying = false;
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
- setTimeout(() => this.next(), 500); // Small delay for better UX
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
- // ── Public state ────────────────────────────────────────────────────────────
287
+ this.#audio.addEventListener('ended', this.#endedHandler);
288
+ }
139
289
 
140
- /** True while a clip is actively playing. */
141
- isPlaying = false;
290
+ // ── PUBLIC API ──────────────────────────────────────────────────────────────
142
291
 
143
- /** True after first play() call (sequence has started). */
144
- isStarted = false;
292
+ /** @returns {boolean} True while a clip is actively playing. Read-only. */
293
+ get isPlaying() { return this.#isPlaying; }
145
294
 
146
- // ── Public API ──────────────────────────────────────────────────────────────
295
+ /** @returns {boolean} True after the first successful play(). Read-only. */
296
+ get isStarted() { return this.#isStarted; }
147
297
 
148
298
  /**
149
- * Unlock the audio element (if needed) and play the first clip.
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
- * Safe to call repeatedly already-playing calls are ignored.
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.isPlaying || this.#isUnlocking) return;
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
- // Play silent MP3 to unlock
160
- const unlock = new Audio(SequentialAudio.#SILENT_MP3);
161
- unlock.play().then(() => {
162
- this.#isUnlocking = false;
163
- this.#playClip();
164
- }).catch(() => {
165
- this.#isUnlocking = false;
166
- this.#playClip();
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
- * Wraps to beginning if at end (and loop is enabled).
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
- // Move to next clip
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
- // End: stop and call onComplete
188
- this.#currentIndex = this.#clips.length - 1; // Stay at last clip
189
- this.isPlaying = false;
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 specific clip by index and play it.
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 playback without resetting position.
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.isPlaying = false;
407
+ this.#isPlaying = false;
235
408
  }
236
409
 
237
410
  /**
238
- * Resume playback from current position.
239
- * If not paused, this has no effect.
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
- this.#audio.play().catch(err => {
244
- console.warn('SequentialAudio: resume() failed:', err);
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
- * Stop playback and rewind to beginning of current clip.
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.isPlaying = false;
447
+ this.#isPlaying = false;
255
448
  }
256
449
 
257
450
  /**
258
- * Reset sequence to first clip without playing.
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.stop();
456
+ if (this.#isDestroyed) return;
457
+ this.stop(); // stop() clears #advanceTimer
262
458
  this.#currentIndex = 0;
263
- this.isStarted = false;
459
+ this.#isStarted = false;
264
460
  }
265
461
 
266
462
  /**
267
- * Get the current clip object.
268
- *
269
- * @returns {object} Current clip { src, label, ... }
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
- getCurrentClip() {
272
- return this.#clips[this.#currentIndex];
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
- * Get current clip index.
277
- *
278
- * @returns {number}
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
- getCurrentIndex() {
281
- return this.#currentIndex;
282
- }
492
+ getCurrentClip() { return { ...this.#clips[this.#currentIndex] }; }
283
493
 
284
- /**
285
- * Get total number of clips.
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
- * Get all clips.
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
- * Removes all references. Call on SPA component unmount.
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
- destroy() {
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 support for audio MIME type.
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
- const result = new Audio().canPlayType(type);
319
- return result === 'probably' || result === 'maybe';
513
+ if (typeof type !== 'string' || !type.trim()) return false;
514
+ return new Audio().canPlayType(type) !== '';
320
515
  }
321
516
 
322
- // ── Private ─────────────────────────────────────────────────────────────────
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.#currentIndex < 0 || this.#currentIndex >= this.#clips.length) {
326
- return;
327
- }
529
+ if (this.#isDestroyed) return;
328
530
 
329
531
  const clip = this.#clips[this.#currentIndex];
330
532
 
331
- // Rewind to start
533
+ this.#audio.src = clip.src;
332
534
  this.#audio.currentTime = 0;
333
- this.#audio.src = clip.src;
535
+ this.#audio.volume = this.#volume;
334
536
 
335
- this.#audio.play().then(() => {
336
- this.isStarted = true;
337
- this.isPlaying = true;
338
- try { if (this.#onPlay) this.#onPlay(clip); } catch (e) {
339
- console.warn(`SequentialAudio: onPlay callback error (${clip.label}):`, e);
340
- }
341
- }).catch(err => {
342
- if (err.name === 'AbortError') return;
343
-
344
- if (err.name === 'NotAllowedError') {
345
- console.warn(
346
- `SequentialAudio: play() blocked by autoplay policy for "${clip.label}". ` +
347
- `Call play() again inside a user gesture.`
348
- );
349
- } else {
350
- console.warn(`SequentialAudio: play() failed for "${clip.label}".\nError:`, err);
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: true, // Auto-play next clip when current finishes
377
- loop: true, // Loop back to beginning when sequence ends
378
- onPlay: (clip) => console.log(`Playing: ${clip.label}`),
379
- onEnd: (clip) => console.log(`Finished: ${clip.label}`),
380
- onComplete: () => console.log('Sequence complete!')
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', label: 'Welcome' },
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', label: 'Goodbye' }
599
+ { src: 'goodbye.mp3', label: 'Goodbye' }
391
600
  ], {
392
- onPlay: (clip) => showStep(clip.label),
393
- onEnd: (clip) => console.log(`Finished: ${clip.label}`),
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, // User controls pacing
412
- onPlay: (clip) => updateUI(`Now reading: ${clip.label}`),
413
- onComplete: () => showCongratulations()
619
+ autoAdvance: false,
620
+ onPlay: (clip) => updateUI(`Now reading: ${clip.label}`),
621
+ onComplete: () => showCongratulations()
414
622
  });
415
623
 
416
- // Start, next, pause, resume controls
417
- document.getElementById('play-btn').addEventListener('click', () => story.play());
418
- document.getElementById('next-btn').addEventListener('click', () => story.next());
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'); // Jump to 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 = player.getCurrentIndex();
432
- const total = player.getClipCount();
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
  */