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