openaudio-suite 2.5.5 → 2.6.1

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.js CHANGED
@@ -1,15 +1,38 @@
1
1
  /**
2
2
  * @file OpenAudio.js
3
3
  * @author Rexore
4
- * @version 1.1.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
  * Plays one audio file once, triggered by any user event.
8
22
  *
9
23
  * Key behaviours:
10
- * - Silent MP3 unlock: satisfies browser autoplay policy on mobile/desktop
11
- * by playing a zero-length base64 MP3 synchronously inside the gesture.
24
+ * - Silent MP3 unlock on the shared element: satisfies browser autoplay
25
+ * policy on mobile/desktop by playing a base64 MP3 synchronously on the
26
+ * shared #audio element inside the gesture context. The unlock is performed
27
+ * only once (#isUnlocked flag); subsequent play() calls go directly to
28
+ * #playClip() without re-unlocking, preserving preloaded data and
29
+ * eliminating replay latency.
12
30
  * - #isUnlocking guard: ignores rapid repeated calls during the async unlock.
31
+ * - #playCancelled flag: stop() plants a cancellation token that the
32
+ * in-flight unlock .then() checks before calling #playClip(), preventing
33
+ * phantom playback after stop() is called during an unlock.
34
+ * - #isPlaying private field + getter: state is read-only externally,
35
+ * preventing callers from silently corrupting the state machine.
13
36
  * - Background tab detection: listens to the Page Visibility API
14
37
  * (document.visibilitychange). Optionally pauses on hide and resumes on
15
38
  * show (pauseOnHidden). Always fires onHidden / onVisible callbacks.
@@ -17,8 +40,10 @@
17
40
  * reference — no stale listener accumulation in SPAs.
18
41
  * - Callbacks: onPlay, onEnd, onHidden, onVisible — all wrapped in try/catch
19
42
  * so a throwing handler can never stall playback.
43
+ * - #isDestroyed flag: all public methods are safe no-ops after destroy().
20
44
  * - destroy(): removes the visibilitychange listener and releases the Audio
21
- * element. Safe for SPA component teardown.
45
+ * element via removeAttribute('src') + load() per the WHATWG spec resource-
46
+ * release pattern. Safe for SPA component teardown.
22
47
  * - canPlay() static: check browser format support before constructing.
23
48
  *
24
49
  * ============================================================================
@@ -28,18 +53,14 @@
28
53
  * const player = new OpenAudio('audio/sound.mp3', {
29
54
  * volume: 0.9,
30
55
  * label: 'My Sound',
31
- * pauseOnHidden: true, // pause when tab loses focus
56
+ * pauseOnHidden: true,
32
57
  * onPlay: () => console.log('playing'),
33
58
  * onEnd: () => console.log('done'),
34
59
  * onHidden: () => console.log('tab hidden'),
35
60
  * onVisible: () => console.log('tab visible'),
36
61
  * });
37
62
  *
38
- * // Trigger on any user gesture:
39
63
  * document.getElementById('btn').addEventListener('click', () => player.play());
40
- * document.addEventListener('click', () => player.play(), { once: true });
41
- * document.addEventListener('keydown', () => player.play(), { once: true });
42
- * document.addEventListener('touchstart', () => player.play(), { once: true });
43
64
  *
44
65
  * ============================================================================
45
66
  * BROWSER AUTOPLAY POLICY
@@ -47,45 +68,82 @@
47
68
  *
48
69
  * Call play() synchronously inside a user-initiated event handler.
49
70
  * Scroll does NOT qualify as a gesture in Chrome or Firefox.
50
- * play() internally fires a silent base64 MP3 to unlock the audio element
51
- * before playing the real clip required for iOS Safari compatibility.
71
+ *
72
+ * On the first call, play() sets #audio.src to a silent base64 MP3 and plays
73
+ * it on the shared element within the gesture context, blessing it for all
74
+ * future play() calls. On every subsequent call #isUnlocked is true, so
75
+ * play() calls #playClip() directly — no re-unlock, no preload invalidation.
76
+ *
77
+ * Previously a throwaway new Audio() was used for the unlock — this failed
78
+ * on iOS Safari because the throwaway element was blessed but #audio was not,
79
+ * causing NotAllowedError on the actual clip.
52
80
  *
53
81
  * ============================================================================
54
82
  * BACKGROUND TAB DETECTION
55
83
  * ============================================================================
56
84
  *
57
- * This engine listens to the Page Visibility API (document.visibilitychange).
58
- *
59
- * Behaviour depends on the pauseOnHidden option (default: false):
60
- *
61
- * pauseOnHidden: false (default)
62
- * Audio continues playing when the tab is hidden — browsers do not
63
- * throttle active audio playback, only setTimeout timers. onHidden and
64
- * onVisible still fire so you can update UI state if needed.
85
+ * pauseOnHidden: false (default)
86
+ * Audio continues when the tab is hidden. onHidden / onVisible still fire
87
+ * so you can update UI state if needed.
65
88
  *
66
- * pauseOnHidden: true
67
- * The clip is paused when the tab hides and resumed from the same
68
- * position when the tab returns to the foreground. Useful when audio
69
- * should only play while the page is visible (e.g. in-app sounds, game
70
- * audio). Note: after a resume, browsers may require the resume call
71
- * to be inside a gesture on stricter autoplay policies if the user
72
- * has not interacted since hiding, resume may be silently blocked.
73
- *
74
- * The visibilitychange listener is stored as a bound reference (#boundVisibility)
75
- * so that destroy() can pass the exact same function to removeEventListener.
76
- * An inline arrow function would create a new reference each time and could
77
- * not be removed, causing stale listeners to accumulate in SPAs.
89
+ * pauseOnHidden: true
90
+ * The clip is paused when the tab hides and resumed from the same position
91
+ * when it returns. If the browser's autoplay policy has changed between the
92
+ * initial unlock and the visibility-restore (e.g. page was reloaded in the
93
+ * background), resume may be blocked and a console warning will be emitted.
94
+ * In practice, on all major 2025 browsers (Chrome, Firefox, Safari, Edge),
95
+ * resuming a previously-playing element after a tab becomes visible again
96
+ * does not require a new user gesture.
78
97
  *
79
98
  * ============================================================================
80
99
  * CHANGELOG
81
100
  * ============================================================================
82
101
  *
102
+ * 1.3.0
103
+ * - #isUnlocked flag: unlock is performed only once. Subsequent play() calls
104
+ * (including replays) skip the silent-MP3 dance entirely, preserving
105
+ * preloaded data and eliminating per-replay latency. Previously every
106
+ * play() discarded buffered audio and re-fetched the real src.
107
+ * - #playCancelled flag: stop() plants a cancellation token before pausing.
108
+ * The in-flight unlock .then() checks the flag before calling #playClip(),
109
+ * preventing phantom playback when stop() is called during an async unlock.
110
+ * Previously stop() could not cancel an in-flight unlock.
111
+ * - isPlaying is now a private field (#isPlaying) exposed via a read-only
112
+ * getter. Previously it was a plain public property, allowing callers to
113
+ * silently corrupt the state machine.
114
+ * - #isPlaying is now set true synchronously before the .play() call in
115
+ * #playClip(), closing the double-play race window. Reverted to false in
116
+ * .catch() on non-abort errors. Previously setting it in .then() left a
117
+ * window where rapid play() calls could attempt concurrent playback.
118
+ * - Same pre-set/revert pattern applied to the visibility-resume .play()
119
+ * call in #onVisibilityChange(). Previously the .then() set isPlaying =
120
+ * true after stop() had already set it to false.
121
+ * - destroy() now uses removeAttribute('src') + load() per the WHATWG
122
+ * HTMLMediaElement resource-release spec, rather than src = ''.
123
+ * - destroy() now resets #pausedByVisibility to false.
124
+ * - Removed unreachable this.#audio?.pause() from #playClip() .then().
125
+ *
126
+ * 1.2.0
127
+ * - Unlock now plays the silent MP3 on the shared #audio element rather
128
+ * than a throwaway new Audio(). Previously the throwaway was blessed but
129
+ * #audio was not, causing NotAllowedError on iOS Safari for the real clip.
130
+ * - #isDestroyed flag: all public methods (play, stop, destroy) return
131
+ * immediately after destroy() has been called, making post-destroy calls
132
+ * safe no-ops rather than throws on the nulled #audio element.
133
+ * - destroy() now checks #isDestroyed to prevent double-destroy throwing.
134
+ * - play() guard extended: also checks #isDestroyed.
135
+ * - stop() guard extended: also checks #isDestroyed.
136
+ * - #onVisibilityChange() guards on #isDestroyed and #audio null-check,
137
+ * preventing a race if the event fires during or after teardown.
138
+ * - canPlay() static: now returns false for empty/non-string input rather
139
+ * than letting canPlayType() throw on undefined.
140
+ *
83
141
  * 1.1.0
84
142
  * - Background tab detection via Page Visibility API.
85
143
  * - pauseOnHidden option: pause on hide, resume on show.
86
144
  * - onHidden / onVisible callbacks, wrapped in try/catch.
87
145
  * - #boundVisibility: stored bound reference for clean destroy() removal.
88
- * - destroy() now removes the visibilitychange listener.
146
+ * - destroy() removes the visibilitychange listener.
89
147
  * - Class renamed from SingleAudio to OpenAudio to match filename.
90
148
  *
91
149
  * 1.0.0
@@ -94,223 +152,318 @@
94
152
  * destroy(), canPlay() static.
95
153
  *
96
154
  * ============================================================================
155
+ * CONFIGURATION OPTIONS
156
+ * ============================================================================
157
+ *
158
+ * volume {number} Playback volume 0.0–1.0 default: 1.0
159
+ * label {string} Name shown in console warnings default: src
160
+ * pauseOnHidden {boolean} Pause when tab hides, resume on show default: false
161
+ * onPlay {Function} Called when playback starts default: null
162
+ * onEnd {Function} Called when playback ends naturally default: null
163
+ * onHidden {Function} Called when the tab becomes hidden default: null
164
+ * onVisible {Function} Called when the tab becomes visible default: null
165
+ *
166
+ * ============================================================================
167
+ * PUBLIC API
168
+ * ============================================================================
169
+ *
170
+ * player.play() Unlock (first call only) and play the clip. Must be
171
+ * inside a gesture handler on first call. Rewinds and
172
+ * replays if called after the clip has already ended.
173
+ * Ignored while already playing, unlocking, or after
174
+ * destroy().
175
+ *
176
+ * player.stop() Pause and rewind to start. Cancels any in-flight
177
+ * unlock so the clip does not start after stop() returns.
178
+ * No-op after destroy().
179
+ *
180
+ * player.destroy() Remove visibilitychange listener, release Audio element
181
+ * per WHATWG spec (removeAttribute + load). Call on SPA
182
+ * component unmount. All subsequent calls are safe no-ops.
183
+ *
184
+ * player.isPlaying {boolean} Read-only. True while the clip is actively
185
+ * playing.
186
+ *
187
+ * ── STATIC UTILITY ──────────────────────────────────────────────────────────
188
+ *
189
+ * OpenAudio.canPlay(type) Returns true if the browser can likely play the
190
+ * given MIME type. Wraps canPlayType().
191
+ *
192
+ * ============================================================================
97
193
  */
