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