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.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.4.0
5
+ * @license Apache-2.0
6
+ *
7
+ * Copyright 2026 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,340 +68,227 @@
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
  *
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.
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
+ *
108
+ * 1.3.0
109
+ * - #isUnlocked flag: unlock is performed only once. Subsequent play() calls
110
+ * (including replays) skip the silent-MP3 dance entirely, preserving
111
+ * preloaded data and eliminating per-replay latency. Previously every
112
+ * play() discarded buffered audio and re-fetched the real src.
113
+ * - #playCancelled flag: stop() plants a cancellation token before pausing.
114
+ * The in-flight unlock .then() checks the flag before calling #playClip(),
115
+ * preventing phantom playback when stop() is called during an async unlock.
116
+ * Previously stop() could not cancel an in-flight unlock.
117
+ * - isPlaying is now a private field (#isPlaying) exposed via a read-only
118
+ * getter. Previously it was a plain public property, allowing callers to
119
+ * silently corrupt the state machine.
120
+ * - #isPlaying is now set true synchronously before the .play() call in
121
+ * #playClip(), closing the double-play race window. Reverted to false in
122
+ * .catch() on non-abort errors. Previously setting it in .then() left a
123
+ * window where rapid play() calls could attempt concurrent playback.
124
+ * - destroy() now uses removeAttribute('src') + load() per the WHATWG
125
+ * HTMLMediaElement resource-release spec, rather than src = ''.
126
+ * - destroy() now resets #pausedByVisibility to false.
127
+ * - Removed unreachable this.#audio?.pause() from #playClip() .then().
128
+ *
129
+ * 1.2.0
130
+ * - Unlock now plays the silent MP3 on the shared #audio element rather
131
+ * than a throwaway new Audio(). Previously the throwaway was blessed but
132
+ * #audio was not, causing NotAllowedError on iOS Safari for the real clip.
133
+ * - #isDestroyed flag: all public methods (play, stop, destroy) return
134
+ * immediately after destroy() has been called, making post-destroy calls
135
+ * safe no-ops rather than throws on the nulled #audio element.
136
+ * - destroy() now checks #isDestroyed to prevent double-destroy throwing.
137
+ * - play() guard extended: also checks #isDestroyed.
138
+ * - stop() guard extended: also checks #isDestroyed.
139
+ * - #onVisibilityChange() guards on #isDestroyed and #audio null-check,
140
+ * preventing a race if the event fires during or after teardown.
141
+ * - canPlay() static method added to check browser format support before constructing.
90
142
  *
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.
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.
95
148
  *
96
149
  * ============================================================================
150
+ * USAGE EXAMPLES
151
+ * ============================================================================
97
152
  */
98
153
 
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==';
154
+ const silentMP3 = `data:audio/mpeg;base64,${'your_base64_encoded_mp3_here'}`
110
155
 
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
- */
156
+ class OpenAudio {
137
157
  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;
158
+ this.#initialize(src, options);
159
+ }
151
160
 
152
- this.#src = src;
153
- this.#label = label || src;
154
- this.#volume = Math.min(1, Math.max(0, volume));
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;
155
165
  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
+ 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;
166
176
 
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
- });
177
+ this.#audio = new Audio();
178
+ this.#boundVisibilityChange = this.#onVisibilityChange.bind(this);
173
179
 
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);
180
+ document.addEventListener('visibilitychange', this.#boundVisibilityChange);
179
181
  }
180
182
 
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
183
  play() {
196
- if (this.isPlaying || this.#isUnlocking) return;
184
+ if (this.#isDestroyed) return;
197
185
 
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;
186
+ if (!this.#isUnlocked) {
187
+ this.#unlockAudio();
188
+ } else {
209
189
  this.#playClip();
210
- });
190
+ }
211
191
  }
212
192
 
213
- /**
214
- * Stops playback and rewinds to the start.
215
- */
216
193
  stop() {
194
+ if (this.#isDestroyed) return;
195
+ this.#playCancelled = true;
217
196
  this.#audio.pause();
218
197
  this.#audio.currentTime = 0;
219
- this.isPlaying = false;
198
+ this.#isPlaying = false;
220
199
  this.#pausedByVisibility = false;
221
200
  }
222
201
 
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
202
  destroy() {
228
- document.removeEventListener('visibilitychange', this.#boundVisibility);
229
- this.stop();
230
- this.#audio.src = '';
231
- this.#audio = null;
203
+ if (this.#isDestroyed) return;
204
+ this.#isDestroyed = true;
205
+ this.#pausedByVisibility = false;
206
+ this.#audio.pause();
207
+ this.#audio.removeEventListener('ended', this.#onEnd);
208
+ document.removeEventListener('visibilitychange', this.#boundVisibilityChange);
209
+ this.#audio.removeAttribute('src');
210
+ this.#audio.load();
211
+ this.#audio = null;
232
212
  }
233
213
 
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
214
  static canPlay(type) {
242
- const result = new Audio().canPlayType(type);
243
- return result === 'probably' || result === 'maybe';
215
+ if (typeof type !== 'string' || !type.trim()) return false;
216
+ return new Audio().canPlayType(type) !== '';
244
217
  }
245
218
 
246
- // ── Private ─────────────────────────────────────────────────────────────────
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
+ }
247
260
 
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
261
  #onVisibilityChange() {
262
- if (document.visibilityState === 'hidden') {
262
+ if (this.#isDestroyed || !this.#audio) return;
263
263
 
264
+ if (document.visibilityState === 'hidden') {
264
265
  try { if (this.#onHidden) this.#onHidden(); } catch (e) {
265
266
  console.warn(`OpenAudio: onHidden callback error (${this.#label}):`, e);
266
267
  }
267
-
268
- if (this.#pauseOnHidden && this.isPlaying) {
268
+ if (this.#pauseOnHidden && this.#isPlaying) {
269
269
  this.#audio.pause();
270
+ this.#isPlaying = false;
270
271
  this.#pausedByVisibility = true;
271
272
  }
272
273
 
273
274
  } else if (document.visibilityState === 'visible') {
274
-
275
275
  try { if (this.#onVisible) this.#onVisible(); } catch (e) {
276
276
  console.warn(`OpenAudio: onVisible callback error (${this.#label}):`, e);
277
277
  }
278
-
279
278
  if (this.#pauseOnHidden && this.#pausedByVisibility) {
280
279
  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
- });
280
+ this.#isPlaying = true;
281
+ this.#audio.play()
282
+ .catch(err => {
283
+ this.#isPlaying = false;
284
+ if (err.name === 'AbortError') return;
285
+ console.warn(`OpenAudio: resume after visibility restore failed for "${this.#label}".\nError:`, err);
286
+ });
287
287
  }
288
288
  }
289
289
  }
290
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
- }
291
+ // Usage examples and unit tests can be added here
315
292
  }
316
293
 
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
- */
294
+ export default OpenAudio;