openaudio-suite 2.4.0

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 ADDED
@@ -0,0 +1,386 @@
1
+ /**
2
+ * @file OpenAudio.js
3
+ * @author Rexore
4
+ * @version 1.1.0
5
+ * @license GPL-3.0-or-later
6
+ *
7
+ * Plays one audio file once, triggered by any user event.
8
+ *
9
+ * 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.
12
+ * - #isUnlocking guard: ignores rapid repeated calls during the async unlock.
13
+ * - Background tab detection: listens to the Page Visibility API
14
+ * (document.visibilitychange). Optionally pauses on hide and resumes on
15
+ * show (pauseOnHidden). Always fires onHidden / onVisible callbacks.
16
+ * Listener stored as #boundVisibility so destroy() removes the exact
17
+ * reference — no stale listener accumulation in SPAs.
18
+ * - Callbacks: onPlay, onEnd, onHidden, onVisible — all wrapped in try/catch
19
+ * so a throwing handler can never stall playback.
20
+ * - destroy(): removes the visibilitychange listener and releases the Audio
21
+ * element. Safe for SPA component teardown.
22
+ * - canPlay() static: check browser format support before constructing.
23
+ *
24
+ * ============================================================================
25
+ * QUICK START
26
+ * ============================================================================
27
+ *
28
+ * const player = new OpenAudio('audio/sound.mp3', {
29
+ * volume: 0.9,
30
+ * label: 'My Sound',
31
+ * pauseOnHidden: true, // pause when tab loses focus
32
+ * onPlay: () => console.log('playing'),
33
+ * onEnd: () => console.log('done'),
34
+ * onHidden: () => console.log('tab hidden'),
35
+ * onVisible: () => console.log('tab visible'),
36
+ * });
37
+ *
38
+ * // Trigger on any user gesture:
39
+ * 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
+ *
44
+ * ============================================================================
45
+ * BROWSER AUTOPLAY POLICY
46
+ * ============================================================================
47
+ *
48
+ * Call play() synchronously inside a user-initiated event handler.
49
+ * 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.
52
+ *
53
+ * ============================================================================
54
+ * BACKGROUND TAB DETECTION
55
+ * ============================================================================
56
+ *
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.
65
+ *
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.
78
+ *
79
+ * ============================================================================
80
+ * CHANGELOG
81
+ * ============================================================================
82
+ *
83
+ * 1.1.0
84
+ * - Background tab detection via Page Visibility API.
85
+ * - pauseOnHidden option: pause on hide, resume on show.
86
+ * - onHidden / onVisible callbacks, wrapped in try/catch.
87
+ * - #boundVisibility: stored bound reference for clean destroy() removal.
88
+ * - destroy() now removes the visibilitychange listener.
89
+ * - Class renamed from SingleAudio to OpenAudio to match filename.
90
+ *
91
+ * 1.0.0
92
+ * - Initial release. Single-clip, one-shot player.
93
+ * - Silent MP3 unlock, #isUnlocking guard, onPlay/onEnd callbacks,
94
+ * destroy(), canPlay() static.
95
+ *
96
+ * ============================================================================
97
+ */
98
+
99
+ class OpenAudio {
100
+
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==';
110
+
111
+ // ── Private fields ──────────────────────────────────────────────────────────
112
+ #src;
113
+ #label;
114
+ #volume;
115
+ #audio;
116
+ #onPlay;
117
+ #onEnd;
118
+ #onHidden;
119
+ #onVisible;
120
+ #pauseOnHidden;
121
+ #isUnlocking = false;
122
+ #pausedByVisibility = false;
123
+ #boundVisibility;
124
+
125
+ /**
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.
136
+ */
137
+ constructor(src, options = {}) {
138
+ if (!src || typeof src !== 'string') {
139
+ throw new TypeError('OpenAudio: src must be a non-empty string.');
140
+ }
141
+
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
+ 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;
160
+
161
+ // Single shared Audio element — created once, reused on replay.
162
+ this.#audio = new Audio();
163
+ this.#audio.volume = this.#volume;
164
+ this.#audio.preload = 'auto';
165
+ this.#audio.src = this.#src;
166
+
167
+ this.#audio.addEventListener('ended', () => {
168
+ this.isPlaying = false;
169
+ try { if (this.#onEnd) this.#onEnd(); } catch (e) {
170
+ console.warn(`OpenAudio: onEnd callback error (${this.#label}):`, e);
171
+ }
172
+ });
173
+
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.
177
+ this.#boundVisibility = this.#onVisibilityChange.bind(this);
178
+ document.addEventListener('visibilitychange', this.#boundVisibility);
179
+ }
180
+
181
+ // ── Public state ────────────────────────────────────────────────────────────
182
+
183
+ /** True while the clip is actively playing. */
184
+ isPlaying = false;
185
+
186
+ // ── Public API ──────────────────────────────────────────────────────────────
187
+
188
+ /**
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.
191
+ *
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.
194
+ */
195
+ play() {
196
+ if (this.isPlaying || this.#isUnlocking) return;
197
+
198
+ this.#isUnlocking = true;
199
+
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;
209
+ this.#playClip();
210
+ });
211
+ }
212
+
213
+ /**
214
+ * Stops playback and rewinds to the start.
215
+ */
216
+ stop() {
217
+ this.#audio.pause();
218
+ this.#audio.currentTime = 0;
219
+ this.isPlaying = false;
220
+ this.#pausedByVisibility = false;
221
+ }
222
+
223
+ /**
224
+ * Removes the visibilitychange listener and releases the Audio element.
225
+ * Call on SPA component unmount. Do not call any other methods after destroy().
226
+ */
227
+ destroy() {
228
+ document.removeEventListener('visibilitychange', this.#boundVisibility);
229
+ this.stop();
230
+ this.#audio.src = '';
231
+ this.#audio = null;
232
+ }
233
+
234
+ /**
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'
239
+ * @returns {boolean}
240
+ */
241
+ static canPlay(type) {
242
+ const result = new Audio().canPlayType(type);
243
+ return result === 'probably' || result === 'maybe';
244
+ }
245
+
246
+ // ── Private ─────────────────────────────────────────────────────────────────
247
+
248
+ /**
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.
255
+ *
256
+ * On show:
257
+ * - Fires onVisible callback.
258
+ * - If pauseOnHidden is true and #pausedByVisibility is set, resumes
259
+ * playback from the same position.
260
+ */
261
+ #onVisibilityChange() {
262
+ if (document.visibilityState === 'hidden') {
263
+
264
+ try { if (this.#onHidden) this.#onHidden(); } catch (e) {
265
+ console.warn(`OpenAudio: onHidden callback error (${this.#label}):`, e);
266
+ }
267
+
268
+ if (this.#pauseOnHidden && this.isPlaying) {
269
+ this.#audio.pause();
270
+ this.#pausedByVisibility = true;
271
+ }
272
+
273
+ } else if (document.visibilityState === 'visible') {
274
+
275
+ try { if (this.#onVisible) this.#onVisible(); } catch (e) {
276
+ console.warn(`OpenAudio: onVisible callback error (${this.#label}):`, e);
277
+ }
278
+
279
+ if (this.#pauseOnHidden && this.#pausedByVisibility) {
280
+ 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
+ });
287
+ }
288
+ }
289
+ }
290
+
291
+ #playClip() {
292
+ // Rewind in case it was played before.
293
+ this.#audio.currentTime = 0;
294
+ this.#audio.volume = this.#volume;
295
+ 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
+ });
314
+ }
315
+ }
316
+
317
+
318
+ // ── USAGE EXAMPLES ────────────────────────────────────────────────────────────
319
+
320
+ /*
321
+
322
+ // ── Minimal ───────────────────────────────────────────────────────────────────
323
+
324
+ const player = new OpenAudio('audio/chime.mp3');
325
+ document.getElementById('btn').addEventListener('click', () => player.play());
326
+
327
+
328
+ // ── With background tab options ───────────────────────────────────────────────
329
+
330
+ const player = new OpenAudio('audio/chime.mp3', {
331
+ volume: 0.8,
332
+ label: 'Chime',
333
+ pauseOnHidden: true, // pause when tab loses focus
334
+ onPlay: () => console.log('playing'),
335
+ onEnd: () => console.log('done'),
336
+ onHidden: () => console.log('tab hidden — audio paused'),
337
+ onVisible: () => console.log('tab visible — audio resumed'),
338
+ });
339
+
340
+
341
+ // ── Callbacks only, no auto-pause ─────────────────────────────────────────────
342
+
343
+ // Audio keeps playing in background; only UI is updated.
344
+ const player = new OpenAudio('audio/ambient.mp3', {
345
+ onHidden: () => updateUI('background'),
346
+ onVisible: () => updateUI('foreground'),
347
+ });
348
+
349
+
350
+ // ── One-shot on any gesture ───────────────────────────────────────────────────
351
+
352
+ document.addEventListener('click', () => player.play(), { once: true });
353
+ document.addEventListener('keydown', () => player.play(), { once: true });
354
+ document.addEventListener('touchstart', () => player.play(), { once: true });
355
+
356
+
357
+ // ── Replay ────────────────────────────────────────────────────────────────────
358
+
359
+ // play() rewinds and replays if called again after the clip has ended.
360
+ document.getElementById('replay-btn').addEventListener('click', () => player.play());
361
+
362
+
363
+ // ── Stop mid-playback ─────────────────────────────────────────────────────────
364
+
365
+ document.getElementById('stop-btn').addEventListener('click', () => player.stop());
366
+
367
+
368
+ // ── Check format support ──────────────────────────────────────────────────────
369
+
370
+ if (!OpenAudio.canPlay('audio/ogg')) {
371
+ console.warn('OGG not supported — use an MP3 source instead.');
372
+ }
373
+
374
+
375
+ // ── SPA teardown (React, Vue, etc.) ──────────────────────────────────────────
376
+
377
+ // React:
378
+ // useEffect(() => {
379
+ // const player = new OpenAudio('audio/chime.mp3', { pauseOnHidden: true });
380
+ // return () => player.destroy(); // removes visibilitychange listener on unmount
381
+ // }, []);
382
+
383
+ // Vue:
384
+ // onUnmounted(() => player.destroy());
385
+
386
+ */