audio-channel-queue 1.6.0 → 1.8.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 ADDED
@@ -0,0 +1,480 @@
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
+ };
package/src/index.ts CHANGED
@@ -1,104 +1,108 @@
1
1
  /**
2
2
  * @fileoverview Main entry point for the audio-channel-queue package
3
- *
4
- * A comprehensive audio queue management system with real-time progress tracking,
5
- * multi-channel support, pause/resume functionality, volume control with ducking,
6
- * looping capabilities, and extensive event handling.
7
- *
8
- * @example Basic Usage
9
- * ```typescript
10
- * import { queueAudio, onAudioProgress, pauseChannel } from 'audio-channel-queue';
11
- *
12
- * // Queue an audio file
13
- * await queueAudio('song.mp3');
14
- *
15
- * // Track progress
16
- * onAudioProgress(0, (info) => {
17
- * console.log(`Progress: ${info.progress * 100}%`);
18
- * });
19
- *
20
- * // Pause playback
21
- * await pauseChannel(0);
22
- * ```
3
+ * Exports all public functions and types for audio queue management, pause/resume controls,
4
+ * volume management with ducking, progress tracking, and comprehensive event system
23
5
  */
24
6
 
25
- // Export all type definitions
26
- export type {
27
- AudioCompleteCallback,
28
- AudioCompleteInfo,
29
- AudioInfo,
30
- AudioPauseCallback,
31
- AudioQueue,
32
- AudioQueueChannel,
33
- AudioQueueOptions,
34
- AudioResumeCallback,
35
- AudioStartCallback,
36
- AudioStartInfo,
37
- ExtendedAudioQueueChannel,
38
- ProgressCallback,
39
- QueueChangeCallback,
40
- QueueItem,
41
- QueueSnapshot,
42
- VolumeConfig
43
- } from './types';
7
+ // Core queue management functions
8
+ export { queueAudio, queueAudioPriority, stopCurrentAudioInChannel, stopAllAudioInChannel, stopAllAudio, playAudioQueue } from './core';
44
9
 
45
- // Export core queue management functions
46
- export {
47
- playAudioQueue,
48
- queueAudio,
49
- queueAudioPriority,
50
- stopAllAudio,
51
- stopAllAudioInChannel,
52
- stopCurrentAudioInChannel
53
- } from './core';
10
+ // Error handling and recovery functions
11
+ export {
12
+ getErrorRecovery,
13
+ getRetryConfig,
14
+ offAudioError,
15
+ onAudioError,
16
+ retryFailedAudio,
17
+ setErrorRecovery,
18
+ setRetryConfig,
19
+ } from './errors';
54
20
 
55
- // Export pause and resume functionality
56
- export {
57
- getAllChannelsPauseState,
58
- isChannelPaused,
59
- pauseAllChannels,
60
- pauseChannel,
61
- resumeAllChannels,
62
- resumeChannel,
21
+ // Pause and resume management functions
22
+ export {
23
+ getAllChannelsPauseState,
24
+ isChannelPaused,
25
+ pauseAllChannels,
26
+ pauseAllWithFade,
27
+ pauseChannel,
28
+ pauseWithFade,
29
+ resumeAllChannels,
30
+ resumeAllWithFade,
31
+ resumeChannel,
32
+ resumeWithFade,
63
33
  togglePauseAllChannels,
64
- togglePauseChannel
34
+ togglePauseAllWithFade,
35
+ togglePauseChannel,
36
+ togglePauseWithFade,
65
37
  } from './pause';
66
38
 
67
- // Export volume control functions
68
- export {
69
- applyVolumeDucking,
39
+ // Volume control and ducking functions
40
+ export {
70
41
  clearVolumeDucking,
71
- getAllChannelsVolume,
72
- getChannelVolume,
73
- restoreVolumeLevels,
74
- setAllChannelsVolume,
75
- setChannelVolume,
42
+ fadeVolume,
43
+ getAllChannelsVolume,
44
+ getChannelVolume,
45
+ getFadeConfig,
46
+ setAllChannelsVolume,
47
+ setChannelVolume,
76
48
  setVolumeDucking,
77
- transitionVolume
49
+ transitionVolume,
78
50
  } from './volume';
79
51
 
80
- // Export audio information and progress tracking functions
81
- export {
82
- audioChannels,
83
- getAllChannelsInfo,
84
- getCurrentAudioInfo,
85
- getQueueSnapshot,
86
- offAudioPause,
87
- offAudioProgress,
52
+ // Audio information and progress tracking functions
53
+ export {
54
+ getAllChannelsInfo,
55
+ getCurrentAudioInfo,
56
+ getQueueSnapshot,
57
+ offAudioPause,
58
+ offAudioProgress,
88
59
  offAudioResume,
89
- offQueueChange,
90
- onAudioComplete,
91
- onAudioPause,
92
- onAudioProgress,
93
- onAudioResume,
94
- onAudioStart,
95
- onQueueChange
60
+ offQueueChange,
61
+ onAudioComplete,
62
+ onAudioPause,
63
+ onAudioProgress,
64
+ onAudioResume,
65
+ onAudioStart,
66
+ onQueueChange,
96
67
  } from './info';
97
68
 
98
- // Export utility functions (for advanced usage)
69
+ // Core data access for legacy compatibility
70
+ export { audioChannels } from './info';
71
+
72
+ // Utility helper functions
99
73
  export {
100
74
  cleanWebpackFilename,
101
75
  createQueueSnapshot,
102
76
  extractFileName,
103
77
  getAudioInfoFromElement
104
- } from './utils';
78
+ } from './utils';
79
+
80
+ // TypeScript type definitions and interfaces
81
+ export type {
82
+ AudioCompleteCallback,
83
+ AudioCompleteInfo,
84
+ AudioErrorCallback,
85
+ AudioErrorInfo,
86
+ AudioInfo,
87
+ AudioPauseCallback,
88
+ AudioQueueOptions,
89
+ AudioResumeCallback,
90
+ AudioStartCallback,
91
+ AudioStartInfo,
92
+ ChannelFadeState,
93
+ ErrorRecoveryOptions,
94
+ ExtendedAudioQueueChannel,
95
+ FadeConfig,
96
+ ProgressCallback,
97
+ QueueChangeCallback,
98
+ QueueItem,
99
+ QueueSnapshot,
100
+ RetryConfig,
101
+ VolumeConfig
102
+ } from './types';
103
+
104
+ // Enums
105
+ export {
106
+ EasingType,
107
+ FadeType
108
+ } from './types';