98
194
 
99
- class OpenAudio {
195
+ // Silent 1-second MP3 — used to unlock the shared Audio element within the
196
+ // gesture context before the real clip is played. Played only once per
197
+ // instance (#isUnlocked ensures it is never repeated).
198
+ const _OPENAUDIO_SILENT_MP3 =
199
+ 'data:audio/mpeg;base64,SUQzBAAAAAABEVRYWFgAAAAtAAADY29tbWVudABCaWdTb3VuZEJhbmsgU291bmQgRWZmZWN0cyBMaWJyYXJ5Ly8v' +
200
+ 'VFNTTQAAAAALAAADAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAA' +
201
+ 'AP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP//' +
202
+ '//8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8A' +
203
+ 'AAAAAAAAAP////8=';
100
204
 
101
- // ── Silent 1-second MP3 used only to unlock the audio element ──────────────
102
- static #SILENT_MP3 =
103
- 'data:audio/mpeg;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4LjI5LjEwMAAAAAAA' +
104
- 'AAAAAP/7kGQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAADhgCg' +
105
- 'oKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKD///////' +
106
- '///////////////////////////////////////////////////////////AAAAAExhdmM1OC41' +
107
- 'NQAAAAAAAAAAAAAAA//uQZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWluZwAAAA8A' +
108
- 'AAAkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAv' +
109
- 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==';
205
+ class OpenAudio {
110
206
 
111
207
  // ── Private fields ──────────────────────────────────────────────────────────
112
208
  #src;
113
209
  #label;
114
210
  #volume;
115
211
  #audio;
212
+ #endedHandler;
213
+ #boundVisibility;
116
214
  #onPlay;
117
215
  #onEnd;
118
216
  #onHidden;
119
217
  #onVisible;
120
218
  #pauseOnHidden;
121
219
  #isUnlocking = false;
220
+ // True after the first successful user-gesture unlock. Subsequent play()
221
+ // calls skip the silent-MP3 dance entirely, preserving preloaded data.
222
+ #isUnlocked = false;
223
+ #isDestroyed = false;
122
224
  #pausedByVisibility = false;
123
- #boundVisibility;
225
+ // Written by stop() and checked by the async unlock .then() before it calls
226
+ // #playClip(), preventing phantom playback when stop() races an unlock.
227
+ #playCancelled = false;
228
+ // Backing field for the read-only isPlaying getter.
229
+ #isPlaying = false;
124
230
 
125
231
  /**
126
- * @param {string} src - Path or data URI for your audio file.
127
- * @param {object} [options]
128
- * @param {number} [options.volume=1.0] - Playback volume 0.0–1.0.
129
- * @param {string} [options.label=''] - Name shown in console warnings.
130
- * @param {boolean} [options.pauseOnHidden=false] - Pause when tab is hidden;
131
- * resume when it returns.
132
- * @param {Function} [options.onPlay] - Called when playback starts.
133
- * @param {Function} [options.onEnd] - Called when playback ends naturally.
134
- * @param {Function} [options.onHidden] - Called when the tab becomes hidden.
135
- * @param {Function} [options.onVisible] - Called when the tab becomes visible.
232
+ * @param {string} src
233
+ * @param {Object} [options={}]
234
+ * @param {number} [options.volume=1.0]
235
+ * @param {string} [options.label='']
236
+ * @param {boolean} [options.pauseOnHidden=false]
237
+ * @param {Function} [options.onPlay]
238
+ * @param {Function} [options.onEnd]
239
+ * @param {Function} [options.onHidden]
240
+ * @param {Function} [options.onVisible]
136
241
  */
137
242
  constructor(src, options = {}) {
138
- if (!src || typeof src !== 'string') {
243
+ if (!src || typeof src !== 'string' || !src.trim()) {
139
244
  throw new TypeError('OpenAudio: src must be a non-empty string.');
140
245
  }
141
246
 
142
- const {
143
- volume = 1.0,
144
- label = '',
145
- pauseOnHidden = false,
146
- onPlay = null,
147
- onEnd = null,
148
- onHidden = null,
149
- onVisible = null,
150
- } = options;
151
-
152
247
  this.#src = src;
153
- this.#label = label || src;
154
- this.#volume = Math.min(1, Math.max(0, volume));
155
- this.#pauseOnHidden = pauseOnHidden;
156
- this.#onPlay = onPlay;
157
- this.#onEnd = onEnd;
158
- this.#onHidden = onHidden;
159
- this.#onVisible = onVisible;
248
+ this.#label = options.label || src;
249
+ this.#volume = Math.min(1, Math.max(0, options.volume ?? 1.0));
250
+ this.#pauseOnHidden = options.pauseOnHidden ?? false;
251
+ this.#onPlay = options.onPlay || null;
252
+ this.#onEnd = options.onEnd || null;
253
+ this.#onHidden = options.onHidden || null;
254
+ this.#onVisible = options.onVisible || null;
160
255
 
161
256
  // Single shared Audio element — created once, reused on replay.
257
+ // Preloading the real src here so the browser can begin buffering before
258
+ // play() is called. Because #isUnlocked skips the re-unlock on subsequent
259
+ // play() calls, this preloaded data is now actually preserved and used.
162
260
  this.#audio = new Audio();
163
261
  this.#audio.volume = this.#volume;
164
262
  this.#audio.preload = 'auto';
165
263
  this.#audio.src = this.#src;
166
264
 
167
- this.#audio.addEventListener('ended', () => {
168
- this.isPlaying = false;
265
+ // Store handler reference so destroy() can remove it cleanly.
266
+ this.#endedHandler = () => {
267
+ if (this.#isDestroyed) return;
268
+ this.#isPlaying = false;
169
269
  try { if (this.#onEnd) this.#onEnd(); } catch (e) {
170
270
  console.warn(`OpenAudio: onEnd callback error (${this.#label}):`, e);
171
271
  }
172
- });
272
+ };
273
+ this.#audio.addEventListener('ended', this.#endedHandler);
173
274
 
174
- // Store the bound reference so destroy() removes the exact same function.
175
- // An inline arrow would create a new reference that removeEventListener
176
- // could never match — causing stale listeners to accumulate in SPAs.
275
+ // Stored bound reference an inline arrow cannot be removed by
276
+ // removeEventListener, causing stale listeners to accumulate in SPAs.
177
277
  this.#boundVisibility = this.#onVisibilityChange.bind(this);
178
278
  document.addEventListener('visibilitychange', this.#boundVisibility);
179
279
  }
180
280
 
181
- // ── Public state ────────────────────────────────────────────────────────────
182
-
183
- /** True while the clip is actively playing. */
184
- isPlaying = false;
281
+ // ── PUBLIC API ──────────────────────────────────────────────────────────────
185
282
 
186
- // ── Public API ──────────────────────────────────────────────────────────────
283
+ /** @returns {boolean} True while the clip is actively playing. Read-only. */
284
+ get isPlaying() { return this.#isPlaying; }
187
285
 
188
286
  /**
189
- * Unlocks the audio element (if needed) then plays the clip.
190
- * Must be called synchronously inside a user-gesture event handler on first use.
287
+ * Unlock the Audio element (first call only) and play the clip.
191
288
  *
192
- * Safe to call repeatedly ignored while already playing or unlocking.
193
- * Calling play() after the clip has ended rewinds and replays from the start.
289
+ * Must be called synchronously inside a user gesture on first use.
290
+ * Rewinds and replays from the start if the clip has already ended.
291
+ * Ignored while already playing, while the unlock is in progress,
292
+ * or after destroy() has been called.
194
293
  */
195
294
  play() {
196
- if (this.isPlaying || this.#isUnlocking) return;
295
+ if (this.#isDestroyed || this.#isPlaying || this.#isUnlocking) return;
197
296
 
198
- this.#isUnlocking = true;
297
+ // Reset cancellation flag on every fresh play() invocation.
298
+ this.#playCancelled = false;
199
299
 
200
- // Play the silent MP3 synchronously within the gesture context.
201
- // This unlocks the audio element for subsequent .play() calls on iOS/Chrome.
202
- const unlock = new Audio(OpenAudio.#SILENT_MP3);
203
- unlock.play().then(() => {
204
- this.#isUnlocking = false;
205
- this.#playClip();
206
- }).catch(() => {
207
- // Unlock failed — still attempt playback (desktop may not need it).
208
- this.#isUnlocking = false;
300
+ // Element already blessed skip the silent-MP3 unlock entirely.
301
+ // The real src is already loaded; #playClip() can call .play() directly.
302
+ if (this.#isUnlocked) {
209
303
  this.#playClip();
210
- });
304
+ return;
305
+ }
306
+
307
+ this.#isUnlocking = true;
308
+
309
+ // Unlock the SHARED element by playing the silent MP3 on it directly.
310
+ // A throwaway new Audio() would bless the wrong element and leave #audio
311
+ // blocked on iOS Safari.
312
+ this.#audio.src = _OPENAUDIO_SILENT_MP3;
313
+ this.#audio.volume = 0;
314
+ this.#audio.play()
315
+ .then(() => {
316
+ this.#isUnlocking = false;
317
+ // Honour a stop() call that arrived while the unlock was in progress.
318
+ if (this.#isDestroyed || this.#playCancelled) return;
319
+ // Mark permanently unlocked so future calls skip this block.
320
+ this.#isUnlocked = true;
321
+ this.#audio.src = this.#src;
322
+ this.#audio.volume = this.#volume;
323
+ this.#playClip();
324
+ })
325
+ .catch(err => {
326
+ this.#isUnlocking = false;
327
+ if (this.#isDestroyed) return;
328
+ if (err.name === 'NotAllowedError') {
329
+ console.warn(
330
+ `OpenAudio: autoplay blocked during unlock for "${this.#label}". ` +
331
+ 'play() must be called synchronously inside a user gesture handler ' +
332
+ '(click / keydown / touchstart).'
333
+ );
334
+ }
335
+ // AbortError or other — leave state clean, do not attempt playback.
336
+ });
211
337
  }
212
338
 
213
339
  /**
214
- * Stops playback and rewinds to the start.
340
+ * Stop playback and rewind to start.
341
+ *
342
+ * Also cancels any in-flight unlock via #playCancelled, so the clip will
343
+ * not start playing after stop() returns even if the async unlock .then()
344
+ * fires a few milliseconds later. No-op after destroy().
215
345
  */
216
346
  stop() {
347
+ if (this.#isDestroyed) return;
348
+ this.#playCancelled = true;
217
349
  this.#audio.pause();
218
- this.#audio.currentTime = 0;
219
- this.isPlaying = false;
350
+ this.#audio.currentTime = 0;
351
+ this.#isPlaying = false;
220
352
  this.#pausedByVisibility = false;
221
353
  }
222
354
 
223
355
  /**
224
- * Removes the visibilitychange listener and releases the Audio element.
225
- * Call on SPA component unmount. Do not call any other methods after destroy().
356
+ * Remove the visibilitychange listener and release the Audio element.
357
+ * Uses removeAttribute('src') + load() per the WHATWG HTMLMediaElement
358
+ * resource-release spec to abort any pending network activity cleanly.
359
+ * Call on SPA component unmount. All subsequent method calls are safe no-ops.
226
360
  */
227
361
  destroy() {
362
+ if (this.#isDestroyed) return;
363
+ this.#isDestroyed = true;
364
+ this.#pausedByVisibility = false;
365
+ this.#audio.pause();
366
+ this.#audio.removeEventListener('ended', this.#endedHandler);
228
367
  document.removeEventListener('visibilitychange', this.#boundVisibility);
229
- this.stop();
230
- this.#audio.src = '';
231
- this.#audio = null;
368
+ // WHATWG-specified resource-release sequence: removeAttribute('src') +
369
+ // load() aborts any in-progress network fetch and resets the media element
370
+ // state machine cleanly, avoiding the spurious 'error' events that
371
+ // src = '' can fire on some browsers.
372
+ this.#audio.removeAttribute('src');
373
+ this.#audio.load();
374
+ this.#audio = null;
232
375
  }
233
376
 
234
377
  /**
235
- * Returns true if the browser reports it can probably or maybe play the
236
- * given MIME type. Use before constructing to avoid silent format failures.
237
- *
238
- * @param {string} type e.g. 'audio/ogg', 'audio/wav', 'audio/mpeg'
378
+ * Check whether the browser can likely play a given audio MIME type.
379
+ * @param {string} type e.g. 'audio/mpeg', 'audio/ogg', 'audio/wav'
239
380
  * @returns {boolean}
240
381
  */
241
382
  static canPlay(type) {
242
- const result = new Audio().canPlayType(type);
243
- return result === 'probably' || result === 'maybe';
383
+ if (typeof type !== 'string' || !type.trim()) return false;
384
+ return new Audio().canPlayType(type) !== '';
244
385
  }
245
386
 
246
- // ── Private ─────────────────────────────────────────────────────────────────
387
+ // ── PRIVATE ─────────────────────────────────────────────────────────────────
247
388
 
248
389
  /**
249
- * Handles visibilitychange events.
250
- *
251
- * On hide:
252
- * - Fires onHidden callback.
253
- * - If pauseOnHidden is true and the clip is playing, pauses it and sets
254
- * #pausedByVisibility so the resume path knows to restore playback.
390
+ * Handles visibilitychange events for background tab detection.
255
391
  *
256
- * On show:
257
- * - Fires onVisible callback.
258
- * - If pauseOnHidden is true and #pausedByVisibility is set, resumes
259
- * playback from the same position.
392
+ * On hide: fires onHidden; pauses if pauseOnHidden is true and clip is playing.
393
+ * On show: fires onVisible; resumes if pauseOnHidden is true and clip was
394
+ * paused by this handler (#pausedByVisibility).
260
395
  */
261
396
  #onVisibilityChange() {
262
- if (document.visibilityState === 'hidden') {
397
+ if (this.#isDestroyed || !this.#audio) return;
263
398
 
399
+ if (document.visibilityState === 'hidden') {
264
400
  try { if (this.#onHidden) this.#onHidden(); } catch (e) {
265
401
  console.warn(`OpenAudio: onHidden callback error (${this.#label}):`, e);
266
402
  }
267
-
268
- if (this.#pauseOnHidden && this.isPlaying) {
403
+ if (this.#pauseOnHidden && this.#isPlaying) {
269
404
  this.#audio.pause();
405
+ this.#isPlaying = false;
270
406
  this.#pausedByVisibility = true;
271
407
  }
272
408
 
273
409
  } else if (document.visibilityState === 'visible') {
274
-
275
410
  try { if (this.#onVisible) this.#onVisible(); } catch (e) {
276
411
  console.warn(`OpenAudio: onVisible callback error (${this.#label}):`, e);
277
412
  }
278
-
279
413
  if (this.#pauseOnHidden && this.#pausedByVisibility) {
280
414
  this.#pausedByVisibility = false;
281
- this.#audio.play().catch(err => {
282
- if (err.name === 'AbortError') return;
283
- console.warn(
284
- `OpenAudio: resume after visibility restore failed for "${this.#label}".\nError:`, err
285
- );
286
- });
415
+ // Set #isPlaying true synchronously before the Promise, then revert in
416
+ // .catch() if play() fails — mirrors the #playClip() pattern, closing
417
+ // the race where a stop() .then() could overwrite stop()'s false.
418
+ this.#isPlaying = true;
419
+ this.#audio.play()
420
+ .catch(err => {
421
+ this.#isPlaying = false;
422
+ if (err.name === 'AbortError') return;
423
+ console.warn(
424
+ `OpenAudio: resume after visibility restore failed for "${this.#label}".\nError:`, err
425
+ );
426
+ });
287
427
  }
288
428
  }
289
429
  }
290
430
 
431
+ /**
432
+ * Play the real clip on the already-unlocked shared Audio element.
433
+ *
434
+ * #isPlaying is set true synchronously before the .play() call so that any
435
+ * rapid second play() call hits the isPlaying guard immediately, closing the
436
+ * race window that previously existed between the call and .then(). On
437
+ * failure, #isPlaying is reverted to false in .catch().
438
+ */
291
439
  #playClip() {
292
- // Rewind in case it was played before.
440
+ if (this.#isDestroyed) return;
441
+
293
442
  this.#audio.currentTime = 0;
294
443
  this.#audio.volume = this.#volume;
295
444
  this.#pausedByVisibility = false;
296
-
297
- this.#audio.play().then(() => {
298
- this.isPlaying = true;
299
- try { if (this.#onPlay) this.#onPlay(); } catch (e) {
300
- console.warn(`OpenAudio: onPlay callback error (${this.#label}):`, e);
301
- }
302
- }).catch(err => {
303
- if (err.name === 'AbortError') return;
304
-
305
- if (err.name === 'NotAllowedError') {
306
- console.warn(
307
- `OpenAudio: play() blocked by autoplay policy for "${this.#label}". ` +
308
- `Call play() again inside a user gesture.`
309
- );
310
- } else {
311
- console.warn(`OpenAudio: play() failed for "${this.#label}".\nError:`, err);
312
- }
313
- });
445
+ // Set before the async boundary to close the double-play race window.
446
+ this.#isPlaying = true;
447
+
448
+ this.#audio.play()
449
+ .then(() => {
450
+ if (this.#isDestroyed) return;
451
+ try { if (this.#onPlay) this.#onPlay(); } catch (e) {
452
+ console.warn(`OpenAudio: onPlay callback error (${this.#label}):`, e);
453
+ }
454
+ })
455
+ .catch(err => {
456
+ this.#isPlaying = false; // revert the optimistic flag on failure
457
+ if (err.name === 'AbortError' || this.#isDestroyed) return;
458
+ if (err.name === 'NotAllowedError') {
459
+ console.warn(
460
+ `OpenAudio: play() blocked by autoplay policy for "${this.#label}". ` +
461
+ 'Call play() again inside a user gesture.'
462
+ );
463
+ } else {
464
+ console.warn(`OpenAudio: play() failed for "${this.#label}".\nError:`, err);
465
+ }
466
+ });
314
467
  }
315
468
  }
316
469
 
@@ -325,12 +478,12 @@ const player = new OpenAudio('audio/chime.mp3');
325
478
  document.getElementById('btn').addEventListener('click', () => player.play());
326
479
 
327
480
 
328
- // ── With background tab options ───────────────────────────────────────────────
481
+ // ── With all options ──────────────────────────────────────────────────────────
329
482
 
330
483
  const player = new OpenAudio('audio/chime.mp3', {
331
484
  volume: 0.8,
332
485
  label: 'Chime',
333
- pauseOnHidden: true, // pause when tab loses focus
486
+ pauseOnHidden: true,
334
487
  onPlay: () => console.log('playing'),
335
488
  onEnd: () => console.log('done'),
336
489
  onHidden: () => console.log('tab hidden — audio paused'),
@@ -340,7 +493,7 @@ const player = new OpenAudio('audio/chime.mp3', {
340
493
 
341
494
  // ── Callbacks only, no auto-pause ─────────────────────────────────────────────
342
495
 
343
- // Audio keeps playing in background; only UI is updated.
496
+ // Audio keeps playing in background; UI is updated via callbacks only.
344
497
  const player = new OpenAudio('audio/ambient.mp3', {
345
498
  onHidden: () => updateUI('background'),
346
499
  onVisible: () => updateUI('foreground'),
@@ -356,12 +509,14 @@ document.addEventListener('touchstart', () => player.play(), { once: true });
356
509
 
357
510
  // ── Replay ────────────────────────────────────────────────────────────────────
358
511
 
359
- // play() rewinds and replays if called again after the clip has ended.
512
+ // play() rewinds and replays if the clip has already ended.
513
+ // From 1.3.0: replay skips the unlock entirely — preloaded data is preserved.
360
514
  document.getElementById('replay-btn').addEventListener('click', () => player.play());
361
515
 
362
516
 
363
517
  // ── Stop mid-playback ─────────────────────────────────────────────────────────
364
518
 
519
+ // From 1.3.0: stop() also cancels any in-flight unlock via #playCancelled.
365
520
  document.getElementById('stop-btn').addEventListener('click', () => player.stop());
366
521
 
367
522
 
@@ -377,7 +532,7 @@ if (!OpenAudio.canPlay('audio/ogg')) {
377
532
  // React:
378
533
  // useEffect(() => {
379
534
  // const player = new OpenAudio('audio/chime.mp3', { pauseOnHidden: true });
380
- // return () => player.destroy(); // removes visibilitychange listener on unmount
535
+ // return () => player.destroy();
381
536
  // }, []);
382
537
 
383
538
  // Vue: