audioq 2.0.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,467 @@
1
+ /**
2
+ * @fileoverview Error handling, retry logic, and recovery mechanisms for the audioq package
3
+ */
4
+
5
+ import {
6
+ AudioErrorInfo,
7
+ AudioErrorCallback,
8
+ AudioErrorType,
9
+ RetryConfig,
10
+ ErrorRecoveryOptions,
11
+ ExtendedAudioQueueChannel,
12
+ MAX_CHANNELS
13
+ } from './types';
14
+ import { audioChannels } from './info';
15
+ import { extractFileName } from './utils';
16
+
17
+ let globalRetryConfig: RetryConfig = {
18
+ baseDelay: 1000,
19
+ enabled: true,
20
+ exponentialBackoff: true,
21
+ fallbackUrls: [],
22
+ maxRetries: 3,
23
+ skipOnFailure: false,
24
+ timeoutMs: 10000
25
+ };
26
+
27
+ let globalErrorRecovery: ErrorRecoveryOptions = {
28
+ autoRetry: true,
29
+ fallbackToNextTrack: true,
30
+ logErrorsToAnalytics: false,
31
+ preserveQueueOnError: true,
32
+ showUserFeedback: false
33
+ };
34
+
35
+ const retryAttempts: 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
+ * fallbackToNextTrack: true,
147
+ * logErrorsToAnalytics: true,
148
+ * preserveQueueOnError: true,
149
+ * showUserFeedback: 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.error('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 = (error: Error, audio: HTMLAudioElement): AudioErrorType => {
251
+ const errorMessage = error.message.toLowerCase();
252
+
253
+ if (errorMessage.includes('network') || errorMessage.includes('fetch')) {
254
+ return AudioErrorType.Network;
255
+ }
256
+
257
+ // Check for unsupported format first (more specific than decode)
258
+ if (
259
+ errorMessage.includes('not supported') ||
260
+ errorMessage.includes('unsupported') ||
261
+ errorMessage.includes('format not supported')
262
+ ) {
263
+ return AudioErrorType.Unsupported;
264
+ }
265
+
266
+ if (errorMessage.includes('decode') || errorMessage.includes('format')) {
267
+ return AudioErrorType.Decode;
268
+ }
269
+
270
+ if (errorMessage.includes('permission') || errorMessage.includes('blocked')) {
271
+ return AudioErrorType.Permission;
272
+ }
273
+
274
+ if (errorMessage.includes('abort')) {
275
+ return AudioErrorType.Abort;
276
+ }
277
+
278
+ if (errorMessage.includes('timeout')) {
279
+ return AudioErrorType.Timeout;
280
+ }
281
+
282
+ // Check audio element network state for more context
283
+ if (audio.networkState === HTMLMediaElement.NETWORK_NO_SOURCE) {
284
+ return AudioErrorType.Network;
285
+ }
286
+
287
+ if (audio.networkState === HTMLMediaElement.NETWORK_LOADING) {
288
+ return AudioErrorType.Timeout;
289
+ }
290
+
291
+ return AudioErrorType.Unknown;
292
+ };
293
+
294
+ /**
295
+ * Sets up comprehensive error handling for an audio element
296
+ * @param audio - The audio element to set up error handling for
297
+ * @param channelNumber - The channel number this audio belongs to
298
+ * @param originalUrl - The original URL that was requested
299
+ * @param onError - Callback for when an error occurs
300
+ * @internal
301
+ */
302
+ export const setupAudioErrorHandling = (
303
+ audio: HTMLAudioElement,
304
+ channelNumber: number,
305
+ originalUrl: string,
306
+ onError?: (error: Error) => Promise<void>
307
+ ): void => {
308
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
309
+ if (!channel) return;
310
+
311
+ // Handle various error events
312
+ const handleError = (_event: Event): void => {
313
+ const error = new Error(`Audio loading failed: ${audio.error?.message || 'Unknown error'}`);
314
+ handleAudioError(audio, channelNumber, originalUrl, error);
315
+ };
316
+
317
+ const handleAbort = (): void => {
318
+ const error = new Error('Audio loading was aborted');
319
+ handleAudioError(audio, channelNumber, originalUrl, error);
320
+ };
321
+
322
+ const handleStall = (): void => {
323
+ const error = new Error('Audio loading stalled');
324
+ handleAudioError(audio, channelNumber, originalUrl, error);
325
+ };
326
+
327
+ // Add event listeners
328
+ audio.addEventListener('error', handleError);
329
+ audio.addEventListener('abort', handleAbort);
330
+ audio.addEventListener('stalled', handleStall);
331
+
332
+ // Custom play error handling
333
+ if (onError) {
334
+ const originalPlay = audio.play.bind(audio);
335
+ const wrappedPlay = async (): Promise<void> => {
336
+ try {
337
+ await originalPlay();
338
+ } catch (error) {
339
+ await onError(error as Error);
340
+ throw error;
341
+ }
342
+ };
343
+
344
+ audio.play = wrappedPlay;
345
+ }
346
+ };
347
+
348
+ /**
349
+ * Handles audio errors with retry logic and recovery mechanisms
350
+ * @param audio - The audio element that failed
351
+ * @param channelNumber - The channel number
352
+ * @param originalUrl - The original URL that was requested
353
+ * @param error - The error that occurred
354
+ * @internal
355
+ */
356
+ export const handleAudioError = async (
357
+ audio: HTMLAudioElement,
358
+ channelNumber: number,
359
+ originalUrl: string,
360
+ error: Error
361
+ ): Promise<void> => {
362
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
363
+ if (!channel) return;
364
+
365
+ const currentAttempts = retryAttempts.get(audio) ?? 0;
366
+ const retryConfig = channel.retryConfig ?? globalRetryConfig;
367
+
368
+ const errorInfo: AudioErrorInfo = {
369
+ channelNumber,
370
+ error,
371
+ errorType: categorizeError(error, audio),
372
+ fileName: extractFileName(originalUrl),
373
+ remainingInQueue: channel.queue.length - 1,
374
+ retryAttempt: currentAttempts,
375
+ src: originalUrl,
376
+ timestamp: Date.now()
377
+ };
378
+
379
+ // Emit error event
380
+ emitAudioError(channelNumber, errorInfo, audioChannels);
381
+
382
+ // Attempt retry if enabled and within limits
383
+ if (
384
+ retryConfig.enabled &&
385
+ currentAttempts < retryConfig.maxRetries &&
386
+ globalErrorRecovery.autoRetry
387
+ ) {
388
+ const delay: number = retryConfig.exponentialBackoff
389
+ ? retryConfig.baseDelay * Math.pow(2, currentAttempts)
390
+ : retryConfig.baseDelay;
391
+
392
+ retryAttempts.set(audio, currentAttempts + 1);
393
+
394
+ const retryFunction = async (): Promise<void> => {
395
+ try {
396
+ // Try fallback URLs if available
397
+ if (retryConfig.fallbackUrls && retryConfig.fallbackUrls.length > 0) {
398
+ const fallbackIndex = currentAttempts % retryConfig.fallbackUrls.length;
399
+ const fallbackUrl =
400
+ retryConfig.fallbackUrls[fallbackIndex] + extractFileName(originalUrl);
401
+ audio.src = fallbackUrl;
402
+ }
403
+
404
+ await audio.load();
405
+ await audio.play();
406
+
407
+ // Reset retry counter on success
408
+ retryAttempts.delete(audio);
409
+ } catch (retryError) {
410
+ await handleAudioError(audio, channelNumber, originalUrl, retryError as Error);
411
+ }
412
+ };
413
+
414
+ setTimeout(retryFunction, delay);
415
+ } else {
416
+ // Max retries reached or retry disabled
417
+ if (retryConfig.skipOnFailure || globalErrorRecovery.fallbackToNextTrack) {
418
+ // Skip to next track in queue
419
+ channel.queue.shift();
420
+
421
+ // Import and use playAudioQueue to continue with next track
422
+ const { playAudioQueue } = await import('./core');
423
+ // eslint-disable-next-line no-console
424
+ playAudioQueue(channelNumber).catch(console.error);
425
+ } else if (!globalErrorRecovery.preserveQueueOnError) {
426
+ // Clear the entire queue on failure
427
+ channel.queue = [];
428
+ }
429
+ }
430
+ };
431
+
432
+ /**
433
+ * Creates a timeout-protected audio element with comprehensive error handling
434
+ * @param url - The audio URL to load
435
+ * @param channelNumber - The channel number this audio belongs to
436
+ * @returns Promise that resolves to the configured audio element
437
+ * @internal
438
+ */
439
+ export const createProtectedAudioElement = async (
440
+ url: string,
441
+ channelNumber: number
442
+ ): Promise<HTMLAudioElement> => {
443
+ const audio = new Audio();
444
+
445
+ return new Promise((resolve, reject) => {
446
+ const handleSuccess = (): void => {
447
+ resolve(audio);
448
+ };
449
+
450
+ const handleError = (error: Error): void => {
451
+ reject(error);
452
+ };
453
+
454
+ // Set up error handling
455
+ setupAudioErrorHandling(audio, channelNumber, url, async (error: Error) => {
456
+ handleError(error);
457
+ });
458
+
459
+ // Set up success handlers
460
+ audio.addEventListener('canplay', handleSuccess, { once: true });
461
+ audio.addEventListener('loadedmetadata', handleSuccess, { once: true });
462
+
463
+ // Start loading
464
+ audio.src = url;
465
+ audio.load();
466
+ });
467
+ };
package/src/events.ts ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * @fileoverview Event handling and emission for the audioq package
3
+ */
4
+
5
+ import {
6
+ AudioStartInfo,
7
+ AudioCompleteInfo,
8
+ ExtendedAudioQueueChannel,
9
+ QueueSnapshot,
10
+ ProgressCallback,
11
+ AudioInfo,
12
+ GLOBAL_PROGRESS_KEY
13
+ } from './types';
14
+ import { createQueueSnapshot, getAudioInfoFromElement } from './utils';
15
+
16
+ /**
17
+ * Emits a queue change event to all registered listeners for a specific channel
18
+ * @param channelNumber - The channel number that experienced a queue change
19
+ * @param audioChannels - Array of audio channels
20
+ * @example
21
+ * ```typescript
22
+ * emitQueueChange(0, audioChannels); // Notifies all queue change listeners
23
+ * ```
24
+ */
25
+ export const emitQueueChange = (
26
+ channelNumber: number,
27
+ audioChannels: ExtendedAudioQueueChannel[]
28
+ ): void => {
29
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
30
+ if (!channel?.queueChangeCallbacks) return;
31
+
32
+ const snapshot: QueueSnapshot | null = createQueueSnapshot(channelNumber, audioChannels);
33
+ if (!snapshot) return;
34
+
35
+ channel.queueChangeCallbacks.forEach((callback) => {
36
+ try {
37
+ callback(snapshot);
38
+ } catch (error) {
39
+ // eslint-disable-next-line no-console
40
+ console.error('Error in queue change callback:', error);
41
+ }
42
+ });
43
+ };
44
+
45
+ /**
46
+ * Emits an audio start event to all registered listeners for a specific channel
47
+ * @param channelNumber - The channel number where audio started
48
+ * @param audioInfo - Information about the audio that started
49
+ * @param audioChannels - Array of audio channels
50
+ * @example
51
+ * ```typescript
52
+ * emitAudioStart(0, { src: 'song.mp3', fileName: 'song.mp3', duration: 180000, channelNumber: 0 }, audioChannels);
53
+ * ```
54
+ */
55
+ export const emitAudioStart = (
56
+ channelNumber: number,
57
+ audioInfo: AudioStartInfo,
58
+ audioChannels: ExtendedAudioQueueChannel[]
59
+ ): void => {
60
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
61
+ if (!channel?.audioStartCallbacks) return;
62
+
63
+ channel.audioStartCallbacks.forEach((callback) => {
64
+ try {
65
+ callback(audioInfo);
66
+ } catch (error) {
67
+ // eslint-disable-next-line no-console
68
+ console.error('Error in audio start callback:', error);
69
+ }
70
+ });
71
+ };
72
+
73
+ /**
74
+ * Emits an audio complete event to all registered listeners for a specific channel
75
+ * @param channelNumber - The channel number where audio completed
76
+ * @param audioInfo - Information about the audio that completed
77
+ * @param audioChannels - Array of audio channels
78
+ * @example
79
+ * ```typescript
80
+ * emitAudioComplete(0, { src: 'song.mp3', fileName: 'song.mp3', channelNumber: 0, remainingInQueue: 2 }, audioChannels);
81
+ * ```
82
+ */
83
+ export const emitAudioComplete = (
84
+ channelNumber: number,
85
+ audioInfo: AudioCompleteInfo,
86
+ audioChannels: ExtendedAudioQueueChannel[]
87
+ ): void => {
88
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
89
+ if (!channel?.audioCompleteCallbacks) return;
90
+
91
+ channel.audioCompleteCallbacks.forEach((callback) => {
92
+ try {
93
+ callback(audioInfo);
94
+ } catch (error) {
95
+ // eslint-disable-next-line no-console
96
+ console.error('Error in audio complete callback:', error);
97
+ }
98
+ });
99
+ };
100
+
101
+ /**
102
+ * Emits an audio pause event to all registered listeners for a specific channel
103
+ * @param channelNumber - The channel number where audio was paused
104
+ * @param audioInfo - Information about the audio that was paused
105
+ * @param audioChannels - Array of audio channels
106
+ * @example
107
+ * ```typescript
108
+ * emitAudioPause(0, audioInfo, audioChannels);
109
+ * ```
110
+ */
111
+ export const emitAudioPause = (
112
+ channelNumber: number,
113
+ audioInfo: AudioInfo,
114
+ audioChannels: ExtendedAudioQueueChannel[]
115
+ ): void => {
116
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
117
+ if (!channel?.audioPauseCallbacks) return;
118
+
119
+ channel.audioPauseCallbacks.forEach((callback) => {
120
+ try {
121
+ callback(channelNumber, audioInfo);
122
+ } catch (error) {
123
+ // eslint-disable-next-line no-console
124
+ console.error('Error in audio pause callback:', error);
125
+ }
126
+ });
127
+ };
128
+
129
+ /**
130
+ * Emits an audio resume event to all registered listeners for a specific channel
131
+ * @param channelNumber - The channel number where audio was resumed
132
+ * @param audioInfo - Information about the audio that was resumed
133
+ * @param audioChannels - Array of audio channels
134
+ * @example
135
+ * ```typescript
136
+ * emitAudioResume(0, audioInfo, audioChannels);
137
+ * ```
138
+ */
139
+ export const emitAudioResume = (
140
+ channelNumber: number,
141
+ audioInfo: AudioInfo,
142
+ audioChannels: ExtendedAudioQueueChannel[]
143
+ ): void => {
144
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
145
+ if (!channel?.audioResumeCallbacks) return;
146
+
147
+ channel.audioResumeCallbacks.forEach((callback) => {
148
+ try {
149
+ callback(channelNumber, audioInfo);
150
+ } catch (error) {
151
+ // eslint-disable-next-line no-console
152
+ console.error('Error in audio resume callback:', error);
153
+ }
154
+ });
155
+ };
156
+
157
+ // Store listener functions for cleanup
158
+ const progressListeners: WeakMap<HTMLAudioElement, () => void> = new WeakMap();
159
+
160
+ /**
161
+ * Sets up comprehensive progress tracking for an audio element
162
+ * @param audio - The HTML audio element to track
163
+ * @param channelNumber - The channel number this audio belongs to
164
+ * @param audioChannels - Array of audio channels
165
+ * @example
166
+ * ```typescript
167
+ * const audioElement = new Audio('song.mp3');
168
+ * setupProgressTracking(audioElement, 0, audioChannels);
169
+ * ```
170
+ */
171
+ export const setupProgressTracking = (
172
+ audio: HTMLAudioElement,
173
+ channelNumber: number,
174
+ audioChannels: ExtendedAudioQueueChannel[]
175
+ ): void => {
176
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
177
+ if (!channel) return;
178
+
179
+ if (!channel.progressCallbacks) {
180
+ channel.progressCallbacks = new Map();
181
+ }
182
+
183
+ // Don't set up tracking if already exists
184
+ if (progressListeners.has(audio)) return;
185
+
186
+ const updateProgress = (): void => {
187
+ // Get callbacks for this specific audio element AND the channel-wide callbacks
188
+ const audioCallbacks: Set<ProgressCallback> =
189
+ channel.progressCallbacks?.get(audio) ?? new Set();
190
+ const channelCallbacks: Set<ProgressCallback> =
191
+ channel.progressCallbacks?.get(GLOBAL_PROGRESS_KEY) ?? new Set();
192
+
193
+ // Combine both sets of callbacks
194
+ const allCallbacks: Set<ProgressCallback> = new Set([...audioCallbacks, ...channelCallbacks]);
195
+
196
+ if (allCallbacks.size === 0) return;
197
+
198
+ const info: AudioInfo | null = getAudioInfoFromElement(audio, channelNumber, audioChannels);
199
+ if (info) {
200
+ allCallbacks.forEach((callback: ProgressCallback) => {
201
+ try {
202
+ callback(info);
203
+ } catch (error) {
204
+ // eslint-disable-next-line no-console
205
+ console.error('Error in progress callback:', error);
206
+ }
207
+ });
208
+ }
209
+ };
210
+
211
+ // Store the listener function for cleanup
212
+ progressListeners.set(audio, updateProgress);
213
+
214
+ // Set up comprehensive event listeners for progress tracking
215
+ audio.addEventListener('timeupdate', updateProgress);
216
+ audio.addEventListener('loadedmetadata', updateProgress);
217
+ audio.addEventListener('play', updateProgress);
218
+ audio.addEventListener('pause', updateProgress);
219
+ audio.addEventListener('ended', updateProgress);
220
+ };
221
+
222
+ /**
223
+ * Cleans up progress tracking for an audio element to prevent memory leaks
224
+ * @param audio - The HTML audio element to clean up
225
+ * @param channelNumber - The channel number this audio belongs to
226
+ * @param audioChannels - Array of audio channels
227
+ * @example
228
+ * ```typescript
229
+ * cleanupProgressTracking(audioElement, 0, audioChannels);
230
+ * ```
231
+ */
232
+ export const cleanupProgressTracking = (
233
+ audio: HTMLAudioElement,
234
+ channelNumber: number,
235
+ audioChannels: ExtendedAudioQueueChannel[]
236
+ ): void => {
237
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
238
+ if (!channel?.progressCallbacks) return;
239
+
240
+ // Remove event listeners
241
+ const updateProgress: (() => void) | undefined = progressListeners.get(audio);
242
+ if (updateProgress) {
243
+ audio.removeEventListener('timeupdate', updateProgress);
244
+ audio.removeEventListener('loadedmetadata', updateProgress);
245
+ audio.removeEventListener('play', updateProgress);
246
+ audio.removeEventListener('pause', updateProgress);
247
+ audio.removeEventListener('ended', updateProgress);
248
+ progressListeners.delete(audio);
249
+ }
250
+
251
+ channel.progressCallbacks.delete(audio);
252
+ };