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/CHANGELOG.md +169 -109
- package/CONTRIBUTING.md +39 -324
- package/OpenAudio.js +192 -284
- package/OpenAudio_r.js +211 -164
- package/OpenAudio_s.js +420 -204
- package/README.md +142 -122
- package/docs/OPENAUDIO_R.md +135 -143
- package/docs/OPENAUDIO_S.md +162 -377
- package/package.json +17 -27
package/OpenAudio.js
CHANGED
|
@@ -1,15 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file OpenAudio.js
|
|
3
3
|
* @author Rexore
|
|
4
|
-
* @version 1.
|
|
5
|
-
* @license
|
|
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
|
|
11
|
-
* by playing a
|
|
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
|
|
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,
|
|
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
|
-
*
|
|
51
|
-
*
|
|
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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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.
|
|
84
|
-
* -
|
|
85
|
-
* -
|
|
86
|
-
* -
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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.
|
|
92
|
-
* -
|
|
93
|
-
* -
|
|
94
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
153
|
-
this.#
|
|
154
|
-
this.#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
|
|
157
|
-
this.#onEnd
|
|
158
|
-
this.#onHidden
|
|
159
|
-
this.#onVisible
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
this.#
|
|
163
|
-
this.#
|
|
164
|
-
this.#
|
|
165
|
-
this.#
|
|
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
|
|
168
|
-
|
|
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
|
-
|
|
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
|
|
184
|
+
if (this.#isDestroyed) return;
|
|
197
185
|
|
|
198
|
-
this.#
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
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
|
-
|
|
229
|
-
this
|
|
230
|
-
this.#
|
|
231
|
-
this.#audio
|
|
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
|
-
|
|
243
|
-
return
|
|
215
|
+
if (typeof type !== 'string' || !type.trim()) return false;
|
|
216
|
+
return new Audio().canPlayType(type) !== '';
|
|
244
217
|
}
|
|
245
218
|
|
|
246
|
-
|
|
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 (
|
|
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.#
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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;
|