audio-channel-queue 1.7.0 → 1.9.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/src/errors.ts CHANGED
@@ -1,480 +1,504 @@
1
- /**
2
- * @fileoverview Error handling, retry logic, and recovery mechanisms for the audio-channel-queue package
3
- */
4
-
5
- import { AudioErrorInfo, AudioErrorCallback, RetryConfig, ErrorRecoveryOptions, ExtendedAudioQueueChannel } from './types';
6
- import { audioChannels } from './info';
7
- import { extractFileName } from './utils';
8
-
9
- let globalRetryConfig: RetryConfig = {
10
- enabled: true,
11
- maxRetries: 3,
12
- baseDelay: 1000,
13
- exponentialBackoff: true,
14
- timeoutMs: 10000,
15
- skipOnFailure: false
16
- };
17
-
18
- let globalErrorRecovery: ErrorRecoveryOptions = {
19
- autoRetry: true,
20
- showUserFeedback: false,
21
- logErrorsToAnalytics: false,
22
- preserveQueueOnError: true,
23
- fallbackToNextTrack: true
24
- };
25
-
26
- const retryAttempts = new WeakMap<HTMLAudioElement, number>();
27
-
28
- const loadTimeouts = new WeakMap<HTMLAudioElement, number>();
29
-
30
- /**
31
- * Subscribes to audio error events for a specific channel
32
- * @param channelNumber - The channel number to listen to (defaults to 0)
33
- * @param callback - Function to call when an audio error occurs
34
- * @example
35
- * ```typescript
36
- * onAudioError(0, (errorInfo) => {
37
- * console.log(`Audio error: ${errorInfo.error.message}`);
38
- * console.log(`Error type: ${errorInfo.errorType}`);
39
- * });
40
- * ```
41
- */
42
- export const onAudioError = (channelNumber: number = 0, callback: AudioErrorCallback): void => {
43
- // Ensure channel exists
44
- while (audioChannels.length <= channelNumber) {
45
- audioChannels.push({
46
- audioCompleteCallbacks: new Set(),
47
- audioErrorCallbacks: new Set(),
48
- audioPauseCallbacks: new Set(),
49
- audioResumeCallbacks: new Set(),
50
- audioStartCallbacks: new Set(),
51
- isPaused: false,
52
- progressCallbacks: new Map(),
53
- queue: [],
54
- queueChangeCallbacks: new Set(),
55
- volume: 1.0
56
- });
57
- }
58
-
59
- const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
60
- if (!channel.audioErrorCallbacks) {
61
- channel.audioErrorCallbacks = new Set();
62
- }
63
-
64
- channel.audioErrorCallbacks.add(callback);
65
- };
66
-
67
- /**
68
- * Unsubscribes from audio error events for a specific channel
69
- * @param channelNumber - The channel number to stop listening to (defaults to 0)
70
- * @param callback - The specific callback to remove (optional - if not provided, removes all)
71
- * @example
72
- * ```typescript
73
- * offAudioError(0); // Remove all error callbacks for channel 0
74
- * offAudioError(0, specificCallback); // Remove specific callback
75
- * ```
76
- */
77
- export const offAudioError = (channelNumber: number = 0, callback?: AudioErrorCallback): void => {
78
- const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
79
- if (!channel?.audioErrorCallbacks) return;
80
-
81
- if (callback) {
82
- channel.audioErrorCallbacks.delete(callback);
83
- } else {
84
- channel.audioErrorCallbacks.clear();
85
- }
86
- };
87
-
88
- /**
89
- * Sets the global retry configuration for audio loading failures
90
- * @param config - Retry configuration options
91
- * @example
92
- * ```typescript
93
- * setRetryConfig({
94
- * enabled: true,
95
- * maxRetries: 5,
96
- * baseDelay: 1000,
97
- * exponentialBackoff: true,
98
- * timeoutMs: 15000,
99
- * fallbackUrls: ['https://cdn.backup.com/audio/'],
100
- * skipOnFailure: true
101
- * });
102
- * ```
103
- */
104
- export const setRetryConfig = (config: Partial<RetryConfig>): void => {
105
- globalRetryConfig = { ...globalRetryConfig, ...config };
106
- };
107
-
108
- /**
109
- * Gets the current global retry configuration
110
- * @returns Current retry configuration
111
- * @example
112
- * ```typescript
113
- * const config = getRetryConfig();
114
- * console.log(`Max retries: ${config.maxRetries}`);
115
- * ```
116
- */
117
- export const getRetryConfig = (): RetryConfig => {
118
- return { ...globalRetryConfig };
119
- };
120
-
121
- /**
122
- * Sets the global error recovery configuration
123
- * @param options - Error recovery options
124
- * @example
125
- * ```typescript
126
- * setErrorRecovery({
127
- * autoRetry: true,
128
- * showUserFeedback: true,
129
- * logErrorsToAnalytics: true,
130
- * preserveQueueOnError: true,
131
- * fallbackToNextTrack: true
132
- * });
133
- * ```
134
- */
135
- export const setErrorRecovery = (options: Partial<ErrorRecoveryOptions>): void => {
136
- globalErrorRecovery = { ...globalErrorRecovery, ...options };
137
- };
138
-
139
- /**
140
- * Gets the current global error recovery configuration
141
- * @returns Current error recovery configuration
142
- * @example
143
- * ```typescript
144
- * const recovery = getErrorRecovery();
145
- * console.log(`Auto retry enabled: ${recovery.autoRetry}`);
146
- * ```
147
- */
148
- export const getErrorRecovery = (): ErrorRecoveryOptions => {
149
- return { ...globalErrorRecovery };
150
- };
151
-
152
- /**
153
- * Manually retries loading failed audio for a specific channel
154
- * @param channelNumber - The channel number to retry (defaults to 0)
155
- * @returns Promise that resolves to true if retry was successful, false otherwise
156
- * @example
157
- * ```typescript
158
- * const success = await retryFailedAudio(0);
159
- * if (success) {
160
- * console.log('Audio retry successful');
161
- * } else {
162
- * console.log('Audio retry failed');
163
- * }
164
- * ```
165
- */
166
- export const retryFailedAudio = async (channelNumber: number = 0): Promise<boolean> => {
167
- const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
168
- if (!channel || channel.queue.length === 0) return false;
169
-
170
- const currentAudio: HTMLAudioElement = channel.queue[0];
171
- const currentAttempts = retryAttempts.get(currentAudio) || 0;
172
-
173
- if (currentAttempts >= globalRetryConfig.maxRetries) {
174
- return false;
175
- }
176
-
177
- try {
178
- // Reset the audio element
179
- currentAudio.currentTime = 0;
180
- await currentAudio.play();
181
-
182
- // Reset retry counter on successful play
183
- retryAttempts.delete(currentAudio);
184
- return true;
185
- } catch (error) {
186
- // Increment retry counter
187
- retryAttempts.set(currentAudio, currentAttempts + 1);
188
- return false;
189
- }
190
- };
191
-
192
- /**
193
- * Emits an audio error event to all registered listeners for a specific channel
194
- * @param channelNumber - The channel number where the error occurred
195
- * @param errorInfo - Information about the error
196
- * @param audioChannels - Array of audio channels
197
- * @internal
198
- */
199
- export const emitAudioError = (
200
- channelNumber: number,
201
- errorInfo: AudioErrorInfo,
202
- audioChannels: ExtendedAudioQueueChannel[]
203
- ): void => {
204
- const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
205
- if (!channel?.audioErrorCallbacks) return;
206
-
207
- // Log to analytics if enabled
208
- if (globalErrorRecovery.logErrorsToAnalytics) {
209
- console.warn('Audio Error Analytics:', errorInfo);
210
- }
211
-
212
- channel.audioErrorCallbacks.forEach(callback => {
213
- try {
214
- callback(errorInfo);
215
- } catch (error) {
216
- console.error('Error in audio error callback:', error);
217
- }
218
- });
219
- };
220
-
221
- /**
222
- * Determines the error type based on the error object and context
223
- * @param error - The error that occurred
224
- * @param audio - The audio element that failed
225
- * @returns The categorized error type
226
- * @internal
227
- */
228
- export const categorizeError = (error: Error, audio: HTMLAudioElement): AudioErrorInfo['errorType'] => {
229
- const errorMessage = error.message.toLowerCase();
230
-
231
- if (errorMessage.includes('network') || errorMessage.includes('fetch')) {
232
- return 'network';
233
- }
234
-
235
- // Check for unsupported format first (more specific than decode)
236
- if (errorMessage.includes('not supported') || errorMessage.includes('unsupported') ||
237
- errorMessage.includes('format not supported')) {
238
- return 'unsupported';
239
- }
240
-
241
- if (errorMessage.includes('decode') || errorMessage.includes('format')) {
242
- return 'decode';
243
- }
244
-
245
- if (errorMessage.includes('permission') || errorMessage.includes('blocked')) {
246
- return 'permission';
247
- }
248
-
249
- if (errorMessage.includes('abort')) {
250
- return 'abort';
251
- }
252
-
253
- if (errorMessage.includes('timeout')) {
254
- return 'timeout';
255
- }
256
-
257
- // Check audio element network state for more context
258
- if (audio.networkState === HTMLMediaElement.NETWORK_NO_SOURCE) {
259
- return 'network';
260
- }
261
-
262
- if (audio.networkState === HTMLMediaElement.NETWORK_LOADING) {
263
- return 'timeout';
264
- }
265
-
266
- return 'unknown';
267
- };
268
-
269
- /**
270
- * Sets up comprehensive error handling for an audio element
271
- * @param audio - The audio element to set up error handling for
272
- * @param channelNumber - The channel number this audio belongs to
273
- * @param originalUrl - The original URL that was requested
274
- * @param onError - Callback for when an error occurs
275
- * @internal
276
- */
277
- export const setupAudioErrorHandling = (
278
- audio: HTMLAudioElement,
279
- channelNumber: number,
280
- originalUrl: string,
281
- onError?: (error: Error) => Promise<void>
282
- ): void => {
283
- const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
284
- if (!channel) return;
285
-
286
- // Set up loading timeout with test environment compatibility
287
- let timeoutId: number;
288
- if (typeof setTimeout !== 'undefined') {
289
- timeoutId = setTimeout(() => {
290
- if (audio.networkState === HTMLMediaElement.NETWORK_LOADING) {
291
- const timeoutError = new Error(`Audio loading timeout after ${globalRetryConfig.timeoutMs}ms`);
292
- handleAudioError(audio, channelNumber, originalUrl, timeoutError);
293
- }
294
- }, globalRetryConfig.timeoutMs) as unknown as number;
295
-
296
- loadTimeouts.set(audio, timeoutId);
297
- }
298
-
299
- // Clear timeout when metadata loads successfully
300
- const handleLoadSuccess = (): void => {
301
- if (typeof setTimeout !== 'undefined') {
302
- const timeoutId = loadTimeouts.get(audio);
303
- if (timeoutId) {
304
- clearTimeout(timeoutId);
305
- loadTimeouts.delete(audio);
306
- }
307
- }
308
- };
309
-
310
- // Handle various error events
311
- const handleError = (event: Event): void => {
312
- if (typeof setTimeout !== 'undefined') {
313
- const timeoutId = loadTimeouts.get(audio);
314
- if (timeoutId) {
315
- clearTimeout(timeoutId);
316
- loadTimeouts.delete(audio);
317
- }
318
- }
319
-
320
- const error = new Error(`Audio loading failed: ${audio.error?.message || 'Unknown error'}`);
321
- handleAudioError(audio, channelNumber, originalUrl, error);
322
- };
323
-
324
- const handleAbort = (): void => {
325
- const error = new Error('Audio loading was aborted');
326
- handleAudioError(audio, channelNumber, originalUrl, error);
327
- };
328
-
329
- const handleStall = (): void => {
330
- const error = new Error('Audio loading stalled');
331
- handleAudioError(audio, channelNumber, originalUrl, error);
332
- };
333
-
334
- // Add event listeners
335
- audio.addEventListener('error', handleError);
336
- audio.addEventListener('abort', handleAbort);
337
- audio.addEventListener('stalled', handleStall);
338
- audio.addEventListener('loadedmetadata', handleLoadSuccess);
339
- audio.addEventListener('canplay', handleLoadSuccess);
340
-
341
- // Custom play error handling
342
- if (onError) {
343
- const originalPlay = audio.play.bind(audio);
344
- const wrappedPlay = async () => {
345
- try {
346
- await originalPlay();
347
- } catch (error) {
348
- await onError(error as Error);
349
- throw error;
350
- }
351
- };
352
-
353
- audio.play = wrappedPlay;
354
- }
355
- };
356
-
357
- /**
358
- * Handles audio errors with retry logic and recovery mechanisms
359
- * @param audio - The audio element that failed
360
- * @param channelNumber - The channel number
361
- * @param originalUrl - The original URL that was requested
362
- * @param error - The error that occurred
363
- * @internal
364
- */
365
- export const handleAudioError = async (
366
- audio: HTMLAudioElement,
367
- channelNumber: number,
368
- originalUrl: string,
369
- error: Error
370
- ): Promise<void> => {
371
- const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
372
- if (!channel) return;
373
-
374
- const currentAttempts = retryAttempts.get(audio) || 0;
375
- const retryConfig = channel.retryConfig || globalRetryConfig;
376
-
377
- const errorInfo: AudioErrorInfo = {
378
- channelNumber,
379
- src: originalUrl,
380
- fileName: extractFileName(originalUrl),
381
- error,
382
- errorType: categorizeError(error, audio),
383
- timestamp: Date.now(),
384
- retryAttempt: currentAttempts,
385
- remainingInQueue: channel.queue.length - 1
386
- };
387
-
388
- // Emit error event
389
- emitAudioError(channelNumber, errorInfo, audioChannels);
390
-
391
- // Attempt retry if enabled and within limits
392
- if (retryConfig.enabled && currentAttempts < retryConfig.maxRetries && globalErrorRecovery.autoRetry) {
393
- const delay = retryConfig.exponentialBackoff
394
- ? retryConfig.baseDelay * Math.pow(2, currentAttempts)
395
- : retryConfig.baseDelay;
396
-
397
- retryAttempts.set(audio, currentAttempts + 1);
398
-
399
- const retryFunction = async () => {
400
- try {
401
- // Try fallback URLs if available
402
- if (retryConfig.fallbackUrls && retryConfig.fallbackUrls.length > 0) {
403
- const fallbackIndex = currentAttempts % retryConfig.fallbackUrls.length;
404
- const fallbackUrl = retryConfig.fallbackUrls[fallbackIndex] + extractFileName(originalUrl);
405
- audio.src = fallbackUrl;
406
- }
407
-
408
- await audio.load();
409
- await audio.play();
410
-
411
- // Reset retry counter on success
412
- retryAttempts.delete(audio);
413
- } catch (retryError) {
414
- await handleAudioError(audio, channelNumber, originalUrl, retryError as Error);
415
- }
416
- };
417
-
418
- setTimeout(retryFunction, delay);
419
- } else {
420
- // Max retries reached or retry disabled
421
- if (retryConfig.skipOnFailure || globalErrorRecovery.fallbackToNextTrack) {
422
- // Skip to next track in queue
423
- channel.queue.shift();
424
-
425
- // Import and use playAudioQueue to continue with next track
426
- const { playAudioQueue } = await import('./core');
427
- playAudioQueue(channelNumber).catch(console.error);
428
- } else if (!globalErrorRecovery.preserveQueueOnError) {
429
- // Clear the entire queue on failure
430
- channel.queue = [];
431
- }
432
- }
433
- };
434
-
435
- /**
436
- * Creates a timeout-protected audio element with comprehensive error handling
437
- * @param url - The audio URL to load
438
- * @param channelNumber - The channel number this audio belongs to
439
- * @returns Promise that resolves to the configured audio element
440
- * @internal
441
- */
442
- export const createProtectedAudioElement = async (
443
- url: string,
444
- channelNumber: number
445
- ): Promise<HTMLAudioElement> => {
446
- const audio = new Audio();
447
-
448
- return new Promise((resolve, reject) => {
449
- const cleanup = (): void => {
450
- const timeoutId = loadTimeouts.get(audio);
451
- if (timeoutId) {
452
- clearTimeout(timeoutId);
453
- loadTimeouts.delete(audio);
454
- }
455
- };
456
-
457
- const handleSuccess = (): void => {
458
- cleanup();
459
- resolve(audio);
460
- };
461
-
462
- const handleError = (error: Error): void => {
463
- cleanup();
464
- reject(error);
465
- };
466
-
467
- // Set up error handling
468
- setupAudioErrorHandling(audio, channelNumber, url, async (error: Error) => {
469
- handleError(error);
470
- });
471
-
472
- // Set up success handlers
473
- audio.addEventListener('canplay', handleSuccess, { once: true });
474
- audio.addEventListener('loadedmetadata', handleSuccess, { once: true });
475
-
476
- // Start loading
477
- audio.src = url;
478
- audio.load();
479
- });
480
- };
1
+ /**
2
+ * @fileoverview Error handling, retry logic, and recovery mechanisms for the audio-channel-queue package
3
+ */
4
+
5
+ import {
6
+ AudioErrorInfo,
7
+ AudioErrorCallback,
8
+ RetryConfig,
9
+ ErrorRecoveryOptions,
10
+ ExtendedAudioQueueChannel
11
+ } from './types';
12
+ import { audioChannels } from './info';
13
+ import { extractFileName } from './utils';
14
+
15
+ let globalRetryConfig: RetryConfig = {
16
+ baseDelay: 1000,
17
+ enabled: true,
18
+ exponentialBackoff: true,
19
+ maxRetries: 3,
20
+ skipOnFailure: false,
21
+ timeoutMs: 10000
22
+ };
23
+
24
+ let globalErrorRecovery: ErrorRecoveryOptions = {
25
+ autoRetry: true,
26
+ fallbackToNextTrack: true,
27
+ logErrorsToAnalytics: false,
28
+ preserveQueueOnError: true,
29
+ showUserFeedback: false
30
+ };
31
+
32
+ const retryAttempts: WeakMap<HTMLAudioElement, number> = new WeakMap();
33
+
34
+ const loadTimeouts: WeakMap<HTMLAudioElement, number> = new WeakMap();
35
+
36
+ /**
37
+ * Subscribes to audio error events for a specific channel
38
+ * @param channelNumber - The channel number to listen to (defaults to 0)
39
+ * @param callback - Function to call when an audio error occurs
40
+ * @example
41
+ * ```typescript
42
+ * onAudioError(0, (errorInfo) => {
43
+ * console.log(`Audio error: ${errorInfo.error.message}`);
44
+ * console.log(`Error type: ${errorInfo.errorType}`);
45
+ * });
46
+ * ```
47
+ */
48
+ export const onAudioError = (channelNumber: number = 0, callback: AudioErrorCallback): void => {
49
+ // Ensure channel exists
50
+ while (audioChannels.length <= channelNumber) {
51
+ audioChannels.push({
52
+ audioCompleteCallbacks: new Set(),
53
+ audioErrorCallbacks: new Set(),
54
+ audioPauseCallbacks: new Set(),
55
+ audioResumeCallbacks: new Set(),
56
+ audioStartCallbacks: new Set(),
57
+ isPaused: false,
58
+ progressCallbacks: new Map(),
59
+ queue: [],
60
+ queueChangeCallbacks: new Set(),
61
+ volume: 1.0
62
+ });
63
+ }
64
+
65
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
66
+ if (!channel.audioErrorCallbacks) {
67
+ channel.audioErrorCallbacks = new Set();
68
+ }
69
+
70
+ channel.audioErrorCallbacks.add(callback);
71
+ };
72
+
73
+ /**
74
+ * Unsubscribes from audio error events for a specific channel
75
+ * @param channelNumber - The channel number to stop listening to (defaults to 0)
76
+ * @param callback - The specific callback to remove (optional - if not provided, removes all)
77
+ * @example
78
+ * ```typescript
79
+ * offAudioError(0); // Remove all error callbacks for channel 0
80
+ * offAudioError(0, specificCallback); // Remove specific callback
81
+ * ```
82
+ */
83
+ export const offAudioError = (channelNumber: number = 0, callback?: AudioErrorCallback): void => {
84
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
85
+ if (!channel?.audioErrorCallbacks) return;
86
+
87
+ if (callback) {
88
+ channel.audioErrorCallbacks.delete(callback);
89
+ } else {
90
+ channel.audioErrorCallbacks.clear();
91
+ }
92
+ };
93
+
94
+ /**
95
+ * Sets the global retry configuration for audio loading failures
96
+ * @param config - Retry configuration options
97
+ * @example
98
+ * ```typescript
99
+ * setRetryConfig({
100
+ * enabled: true,
101
+ * maxRetries: 5,
102
+ * baseDelay: 1000,
103
+ * exponentialBackoff: true,
104
+ * timeoutMs: 15000,
105
+ * fallbackUrls: ['https://cdn.backup.com/audio/'],
106
+ * skipOnFailure: true
107
+ * });
108
+ * ```
109
+ */
110
+ export const setRetryConfig = (config: Partial<RetryConfig>): void => {
111
+ globalRetryConfig = { ...globalRetryConfig, ...config };
112
+ };
113
+
114
+ /**
115
+ * Gets the current global retry configuration
116
+ * @returns Current retry configuration
117
+ * @example
118
+ * ```typescript
119
+ * const config = getRetryConfig();
120
+ * console.log(`Max retries: ${config.maxRetries}`);
121
+ * ```
122
+ */
123
+ export const getRetryConfig = (): RetryConfig => {
124
+ return { ...globalRetryConfig };
125
+ };
126
+
127
+ /**
128
+ * Sets the global error recovery configuration
129
+ * @param options - Error recovery options
130
+ * @example
131
+ * ```typescript
132
+ * setErrorRecovery({
133
+ * autoRetry: true,
134
+ * showUserFeedback: true,
135
+ * logErrorsToAnalytics: true,
136
+ * preserveQueueOnError: true,
137
+ * fallbackToNextTrack: true
138
+ * });
139
+ * ```
140
+ */
141
+ export const setErrorRecovery = (options: Partial<ErrorRecoveryOptions>): void => {
142
+ globalErrorRecovery = { ...globalErrorRecovery, ...options };
143
+ };
144
+
145
+ /**
146
+ * Gets the current global error recovery configuration
147
+ * @returns Current error recovery configuration
148
+ * @example
149
+ * ```typescript
150
+ * const recovery = getErrorRecovery();
151
+ * console.log(`Auto retry enabled: ${recovery.autoRetry}`);
152
+ * ```
153
+ */
154
+ export const getErrorRecovery = (): ErrorRecoveryOptions => {
155
+ return { ...globalErrorRecovery };
156
+ };
157
+
158
+ /**
159
+ * Manually retries loading failed audio for a specific channel
160
+ * @param channelNumber - The channel number to retry (defaults to 0)
161
+ * @returns Promise that resolves to true if retry was successful, false otherwise
162
+ * @example
163
+ * ```typescript
164
+ * const success = await retryFailedAudio(0);
165
+ * if (success) {
166
+ * console.log('Audio retry successful');
167
+ * } else {
168
+ * console.log('Audio retry failed');
169
+ * }
170
+ * ```
171
+ */
172
+ export const retryFailedAudio = async (channelNumber: number = 0): Promise<boolean> => {
173
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
174
+ if (!channel || channel.queue.length === 0) return false;
175
+
176
+ const currentAudio: HTMLAudioElement = channel.queue[0];
177
+ const currentAttempts = retryAttempts.get(currentAudio) ?? 0;
178
+
179
+ if (currentAttempts >= globalRetryConfig.maxRetries) {
180
+ return false;
181
+ }
182
+
183
+ try {
184
+ // Reset the audio element
185
+ currentAudio.currentTime = 0;
186
+ await currentAudio.play();
187
+
188
+ // Reset retry counter on successful play
189
+ retryAttempts.delete(currentAudio);
190
+ return true;
191
+ } catch (error) {
192
+ // eslint-disable-next-line no-console
193
+ console.error(`Error in retryFailedAudio: ${error}`);
194
+ // Increment retry counter
195
+ retryAttempts.set(currentAudio, currentAttempts + 1);
196
+ return false;
197
+ }
198
+ };
199
+
200
+ /**
201
+ * Emits an audio error event to all registered listeners for a specific channel
202
+ * @param channelNumber - The channel number where the error occurred
203
+ * @param errorInfo - Information about the error
204
+ * @param audioChannels - Array of audio channels
205
+ * @internal
206
+ */
207
+ export const emitAudioError = (
208
+ channelNumber: number,
209
+ errorInfo: AudioErrorInfo,
210
+ audioChannels: ExtendedAudioQueueChannel[]
211
+ ): void => {
212
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
213
+ if (!channel?.audioErrorCallbacks) return;
214
+
215
+ // Log to analytics if enabled
216
+ if (globalErrorRecovery.logErrorsToAnalytics) {
217
+ // eslint-disable-next-line no-console
218
+ console.warn('Audio Error Analytics:', errorInfo);
219
+ }
220
+
221
+ channel.audioErrorCallbacks.forEach((callback) => {
222
+ try {
223
+ callback(errorInfo);
224
+ } catch (error) {
225
+ // eslint-disable-next-line no-console
226
+ console.error('Error in audio error callback:', error);
227
+ }
228
+ });
229
+ };
230
+
231
+ /**
232
+ * Determines the error type based on the error object and context
233
+ * @param error - The error that occurred
234
+ * @param audio - The audio element that failed
235
+ * @returns The categorized error type
236
+ * @internal
237
+ */
238
+ export const categorizeError = (
239
+ error: Error,
240
+ audio: HTMLAudioElement
241
+ ): AudioErrorInfo['errorType'] => {
242
+ const errorMessage = error.message.toLowerCase();
243
+
244
+ if (errorMessage.includes('network') || errorMessage.includes('fetch')) {
245
+ return 'network';
246
+ }
247
+
248
+ // Check for unsupported format first (more specific than decode)
249
+ if (
250
+ errorMessage.includes('not supported') ||
251
+ errorMessage.includes('unsupported') ||
252
+ errorMessage.includes('format not supported')
253
+ ) {
254
+ return 'unsupported';
255
+ }
256
+
257
+ if (errorMessage.includes('decode') || errorMessage.includes('format')) {
258
+ return 'decode';
259
+ }
260
+
261
+ if (errorMessage.includes('permission') || errorMessage.includes('blocked')) {
262
+ return 'permission';
263
+ }
264
+
265
+ if (errorMessage.includes('abort')) {
266
+ return 'abort';
267
+ }
268
+
269
+ if (errorMessage.includes('timeout')) {
270
+ return 'timeout';
271
+ }
272
+
273
+ // Check audio element network state for more context
274
+ if (audio.networkState === HTMLMediaElement.NETWORK_NO_SOURCE) {
275
+ return 'network';
276
+ }
277
+
278
+ if (audio.networkState === HTMLMediaElement.NETWORK_LOADING) {
279
+ return 'timeout';
280
+ }
281
+
282
+ return 'unknown';
283
+ };
284
+
285
+ /**
286
+ * Sets up comprehensive error handling for an audio element
287
+ * @param audio - The audio element to set up error handling for
288
+ * @param channelNumber - The channel number this audio belongs to
289
+ * @param originalUrl - The original URL that was requested
290
+ * @param onError - Callback for when an error occurs
291
+ * @internal
292
+ */
293
+ export const setupAudioErrorHandling = (
294
+ audio: HTMLAudioElement,
295
+ channelNumber: number,
296
+ originalUrl: string,
297
+ onError?: (error: Error) => Promise<void>
298
+ ): void => {
299
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
300
+ if (!channel) return;
301
+
302
+ // Set up loading timeout with test environment compatibility
303
+ let timeoutId: number;
304
+ if (typeof setTimeout !== 'undefined') {
305
+ timeoutId = setTimeout(() => {
306
+ if (audio.networkState === HTMLMediaElement.NETWORK_LOADING) {
307
+ const timeoutError = new Error(
308
+ `Audio loading timeout after ${globalRetryConfig.timeoutMs}ms`
309
+ );
310
+ handleAudioError(audio, channelNumber, originalUrl, timeoutError);
311
+ }
312
+ }, globalRetryConfig.timeoutMs) as unknown as number;
313
+
314
+ loadTimeouts.set(audio, timeoutId);
315
+ }
316
+
317
+ // Clear timeout when metadata loads successfully
318
+ const handleLoadSuccess = (): void => {
319
+ if (typeof setTimeout !== 'undefined') {
320
+ const timeoutId = loadTimeouts.get(audio);
321
+ if (timeoutId) {
322
+ clearTimeout(timeoutId);
323
+ loadTimeouts.delete(audio);
324
+ }
325
+ }
326
+ };
327
+
328
+ // Handle various error events
329
+ const handleError = (_event: Event): void => {
330
+ if (typeof setTimeout !== 'undefined') {
331
+ const timeoutId = loadTimeouts.get(audio);
332
+ if (timeoutId) {
333
+ clearTimeout(timeoutId);
334
+ loadTimeouts.delete(audio);
335
+ }
336
+ }
337
+
338
+ const error = new Error(`Audio loading failed: ${audio.error?.message || 'Unknown error'}`);
339
+ handleAudioError(audio, channelNumber, originalUrl, error);
340
+ };
341
+
342
+ const handleAbort = (): void => {
343
+ const error = new Error('Audio loading was aborted');
344
+ handleAudioError(audio, channelNumber, originalUrl, error);
345
+ };
346
+
347
+ const handleStall = (): void => {
348
+ const error = new Error('Audio loading stalled');
349
+ handleAudioError(audio, channelNumber, originalUrl, error);
350
+ };
351
+
352
+ // Add event listeners
353
+ audio.addEventListener('error', handleError);
354
+ audio.addEventListener('abort', handleAbort);
355
+ audio.addEventListener('stalled', handleStall);
356
+ audio.addEventListener('loadedmetadata', handleLoadSuccess);
357
+ audio.addEventListener('canplay', handleLoadSuccess);
358
+
359
+ // Custom play error handling
360
+ if (onError) {
361
+ const originalPlay = audio.play.bind(audio);
362
+ const wrappedPlay = async (): Promise<void> => {
363
+ try {
364
+ await originalPlay();
365
+ } catch (error) {
366
+ await onError(error as Error);
367
+ throw error;
368
+ }
369
+ };
370
+
371
+ audio.play = wrappedPlay;
372
+ }
373
+ };
374
+
375
+ /**
376
+ * Handles audio errors with retry logic and recovery mechanisms
377
+ * @param audio - The audio element that failed
378
+ * @param channelNumber - The channel number
379
+ * @param originalUrl - The original URL that was requested
380
+ * @param error - The error that occurred
381
+ * @internal
382
+ */
383
+ export const handleAudioError = async (
384
+ audio: HTMLAudioElement,
385
+ channelNumber: number,
386
+ originalUrl: string,
387
+ error: Error
388
+ ): Promise<void> => {
389
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
390
+ if (!channel) return;
391
+
392
+ const currentAttempts = retryAttempts.get(audio) ?? 0;
393
+ const retryConfig = channel.retryConfig ?? globalRetryConfig;
394
+
395
+ const errorInfo: AudioErrorInfo = {
396
+ channelNumber,
397
+ error,
398
+ errorType: categorizeError(error, audio),
399
+ fileName: extractFileName(originalUrl),
400
+ remainingInQueue: channel.queue.length - 1,
401
+ retryAttempt: currentAttempts,
402
+ src: originalUrl,
403
+ timestamp: Date.now()
404
+ };
405
+
406
+ // Emit error event
407
+ emitAudioError(channelNumber, errorInfo, audioChannels);
408
+
409
+ // Attempt retry if enabled and within limits
410
+ if (
411
+ retryConfig.enabled &&
412
+ currentAttempts < retryConfig.maxRetries &&
413
+ globalErrorRecovery.autoRetry
414
+ ) {
415
+ const delay = retryConfig.exponentialBackoff
416
+ ? retryConfig.baseDelay * Math.pow(2, currentAttempts)
417
+ : retryConfig.baseDelay;
418
+
419
+ retryAttempts.set(audio, currentAttempts + 1);
420
+
421
+ const retryFunction = async (): Promise<void> => {
422
+ try {
423
+ // Try fallback URLs if available
424
+ if (retryConfig.fallbackUrls && retryConfig.fallbackUrls.length > 0) {
425
+ const fallbackIndex = currentAttempts % retryConfig.fallbackUrls.length;
426
+ const fallbackUrl =
427
+ retryConfig.fallbackUrls[fallbackIndex] + extractFileName(originalUrl);
428
+ audio.src = fallbackUrl;
429
+ }
430
+
431
+ await audio.load();
432
+ await audio.play();
433
+
434
+ // Reset retry counter on success
435
+ retryAttempts.delete(audio);
436
+ } catch (retryError) {
437
+ await handleAudioError(audio, channelNumber, originalUrl, retryError as Error);
438
+ }
439
+ };
440
+
441
+ setTimeout(retryFunction, delay);
442
+ } else {
443
+ // Max retries reached or retry disabled
444
+ if (retryConfig.skipOnFailure || globalErrorRecovery.fallbackToNextTrack) {
445
+ // Skip to next track in queue
446
+ channel.queue.shift();
447
+
448
+ // Import and use playAudioQueue to continue with next track
449
+ const { playAudioQueue } = await import('./core');
450
+ // eslint-disable-next-line no-console
451
+ playAudioQueue(channelNumber).catch(console.error);
452
+ } else if (!globalErrorRecovery.preserveQueueOnError) {
453
+ // Clear the entire queue on failure
454
+ channel.queue = [];
455
+ }
456
+ }
457
+ };
458
+
459
+ /**
460
+ * Creates a timeout-protected audio element with comprehensive error handling
461
+ * @param url - The audio URL to load
462
+ * @param channelNumber - The channel number this audio belongs to
463
+ * @returns Promise that resolves to the configured audio element
464
+ * @internal
465
+ */
466
+ export const createProtectedAudioElement = async (
467
+ url: string,
468
+ channelNumber: number
469
+ ): Promise<HTMLAudioElement> => {
470
+ const audio = new Audio();
471
+
472
+ return new Promise((resolve, reject) => {
473
+ const cleanup = (): void => {
474
+ const timeoutId = loadTimeouts.get(audio);
475
+ if (timeoutId) {
476
+ clearTimeout(timeoutId);
477
+ loadTimeouts.delete(audio);
478
+ }
479
+ };
480
+
481
+ const handleSuccess = (): void => {
482
+ cleanup();
483
+ resolve(audio);
484
+ };
485
+
486
+ const handleError = (error: Error): void => {
487
+ cleanup();
488
+ reject(error);
489
+ };
490
+
491
+ // Set up error handling
492
+ setupAudioErrorHandling(audio, channelNumber, url, async (error: Error) => {
493
+ handleError(error);
494
+ });
495
+
496
+ // Set up success handlers
497
+ audio.addEventListener('canplay', handleSuccess, { once: true });
498
+ audio.addEventListener('loadedmetadata', handleSuccess, { once: true });
499
+
500
+ // Start loading
501
+ audio.src = url;
502
+ audio.load();
503
+ });
504
+ };