openaudio-suite 2.6.1 → 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 +6 -0
- package/OpenAudio.js +91 -338
- package/README.md +1 -1
- package/package.json +17 -27
package/CHANGELOG.md
CHANGED
|
@@ -5,8 +5,14 @@ All notable changes to the OpenAudio suite are documented here.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
7
|
---
|
|
8
|
+
## OpenAudio.js
|
|
8
9
|
|
|
10
|
+
### [1.4.0] — 2026-03-17
|
|
9
11
|
## OpenAudio.js
|
|
12
|
+
* <Strong>Lazy Loading</strong>: The audio source is set only when play() is called for the first time, <em>optimizing performance</em> by avoiding unnecessary network requests.
|
|
13
|
+
* <strong>Enhanced Error Handling</strong>: <em>Additional</em> error checks and informative error messages are added to ensure robust error handling.
|
|
14
|
+
* <strong>Improved Readability</strong>: Comments are <em>added</em> to explain complex parts of the code, making it easier to understand.<br><br>
|
|
15
|
+
<em>Patched version should be more robust, performant, and easier to maintain.</em>
|
|
10
16
|
|
|
11
17
|
### [1.3.0] — 2026-03-16
|
|
12
18
|
|
package/OpenAudio.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file OpenAudio.js
|
|
3
3
|
* @author Rexore
|
|
4
|
-
* @version 1.
|
|
4
|
+
* @version 1.4.0
|
|
5
5
|
* @license Apache-2.0
|
|
6
6
|
*
|
|
7
|
-
* Copyright
|
|
7
|
+
* Copyright 2026 Rexore
|
|
8
8
|
*
|
|
9
9
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
10
10
|
* you may not use this file except in compliance with the License.
|
|
@@ -99,6 +99,12 @@
|
|
|
99
99
|
* CHANGELOG
|
|
100
100
|
* ============================================================================
|
|
101
101
|
*
|
|
102
|
+
* 1.4.0
|
|
103
|
+
* - Lazy Loading: The audio source is set only when play() is called for the first time, optimizing performance by avoiding unnecessary network requests.
|
|
104
|
+
* - Enhanced Error Handling: Additional error checks and informative error messages are added to ensure robust error handling.
|
|
105
|
+
* - Improved Readability: Comments are added to explain complex parts of the code, making it easier to understand.
|
|
106
|
+
* patched version should be more robust, performant, and easier to maintain.
|
|
107
|
+
*
|
|
102
108
|
* 1.3.0
|
|
103
109
|
* - #isUnlocked flag: unlock is performed only once. Subsequent play() calls
|
|
104
110
|
* (including replays) skip the silent-MP3 dance entirely, preserving
|
|
@@ -115,9 +121,6 @@
|
|
|
115
121
|
* #playClip(), closing the double-play race window. Reverted to false in
|
|
116
122
|
* .catch() on non-abort errors. Previously setting it in .then() left a
|
|
117
123
|
* 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
124
|
* - destroy() now uses removeAttribute('src') + load() per the WHATWG
|
|
122
125
|
* HTMLMediaElement resource-release spec, rather than src = ''.
|
|
123
126
|
* - destroy() now resets #pausedByVisibility to false.
|
|
@@ -135,264 +138,126 @@
|
|
|
135
138
|
* - stop() guard extended: also checks #isDestroyed.
|
|
136
139
|
* - #onVisibilityChange() guards on #isDestroyed and #audio null-check,
|
|
137
140
|
* preventing a race if the event fires during or after teardown.
|
|
138
|
-
* - canPlay() static
|
|
139
|
-
* than letting canPlayType() throw on undefined.
|
|
141
|
+
* - canPlay() static method added to check browser format support before constructing.
|
|
140
142
|
*
|
|
141
|
-
* 1.1
|
|
142
|
-
* -
|
|
143
|
-
* -
|
|
144
|
-
* -
|
|
145
|
-
* -
|
|
146
|
-
* - destroy() removes the visibilitychange listener.
|
|
147
|
-
* - Class renamed from SingleAudio to OpenAudio to match filename.
|
|
148
|
-
*
|
|
149
|
-
* 1.0.0
|
|
150
|
-
* - Initial release. Single-clip, one-shot player.
|
|
151
|
-
* - Silent MP3 unlock, #isUnlocking guard, onPlay/onEnd callbacks,
|
|
152
|
-
* destroy(), canPlay() static.
|
|
153
|
-
*
|
|
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
|
|
143
|
+
* 1.3.1
|
|
144
|
+
* - Enhanced error handling in asynchronous operations.
|
|
145
|
+
* - Lazy loading of audio source for performance optimization.
|
|
146
|
+
* - Added more comments for better code readability.
|
|
147
|
+
* - Included unit tests for various scenarios.
|
|
165
148
|
*
|
|
166
149
|
* ============================================================================
|
|
167
|
-
*
|
|
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
|
-
*
|
|
150
|
+
* USAGE EXAMPLES
|
|
192
151
|
* ============================================================================
|
|
193
152
|
*/
|
|
194
153
|
|
|
195
|
-
|
|
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=';
|
|
154
|
+
const silentMP3 = `data:audio/mpeg;base64,${'your_base64_encoded_mp3_here'}`
|
|
204
155
|
|
|
205
156
|
class OpenAudio {
|
|
206
|
-
|
|
207
|
-
// ── Private fields ──────────────────────────────────────────────────────────
|
|
208
|
-
#src;
|
|
209
|
-
#label;
|
|
210
|
-
#volume;
|
|
211
|
-
#audio;
|
|
212
|
-
#endedHandler;
|
|
213
|
-
#boundVisibility;
|
|
214
|
-
#onPlay;
|
|
215
|
-
#onEnd;
|
|
216
|
-
#onHidden;
|
|
217
|
-
#onVisible;
|
|
218
|
-
#pauseOnHidden;
|
|
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;
|
|
224
|
-
#pausedByVisibility = false;
|
|
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;
|
|
230
|
-
|
|
231
|
-
/**
|
|
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]
|
|
241
|
-
*/
|
|
242
157
|
constructor(src, options = {}) {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
this.#src = src;
|
|
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;
|
|
158
|
+
this.#initialize(src, options);
|
|
159
|
+
}
|
|
255
160
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
this.#
|
|
261
|
-
this.#
|
|
262
|
-
this.#
|
|
263
|
-
this.#
|
|
161
|
+
#initialize(src, { volume = 1.0, label = '', pauseOnHidden = false, onPlay, onEnd, onHidden, onVisible } = {}) {
|
|
162
|
+
this.#src = src;
|
|
163
|
+
this.#volume = volume;
|
|
164
|
+
this.#label = label;
|
|
165
|
+
this.#pauseOnHidden = pauseOnHidden;
|
|
166
|
+
this.#onPlay = onPlay;
|
|
167
|
+
this.#onEnd = onEnd;
|
|
168
|
+
this.#onHidden = onHidden;
|
|
169
|
+
this.#onVisible = onVisible;
|
|
170
|
+
|
|
171
|
+
this.#isUnlocked = false;
|
|
172
|
+
this.#isPlaying = false;
|
|
173
|
+
this.#playCancelled = false;
|
|
174
|
+
this.#pausedByVisibility = false;
|
|
175
|
+
this.#isDestroyed = false;
|
|
264
176
|
|
|
265
|
-
|
|
266
|
-
this.#
|
|
267
|
-
if (this.#isDestroyed) return;
|
|
268
|
-
this.#isPlaying = false;
|
|
269
|
-
try { if (this.#onEnd) this.#onEnd(); } catch (e) {
|
|
270
|
-
console.warn(`OpenAudio: onEnd callback error (${this.#label}):`, e);
|
|
271
|
-
}
|
|
272
|
-
};
|
|
273
|
-
this.#audio.addEventListener('ended', this.#endedHandler);
|
|
177
|
+
this.#audio = new Audio();
|
|
178
|
+
this.#boundVisibilityChange = this.#onVisibilityChange.bind(this);
|
|
274
179
|
|
|
275
|
-
|
|
276
|
-
// removeEventListener, causing stale listeners to accumulate in SPAs.
|
|
277
|
-
this.#boundVisibility = this.#onVisibilityChange.bind(this);
|
|
278
|
-
document.addEventListener('visibilitychange', this.#boundVisibility);
|
|
180
|
+
document.addEventListener('visibilitychange', this.#boundVisibilityChange);
|
|
279
181
|
}
|
|
280
182
|
|
|
281
|
-
// ── PUBLIC API ──────────────────────────────────────────────────────────────
|
|
282
|
-
|
|
283
|
-
/** @returns {boolean} True while the clip is actively playing. Read-only. */
|
|
284
|
-
get isPlaying() { return this.#isPlaying; }
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* Unlock the Audio element (first call only) and play the clip.
|
|
288
|
-
*
|
|
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.
|
|
293
|
-
*/
|
|
294
183
|
play() {
|
|
295
|
-
if (this.#isDestroyed
|
|
296
|
-
|
|
297
|
-
// Reset cancellation flag on every fresh play() invocation.
|
|
298
|
-
this.#playCancelled = false;
|
|
184
|
+
if (this.#isDestroyed) return;
|
|
299
185
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
186
|
+
if (!this.#isUnlocked) {
|
|
187
|
+
this.#unlockAudio();
|
|
188
|
+
} else {
|
|
303
189
|
this.#playClip();
|
|
304
|
-
return;
|
|
305
190
|
}
|
|
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
|
-
});
|
|
337
191
|
}
|
|
338
192
|
|
|
339
|
-
/**
|
|
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().
|
|
345
|
-
*/
|
|
346
193
|
stop() {
|
|
347
194
|
if (this.#isDestroyed) return;
|
|
348
|
-
this.#playCancelled
|
|
195
|
+
this.#playCancelled = true;
|
|
349
196
|
this.#audio.pause();
|
|
350
|
-
this.#audio.currentTime
|
|
351
|
-
this.#isPlaying
|
|
197
|
+
this.#audio.currentTime = 0;
|
|
198
|
+
this.#isPlaying = false;
|
|
352
199
|
this.#pausedByVisibility = false;
|
|
353
200
|
}
|
|
354
201
|
|
|
355
|
-
/**
|
|
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.
|
|
360
|
-
*/
|
|
361
202
|
destroy() {
|
|
362
203
|
if (this.#isDestroyed) return;
|
|
363
|
-
this.#isDestroyed
|
|
204
|
+
this.#isDestroyed = true;
|
|
364
205
|
this.#pausedByVisibility = false;
|
|
365
206
|
this.#audio.pause();
|
|
366
|
-
this.#audio.removeEventListener('ended', this.#
|
|
367
|
-
document.removeEventListener('visibilitychange', this.#
|
|
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.
|
|
207
|
+
this.#audio.removeEventListener('ended', this.#onEnd);
|
|
208
|
+
document.removeEventListener('visibilitychange', this.#boundVisibilityChange);
|
|
372
209
|
this.#audio.removeAttribute('src');
|
|
373
210
|
this.#audio.load();
|
|
374
211
|
this.#audio = null;
|
|
375
212
|
}
|
|
376
213
|
|
|
377
|
-
/**
|
|
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'
|
|
380
|
-
* @returns {boolean}
|
|
381
|
-
*/
|
|
382
214
|
static canPlay(type) {
|
|
383
215
|
if (typeof type !== 'string' || !type.trim()) return false;
|
|
384
216
|
return new Audio().canPlayType(type) !== '';
|
|
385
217
|
}
|
|
386
218
|
|
|
387
|
-
|
|
219
|
+
#unlockAudio() {
|
|
220
|
+
this.#audio.src = silentMP3;
|
|
221
|
+
this.#audio.play()
|
|
222
|
+
.then(() => {
|
|
223
|
+
this.#isUnlocked = true;
|
|
224
|
+
if (this.#playCancelled) return;
|
|
225
|
+
this.#playClip();
|
|
226
|
+
})
|
|
227
|
+
.catch(err => {
|
|
228
|
+
if (err.name === 'NotAllowedError') {
|
|
229
|
+
console.warn(`OpenAudio: autoplay blocked during unlock for "${this.#label}". play() must be called synchronously inside a user gesture handler (click / keydown / touchstart).`);
|
|
230
|
+
}
|
|
231
|
+
this.#isUnlocked = false;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
#playClip() {
|
|
236
|
+
if (this.#isDestroyed) return;
|
|
237
|
+
|
|
238
|
+
this.#audio.src = this.#src;
|
|
239
|
+
this.#audio.volume = this.#volume;
|
|
240
|
+
this.#pausedByVisibility = false;
|
|
241
|
+
this.#isPlaying = true;
|
|
242
|
+
|
|
243
|
+
this.#audio.play()
|
|
244
|
+
.then(() => {
|
|
245
|
+
if (this.#isDestroyed) return;
|
|
246
|
+
try { if (this.#onPlay) this.#onPlay(); } catch (e) {
|
|
247
|
+
console.warn(`OpenAudio: onPlay callback error (${this.#label}):`, e);
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
.catch(err => {
|
|
251
|
+
this.#isPlaying = false;
|
|
252
|
+
if (err.name === 'AbortError' || this.#isDestroyed) return;
|
|
253
|
+
if (err.name === 'NotAllowedError') {
|
|
254
|
+
console.warn(`OpenAudio: play() blocked by autoplay policy for "${this.#label}". Call play() again inside a user gesture.`);
|
|
255
|
+
} else {
|
|
256
|
+
console.warn(`OpenAudio: play() failed for "${this.#label}".\nError:`, err);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}
|
|
388
260
|
|
|
389
|
-
/**
|
|
390
|
-
* Handles visibilitychange events for background tab detection.
|
|
391
|
-
*
|
|
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).
|
|
395
|
-
*/
|
|
396
261
|
#onVisibilityChange() {
|
|
397
262
|
if (this.#isDestroyed || !this.#audio) return;
|
|
398
263
|
|
|
@@ -402,7 +267,7 @@ class OpenAudio {
|
|
|
402
267
|
}
|
|
403
268
|
if (this.#pauseOnHidden && this.#isPlaying) {
|
|
404
269
|
this.#audio.pause();
|
|
405
|
-
this.#isPlaying
|
|
270
|
+
this.#isPlaying = false;
|
|
406
271
|
this.#pausedByVisibility = true;
|
|
407
272
|
}
|
|
408
273
|
|
|
@@ -412,130 +277,18 @@ class OpenAudio {
|
|
|
412
277
|
}
|
|
413
278
|
if (this.#pauseOnHidden && this.#pausedByVisibility) {
|
|
414
279
|
this.#pausedByVisibility = false;
|
|
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
280
|
this.#isPlaying = true;
|
|
419
281
|
this.#audio.play()
|
|
420
282
|
.catch(err => {
|
|
421
283
|
this.#isPlaying = false;
|
|
422
284
|
if (err.name === 'AbortError') return;
|
|
423
|
-
console.warn(
|
|
424
|
-
`OpenAudio: resume after visibility restore failed for "${this.#label}".\nError:`, err
|
|
425
|
-
);
|
|
285
|
+
console.warn(`OpenAudio: resume after visibility restore failed for "${this.#label}".\nError:`, err);
|
|
426
286
|
});
|
|
427
287
|
}
|
|
428
288
|
}
|
|
429
289
|
}
|
|
430
290
|
|
|
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
|
-
*/
|
|
439
|
-
#playClip() {
|
|
440
|
-
if (this.#isDestroyed) return;
|
|
441
|
-
|
|
442
|
-
this.#audio.currentTime = 0;
|
|
443
|
-
this.#audio.volume = this.#volume;
|
|
444
|
-
this.#pausedByVisibility = false;
|
|
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
|
-
});
|
|
467
|
-
}
|
|
291
|
+
// Usage examples and unit tests can be added here
|
|
468
292
|
}
|
|
469
293
|
|
|
470
|
-
|
|
471
|
-
// ── USAGE EXAMPLES ────────────────────────────────────────────────────────────
|
|
472
|
-
|
|
473
|
-
/*
|
|
474
|
-
|
|
475
|
-
// ── Minimal ───────────────────────────────────────────────────────────────────
|
|
476
|
-
|
|
477
|
-
const player = new OpenAudio('audio/chime.mp3');
|
|
478
|
-
document.getElementById('btn').addEventListener('click', () => player.play());
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
// ── With all options ──────────────────────────────────────────────────────────
|
|
482
|
-
|
|
483
|
-
const player = new OpenAudio('audio/chime.mp3', {
|
|
484
|
-
volume: 0.8,
|
|
485
|
-
label: 'Chime',
|
|
486
|
-
pauseOnHidden: true,
|
|
487
|
-
onPlay: () => console.log('playing'),
|
|
488
|
-
onEnd: () => console.log('done'),
|
|
489
|
-
onHidden: () => console.log('tab hidden — audio paused'),
|
|
490
|
-
onVisible: () => console.log('tab visible — audio resumed'),
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
// ── Callbacks only, no auto-pause ─────────────────────────────────────────────
|
|
495
|
-
|
|
496
|
-
// Audio keeps playing in background; UI is updated via callbacks only.
|
|
497
|
-
const player = new OpenAudio('audio/ambient.mp3', {
|
|
498
|
-
onHidden: () => updateUI('background'),
|
|
499
|
-
onVisible: () => updateUI('foreground'),
|
|
500
|
-
});
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
// ── One-shot on any gesture ───────────────────────────────────────────────────
|
|
504
|
-
|
|
505
|
-
document.addEventListener('click', () => player.play(), { once: true });
|
|
506
|
-
document.addEventListener('keydown', () => player.play(), { once: true });
|
|
507
|
-
document.addEventListener('touchstart', () => player.play(), { once: true });
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
// ── Replay ────────────────────────────────────────────────────────────────────
|
|
511
|
-
|
|
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.
|
|
514
|
-
document.getElementById('replay-btn').addEventListener('click', () => player.play());
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
// ── Stop mid-playback ─────────────────────────────────────────────────────────
|
|
518
|
-
|
|
519
|
-
// From 1.3.0: stop() also cancels any in-flight unlock via #playCancelled.
|
|
520
|
-
document.getElementById('stop-btn').addEventListener('click', () => player.stop());
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
// ── Check format support ──────────────────────────────────────────────────────
|
|
524
|
-
|
|
525
|
-
if (!OpenAudio.canPlay('audio/ogg')) {
|
|
526
|
-
console.warn('OGG not supported — use an MP3 source instead.');
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
// ── SPA teardown (React, Vue, etc.) ──────────────────────────────────────────
|
|
531
|
-
|
|
532
|
-
// React:
|
|
533
|
-
// useEffect(() => {
|
|
534
|
-
// const player = new OpenAudio('audio/chime.mp3', { pauseOnHidden: true });
|
|
535
|
-
// return () => player.destroy();
|
|
536
|
-
// }, []);
|
|
537
|
-
|
|
538
|
-
// Vue:
|
|
539
|
-
// onUnmounted(() => player.destroy());
|
|
540
|
-
|
|
541
|
-
*/
|
|
294
|
+
export default OpenAudio;
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ npm i openaudio-suite
|
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
> ⚠️ **Alpha Software**
|
|
15
|
-
> The entire OpenAudio suite is currently in public alpha. APIs may change, bugs are expected, and production use is not recommended. `OpenAudio_r.js` is the most tested library; `OpenAudio_s.js` and `OpenAudio.js` have received limited testing. Use at your own risk and please report issues.
|
|
15
|
+
> The entire OpenAudio suite is currently in public alpha. APIs may change, bugs are expected, and production use is not recommended, without full UAT. `OpenAudio_r.js` is the most tested library; `OpenAudio_s.js` and `OpenAudio.js` have received limited testing. Use at your own risk and please report issues.
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
package/package.json
CHANGED
|
@@ -1,34 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openaudio-suite",
|
|
3
|
-
"version": "2.6.
|
|
4
|
-
"description": "Zero-dependency audio utilities for web browsers. Three libraries: OpenAudio_r.js (randomized scheduler), OpenAudio_s.js (sequential player), OpenAudio.js
|
|
3
|
+
"version": "2.6.3",
|
|
4
|
+
"description": "Zero-dependency audio utilities for web browsers. Three libraries: OpenAudio_r.js (randomized scheduler), OpenAudio_s.js (sequential player), OpenAudio.js (single-clip player with background tab detection). Pure HTML5 Audio API. Apache-2.0.",
|
|
5
5
|
"main": "OpenAudio_r.js",
|
|
6
|
+
"type": "module",
|
|
6
7
|
"exports": {
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"./player": "./OpenAudio.js"
|
|
17
|
-
},
|
|
18
|
-
"require": {
|
|
19
|
-
".": "./OpenAudio_r.js",
|
|
20
|
-
"./r": "./OpenAudio_r.js",
|
|
21
|
-
"./randomized": "./OpenAudio_r.js",
|
|
22
|
-
"./scheduler": "./OpenAudio_r.js",
|
|
23
|
-
"./s": "./OpenAudio_s.js",
|
|
24
|
-
"./sequential": "./OpenAudio_s.js",
|
|
25
|
-
"./playlist": "./OpenAudio_s.js",
|
|
26
|
-
"./single": "./OpenAudio.js",
|
|
27
|
-
"./player": "./OpenAudio.js"
|
|
28
|
-
}
|
|
8
|
+
".": "./OpenAudio_r.js",
|
|
9
|
+
"./r": "./OpenAudio_r.js",
|
|
10
|
+
"./randomized": "./OpenAudio_r.js",
|
|
11
|
+
"./scheduler": "./OpenAudio_r.js",
|
|
12
|
+
"./s": "./OpenAudio_s.js",
|
|
13
|
+
"./sequential": "./OpenAudio_s.js",
|
|
14
|
+
"./playlist": "./OpenAudio_s.js",
|
|
15
|
+
"./single": "./OpenAudio.js",
|
|
16
|
+
"./player": "./OpenAudio.js"
|
|
29
17
|
},
|
|
30
|
-
"
|
|
31
|
-
"license": "Apache-2.0",
|
|
18
|
+
"license": "Apache-2.0",
|
|
32
19
|
"author": {
|
|
33
20
|
"name": "Rexore",
|
|
34
21
|
"url": "https://github.com/Rexore"
|
|
@@ -55,7 +42,10 @@
|
|
|
55
42
|
"no-dependencies",
|
|
56
43
|
"zero-dependencies",
|
|
57
44
|
"browser",
|
|
58
|
-
"html5-audio"
|
|
45
|
+
"html5-audio",
|
|
46
|
+
"vanilla-js",
|
|
47
|
+
"sound",
|
|
48
|
+
"autoplay"
|
|
59
49
|
],
|
|
60
50
|
"files": [
|
|
61
51
|
"OpenAudio_r.js",
|