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/types.ts ADDED
@@ -0,0 +1,415 @@
1
+ /**
2
+ * @fileoverview Type definitions for the audioq package
3
+ */
4
+
5
+ /**
6
+ * Maximum number of audio channels allowed to prevent memory exhaustion
7
+ */
8
+ export const MAX_CHANNELS: number = 64;
9
+
10
+ /**
11
+ * Symbol used as a key for global (channel-wide) progress callbacks
12
+ * This avoids the need for `null as any` type assertions
13
+ */
14
+ export const GLOBAL_PROGRESS_KEY: unique symbol = Symbol('global-progress-callbacks');
15
+
16
+ /**
17
+ * Array of HTMLAudioElement objects representing an audio queue
18
+ */
19
+ export type AudioQueue = HTMLAudioElement[];
20
+
21
+ /**
22
+ * Basic audio queue channel structure
23
+ */
24
+ export interface AudioQueueChannel {
25
+ queue: AudioQueue;
26
+ }
27
+
28
+ /**
29
+ * Volume ducking configuration for channels
30
+ */
31
+ export interface VolumeConfig {
32
+ /** Duration in milliseconds for volume duck transition (defaults to 250ms) */
33
+ duckTransitionDuration?: number;
34
+ /** Volume level for all other channels when priority channel is active (0-1) */
35
+ duckingVolume: number;
36
+ /** The channel number that should have priority */
37
+ priorityChannel: number;
38
+ /** Volume level for the priority channel (0-1) */
39
+ priorityVolume: number;
40
+ /** Duration in milliseconds for volume restore transition (defaults to 250ms) */
41
+ restoreTransitionDuration?: number;
42
+ /** Easing function for volume transitions (defaults to 'ease-out') */
43
+ transitionEasing?: EasingType;
44
+ }
45
+
46
+ /**
47
+ * Configuration options for queuing audio
48
+ */
49
+ export interface AudioQueueOptions {
50
+ /** Whether to add this audio to the front of the queue (after currently playing) */
51
+ addToFront?: boolean;
52
+ /** Whether the audio should loop when it finishes */
53
+ loop?: boolean;
54
+ /** Maximum number of items allowed in the queue (defaults to unlimited) */
55
+ maxQueueSize?: number;
56
+ /** Volume level for this specific audio (0-1) */
57
+ volume?: number;
58
+ }
59
+
60
+ /**
61
+ * Global queue configuration options
62
+ */
63
+ export interface QueueConfig {
64
+ /** Default maximum queue size across all channels (defaults to unlimited) */
65
+ defaultMaxQueueSize?: number;
66
+ /** Whether to drop oldest items when queue is full (defaults to false - reject new items) */
67
+ dropOldestWhenFull?: boolean;
68
+ /** Whether to show warnings when queue limits are reached (defaults to true) */
69
+ showQueueWarnings?: boolean;
70
+ }
71
+
72
+ /**
73
+ * Comprehensive audio information interface providing metadata about currently playing audio
74
+ */
75
+ export interface AudioInfo {
76
+ /** Current playback position in milliseconds */
77
+ currentTime: number;
78
+ /** Total audio duration in milliseconds */
79
+ duration: number;
80
+ /** Extracted filename from the source URL */
81
+ fileName: string;
82
+ /** Whether the audio is set to loop */
83
+ isLooping: boolean;
84
+ /** Whether the audio is currently paused */
85
+ isPaused: boolean;
86
+ /** Whether the audio is currently playing */
87
+ isPlaying: boolean;
88
+ /** Playback progress as a decimal (0-1) */
89
+ progress: number;
90
+ /** Number of audio files remaining in the queue after current */
91
+ remainingInQueue: number;
92
+ /** Audio file source URL */
93
+ src: string;
94
+ /** Current volume level (0-1) */
95
+ volume: number;
96
+ }
97
+
98
+ /**
99
+ * Information provided when an audio file completes playback
100
+ */
101
+ export interface AudioCompleteInfo {
102
+ /** Channel number where the audio completed */
103
+ channelNumber: number;
104
+ /** Extracted filename from the source URL */
105
+ fileName: string;
106
+ /** Number of audio files remaining in the queue after completion */
107
+ remainingInQueue: number;
108
+ /** Audio file source URL */
109
+ src: string;
110
+ }
111
+
112
+ /**
113
+ * Information provided when an audio file starts playing
114
+ */
115
+ export interface AudioStartInfo {
116
+ /** Channel number where the audio is starting */
117
+ channelNumber: number;
118
+ /** Total audio duration in milliseconds */
119
+ duration: number;
120
+ /** Extracted filename from the source URL */
121
+ fileName: string;
122
+ /** Audio file source URL */
123
+ src: string;
124
+ }
125
+
126
+ /**
127
+ * Information about a single item in an audio queue
128
+ */
129
+ export interface QueueItem {
130
+ /** Total audio duration in milliseconds */
131
+ duration: number;
132
+ /** Extracted filename from the source URL */
133
+ fileName: string;
134
+ /** Whether this item is currently playing */
135
+ isCurrentlyPlaying: boolean;
136
+ /** Whether this item is set to loop */
137
+ isLooping: boolean;
138
+ /** Audio file source URL */
139
+ src: string;
140
+ /** Volume level for this item (0-1) */
141
+ volume: number;
142
+ }
143
+
144
+ /**
145
+ * Complete snapshot of a queue's current state
146
+ */
147
+ export interface QueueSnapshot {
148
+ /** Channel number this snapshot represents */
149
+ channelNumber: number;
150
+ /** Zero-based index of the currently playing item */
151
+ currentIndex: number;
152
+ /** Whether the current audio is paused */
153
+ isPaused: boolean;
154
+ /** Array of audio items in the queue with their metadata */
155
+ items: QueueItem[];
156
+ /** Total number of items in the queue */
157
+ totalItems: number;
158
+ /** Current volume level for the channel (0-1) */
159
+ volume: number;
160
+ }
161
+
162
+ /**
163
+ * Information about a queue manipulation operation result
164
+ */
165
+ export interface QueueManipulationResult {
166
+ /** Error message if operation failed */
167
+ error?: string;
168
+ /** Whether the operation was successful */
169
+ success: boolean;
170
+ /** The queue snapshot after the operation (if successful) */
171
+ updatedQueue?: QueueSnapshot;
172
+ }
173
+
174
+ /**
175
+ * Callback function type for audio progress updates
176
+ * @param info Current audio information
177
+ */
178
+ export type ProgressCallback = (info: AudioInfo) => void;
179
+
180
+ /**
181
+ * Callback function type for queue change notifications
182
+ * @param queueSnapshot Current state of the queue
183
+ */
184
+ export type QueueChangeCallback = (queueSnapshot: QueueSnapshot) => void;
185
+
186
+ /**
187
+ * Callback function type for audio start notifications
188
+ * @param audioInfo Information about the audio that started
189
+ */
190
+ export type AudioStartCallback = (audioInfo: AudioStartInfo) => void;
191
+
192
+ /**
193
+ * Callback function type for audio complete notifications
194
+ * @param audioInfo Information about the audio that completed
195
+ */
196
+ export type AudioCompleteCallback = (audioInfo: AudioCompleteInfo) => void;
197
+
198
+ /**
199
+ * Callback function type for audio pause notifications
200
+ * @param channelNumber Channel that was paused
201
+ * @param audioInfo Information about the audio that was paused
202
+ */
203
+ export type AudioPauseCallback = (channelNumber: number, audioInfo: AudioInfo) => void;
204
+
205
+ /**
206
+ * Callback function type for audio resume notifications
207
+ * @param channelNumber Channel that was resumed
208
+ * @param audioInfo Information about the audio that was resumed
209
+ */
210
+ export type AudioResumeCallback = (channelNumber: number, audioInfo: AudioInfo) => void;
211
+
212
+ /**
213
+ * Types of audio errors that can occur during playback
214
+ */
215
+ export enum AudioErrorType {
216
+ Abort = 'abort',
217
+ Decode = 'decode',
218
+ Network = 'network',
219
+ Permission = 'permission',
220
+ Timeout = 'timeout',
221
+ Unknown = 'unknown',
222
+ Unsupported = 'unsupported'
223
+ }
224
+
225
+ /**
226
+ * Information about an audio error that occurred during playback or loading
227
+ */
228
+ export interface AudioErrorInfo {
229
+ /** Channel number where the error occurred */
230
+ channelNumber: number;
231
+ /** The actual error object that was thrown */
232
+ error: Error;
233
+ /** Categorized type of error for handling different scenarios */
234
+ errorType: AudioErrorType;
235
+ /** Extracted filename from the source URL */
236
+ fileName: string;
237
+ /** Number of audio files remaining in the queue after this error */
238
+ remainingInQueue: number;
239
+ /** Current retry attempt number (if retrying is enabled) */
240
+ retryAttempt?: number;
241
+ /** Audio file source URL that failed */
242
+ src: string;
243
+ /** Unix timestamp when the error occurred */
244
+ timestamp: number;
245
+ }
246
+
247
+ /**
248
+ * Configuration for automatic retry behavior when audio fails to load or play
249
+ */
250
+ export interface RetryConfig {
251
+ /** Initial delay in milliseconds before first retry attempt */
252
+ baseDelay: number;
253
+ /** Whether automatic retries are enabled for this channel */
254
+ enabled: boolean;
255
+ /** Whether to use exponential backoff (doubling delay each retry) */
256
+ exponentialBackoff: boolean;
257
+ /** Alternative URLs to try if the primary source fails */
258
+ fallbackUrls: string[];
259
+ /** Maximum number of retry attempts before giving up */
260
+ maxRetries: number;
261
+ /** Whether to skip to next track in queue if all retries fail */
262
+ skipOnFailure: boolean;
263
+ /** Timeout in milliseconds for each individual retry attempt */
264
+ timeoutMs: number;
265
+ }
266
+
267
+ /**
268
+ * Configuration options for error recovery mechanisms across the audio system
269
+ */
270
+ export interface ErrorRecoveryOptions {
271
+ /** Whether to automatically retry failed audio loads/plays */
272
+ autoRetry: boolean;
273
+ /** Whether to automatically skip to next track when current fails */
274
+ fallbackToNextTrack: boolean;
275
+ /** Whether to send error data to analytics systems */
276
+ logErrorsToAnalytics: boolean;
277
+ /** Whether to maintain queue integrity when errors occur */
278
+ preserveQueueOnError: boolean;
279
+ /** Whether to display user-visible error feedback */
280
+ showUserFeedback: boolean;
281
+ }
282
+
283
+ /**
284
+ * Callback function type for audio error events
285
+ */
286
+ export type AudioErrorCallback = (errorInfo: AudioErrorInfo) => void;
287
+
288
+ /**
289
+ * Web Audio API configuration options
290
+ */
291
+ export interface WebAudioConfig {
292
+ /** Whether to automatically use Web Audio API on iOS devices */
293
+ autoDetectIOS: boolean;
294
+ /** Whether Web Audio API support is enabled */
295
+ enabled: boolean;
296
+ /** Whether to force Web Audio API usage on all devices */
297
+ forceWebAudio: boolean;
298
+ }
299
+
300
+ /**
301
+ * Web Audio API support information
302
+ */
303
+ export interface WebAudioSupport {
304
+ /** Whether Web Audio API is available in the current environment */
305
+ available: boolean;
306
+ /** Whether the current device is iOS */
307
+ isIOS: boolean;
308
+ /** Whether Web Audio API is currently being used */
309
+ usingWebAudio: boolean;
310
+ /** Reason for current Web Audio API usage state */
311
+ reason: string;
312
+ }
313
+
314
+ /**
315
+ * Web Audio API node set for audio element control
316
+ */
317
+ export interface WebAudioNodeSet {
318
+ /** Gain node for volume control */
319
+ gainNode: GainNode;
320
+ /** Media element source node */
321
+ sourceNode: MediaElementAudioSourceNode;
322
+ }
323
+
324
+ /**
325
+ * Extended audio channel with comprehensive queue management, callback support, and state tracking
326
+ */
327
+ export interface ExtendedAudioQueueChannel {
328
+ /** Set of callbacks triggered when audio completes playback */
329
+ audioCompleteCallbacks: Set<AudioCompleteCallback>;
330
+ /** Set of callbacks triggered when audio errors occur */
331
+ audioErrorCallbacks: Set<AudioErrorCallback>;
332
+ /** Set of callbacks triggered when audio is paused */
333
+ audioPauseCallbacks: Set<AudioPauseCallback>;
334
+ /** Set of callbacks triggered when audio is resumed */
335
+ audioResumeCallbacks: Set<AudioResumeCallback>;
336
+ /** Set of callbacks triggered when audio starts playing */
337
+ audioStartCallbacks: Set<AudioStartCallback>;
338
+ /** Current fade state if pause/resume with fade is active */
339
+ fadeState?: ChannelFadeState;
340
+ /** Whether the channel is currently paused */
341
+ isPaused: boolean;
342
+ /** Active operation lock to prevent race conditions */
343
+ isLocked?: boolean;
344
+ /** Maximum allowed queue size for this channel */
345
+ maxQueueSize?: number;
346
+ /** Map of progress callbacks keyed by audio element or global symbol */
347
+ progressCallbacks: Map<HTMLAudioElement | typeof GLOBAL_PROGRESS_KEY, Set<ProgressCallback>>;
348
+ /** Array of HTMLAudioElement objects in the queue */
349
+ queue: HTMLAudioElement[];
350
+ /** Set of callbacks triggered when the queue changes */
351
+ queueChangeCallbacks: Set<QueueChangeCallback>;
352
+ /** Retry configuration for failed audio loads/plays */
353
+ retryConfig?: RetryConfig;
354
+ /** Current volume level for the channel (0-1) */
355
+ volume: number;
356
+ /** Web Audio API context for this channel */
357
+ webAudioContext?: AudioContext;
358
+ /** Map of Web Audio API nodes for each audio element */
359
+ webAudioNodes?: Map<HTMLAudioElement, WebAudioNodeSet>;
360
+ }
361
+
362
+ /**
363
+ * Easing function types for smooth volume transitions and animations
364
+ */
365
+ export enum EasingType {
366
+ Linear = 'linear',
367
+ EaseIn = 'ease-in',
368
+ EaseOut = 'ease-out',
369
+ EaseInOut = 'ease-in-out'
370
+ }
371
+
372
+ /**
373
+ * Predefined fade types for pause/resume operations with different transition characteristics
374
+ */
375
+ export enum FadeType {
376
+ Linear = 'linear',
377
+ Gentle = 'gentle',
378
+ Dramatic = 'dramatic'
379
+ }
380
+
381
+ /**
382
+ * Timer implementation types used for volume transitions to ensure proper cleanup
383
+ */
384
+ export enum TimerType {
385
+ RequestAnimationFrame = 'raf',
386
+ Timeout = 'timeout'
387
+ }
388
+
389
+ /**
390
+ * Configuration for fade transitions
391
+ */
392
+ export interface FadeConfig {
393
+ /** Duration in milliseconds for the fade transition */
394
+ duration: number;
395
+ /** Easing curve to use when pausing (fading out) */
396
+ pauseCurve: EasingType;
397
+ /** Easing curve to use when resuming (fading in) */
398
+ resumeCurve: EasingType;
399
+ }
400
+
401
+ /**
402
+ * Internal fade state tracking for pause/resume with fade functionality
403
+ */
404
+ export interface ChannelFadeState {
405
+ /** The original volume level before fading began */
406
+ originalVolume: number;
407
+ /** The type of fade being used */
408
+ fadeType: FadeType;
409
+ /** Whether the channel is currently paused due to fade */
410
+ isPaused: boolean;
411
+ /** Custom duration in milliseconds if specified (overrides fade type default) */
412
+ customDuration?: number;
413
+ /** Whether the channel is currently transitioning (during any fade operation) to prevent capturing intermediate volumes during rapid pause/resume toggles */
414
+ isTransitioning?: boolean;
415
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,235 @@
1
+ /**
2
+ * @fileoverview Utility functions for the audioq package
3
+ */
4
+
5
+ import { AudioInfo, QueueSnapshot, ExtendedAudioQueueChannel, QueueItem } from './types';
6
+
7
+ /**
8
+ * Validates an audio URL for security and correctness
9
+ * @param url - The URL to validate
10
+ * @returns The validated URL
11
+ * @throws Error if the URL is invalid or potentially malicious
12
+ * @example
13
+ * ```typescript
14
+ * validateAudioUrl('https://example.com/audio.mp3'); // Valid
15
+ * validateAudioUrl('./sounds/local.wav'); // Valid relative path
16
+ * validateAudioUrl('javascript:alert("XSS")'); // Throws error
17
+ * validateAudioUrl('data:text/html,<script>alert("XSS")</script>'); // Throws error
18
+ * ```
19
+ */
20
+ export const validateAudioUrl = (url: string): string => {
21
+ if (!url || typeof url !== 'string') {
22
+ throw new Error('Audio URL must be a non-empty string');
23
+ }
24
+
25
+ // Trim whitespace
26
+ const trimmedUrl: string = url.trim();
27
+
28
+ // Check for dangerous protocols
29
+ const dangerousProtocols: string[] = [
30
+ 'javascript:',
31
+ 'data:',
32
+ 'vbscript:',
33
+ 'file:',
34
+ 'about:',
35
+ 'chrome:',
36
+ 'chrome-extension:'
37
+ ];
38
+
39
+ const lowerUrl: string = trimmedUrl.toLowerCase();
40
+ for (const protocol of dangerousProtocols) {
41
+ if (lowerUrl.startsWith(protocol)) {
42
+ throw new Error(`Invalid audio URL: dangerous protocol "${protocol}" is not allowed`);
43
+ }
44
+ }
45
+
46
+ // Check for path traversal attempts
47
+ if (trimmedUrl.includes('../') || trimmedUrl.includes('..\\')) {
48
+ throw new Error('Invalid audio URL: path traversal attempts are not allowed');
49
+ }
50
+
51
+ // For relative URLs, ensure they don't start with dangerous characters
52
+ if (!trimmedUrl.startsWith('http://') && !trimmedUrl.startsWith('https://')) {
53
+ // Check for protocol-less URLs that might be interpreted as protocols
54
+ if (trimmedUrl.includes(':') && !trimmedUrl.startsWith('//')) {
55
+ const colonIndex: number = trimmedUrl.indexOf(':');
56
+ const beforeColon: string = trimmedUrl.substring(0, colonIndex);
57
+ // Allow only if it looks like a Windows drive letter (e.g., C:)
58
+ if (!/^[a-zA-Z]$/.test(beforeColon)) {
59
+ throw new Error('Invalid audio URL: suspicious protocol-like pattern detected');
60
+ }
61
+ }
62
+ }
63
+
64
+ // Validate common audio file extensions (warning, not error)
65
+ const hasAudioExtension: boolean = /\.(mp3|wav|ogg|m4a|webm|aac|flac|opus|weba|mp4)$/i.test(
66
+ trimmedUrl
67
+ );
68
+ if (!hasAudioExtension && !trimmedUrl.includes('?')) {
69
+ // Log warning but don't throw - some valid URLs might not have extensions
70
+ // eslint-disable-next-line no-console
71
+ console.warn(`Audio URL "${trimmedUrl}" does not have a recognized audio file extension`);
72
+ }
73
+
74
+ return trimmedUrl;
75
+ };
76
+
77
+ /**
78
+ * Sanitizes a string for safe display in HTML contexts
79
+ * @param text - The text to sanitize
80
+ * @returns The sanitized text safe for display
81
+ * @example
82
+ * ```typescript
83
+ * sanitizeForDisplay('<script>alert("XSS")</script>'); // Returns: '&lt;script&gt;alert("XSS")&lt;/script&gt;'
84
+ * sanitizeForDisplay('normal-file.mp3'); // Returns: 'normal-file.mp3'
85
+ * ```
86
+ */
87
+ export const sanitizeForDisplay = (text: string): string => {
88
+ if (!text || typeof text !== 'string') {
89
+ return '';
90
+ }
91
+
92
+ // Replace HTML special characters
93
+ return text
94
+ .replace(/&/g, '&amp;')
95
+ .replace(/</g, '&lt;')
96
+ .replace(/>/g, '&gt;')
97
+ .replace(/"/g, '&quot;')
98
+ .replace(/'/g, '&#x27;')
99
+ .replace(/\//g, '&#x2F;');
100
+ };
101
+
102
+ /**
103
+ * Extracts the filename from a URL string
104
+ * @param url - The URL to extract the filename from
105
+ * @returns The extracted filename or 'unknown' if extraction fails
106
+ * @example
107
+ * ```typescript
108
+ * extractFileName('https://example.com/audio/song.mp3') // Returns: 'song.mp3'
109
+ * extractFileName('/path/to/audio.wav') // Returns: 'audio.wav'
110
+ * ```
111
+ */
112
+ export const extractFileName = (url: string): string => {
113
+ if (!url || typeof url !== 'string') {
114
+ return sanitizeForDisplay('unknown');
115
+ }
116
+
117
+ // Always use simple string manipulation for consistency
118
+ const segments: string[] = url.split('/');
119
+ const lastSegment: string = segments[segments.length - 1] || '';
120
+
121
+ // Remove query parameters and hash
122
+ const fileName: string = lastSegment.split('?')[0].split('#')[0];
123
+
124
+ // Decode URI components and sanitize
125
+ try {
126
+ return sanitizeForDisplay(decodeURIComponent(fileName || 'unknown'));
127
+ } catch {
128
+ // If decoding fails, return the sanitized raw filename
129
+ return sanitizeForDisplay(fileName || 'unknown');
130
+ }
131
+ };
132
+
133
+ /**
134
+ * Extracts comprehensive audio information from an HTMLAudioElement
135
+ * @param audio - The HTML audio element to extract information from
136
+ * @param channelNumber - Optional channel number to include remaining queue info
137
+ * @param audioChannels - Optional audio channels array to calculate remainingInQueue
138
+ * @returns AudioInfo object with current playback state or null if audio is invalid
139
+ * @example
140
+ * ```typescript
141
+ * const audioElement = new Audio('song.mp3');
142
+ * const info = getAudioInfoFromElement(audioElement);
143
+ * console.log(info?.progress); // Current progress as decimal (0-1)
144
+ *
145
+ * // With channel context for remainingInQueue
146
+ * const infoWithQueue = getAudioInfoFromElement(audioElement, 0, audioChannels);
147
+ * console.log(infoWithQueue?.remainingInQueue); // Number of items left in queue
148
+ * ```
149
+ */
150
+ export const getAudioInfoFromElement = (
151
+ audio: HTMLAudioElement,
152
+ channelNumber?: number,
153
+ audioChannels?: ExtendedAudioQueueChannel[]
154
+ ): AudioInfo | null => {
155
+ if (!audio) return null;
156
+
157
+ const duration: number = isNaN(audio.duration) ? 0 : audio.duration * 1000; // Convert to milliseconds
158
+ const currentTime: number = isNaN(audio.currentTime) ? 0 : audio.currentTime * 1000; // Convert to milliseconds
159
+ const progress: number = duration > 0 ? Math.min(currentTime / duration, 1) : 0;
160
+ const isPlaying: boolean = !audio.paused && !audio.ended && audio.readyState > 2;
161
+
162
+ // Calculate remainingInQueue if channel context is provided
163
+ let remainingInQueue: number = 0;
164
+ if (channelNumber !== undefined && audioChannels?.[channelNumber]) {
165
+ const channel = audioChannels[channelNumber];
166
+ remainingInQueue = Math.max(0, channel.queue.length - 1); // Exclude current playing audio
167
+ }
168
+
169
+ return {
170
+ currentTime,
171
+ duration,
172
+ fileName: extractFileName(audio.src),
173
+ isLooping: audio.loop,
174
+ isPaused: audio.paused && !audio.ended,
175
+ isPlaying,
176
+ progress,
177
+ remainingInQueue,
178
+ src: audio.src,
179
+ volume: audio.volume
180
+ };
181
+ };
182
+
183
+ /**
184
+ * Creates a complete snapshot of a queue's current state
185
+ * @param channelNumber - The channel number to create a snapshot for
186
+ * @param audioChannels - Array of audio channels
187
+ * @returns QueueSnapshot object or null if channel doesn't exist
188
+ * @example
189
+ * ```typescript
190
+ * const snapshot = createQueueSnapshot(0, audioChannels);
191
+ * console.log(`Queue has ${snapshot?.totalItems} items`);
192
+ * ```
193
+ */
194
+ export const createQueueSnapshot = (
195
+ channelNumber: number,
196
+ audioChannels: ExtendedAudioQueueChannel[]
197
+ ): QueueSnapshot | null => {
198
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
199
+ if (!channel) return null;
200
+
201
+ const items: QueueItem[] = channel.queue.map((audio: HTMLAudioElement, index: number) => ({
202
+ duration: isNaN(audio.duration) ? 0 : audio.duration * 1000,
203
+ fileName: extractFileName(audio.src),
204
+ isCurrentlyPlaying: index === 0 && !audio.paused && !audio.ended,
205
+ isLooping: audio.loop,
206
+ src: audio.src,
207
+ volume: audio.volume
208
+ }));
209
+
210
+ return {
211
+ channelNumber,
212
+ currentIndex: 0, // Current playing is always index 0 in our queue structure
213
+ isPaused: channel.isPaused ?? false,
214
+ items,
215
+ totalItems: channel.queue.length,
216
+ volume: channel.volume ?? 1.0
217
+ };
218
+ };
219
+
220
+ /**
221
+ * Removes webpack hash patterns from filenames to get clean, readable names
222
+ * @param fileName - The filename that may contain webpack hashes
223
+ * @returns The cleaned filename with webpack hashes removed
224
+ * @example
225
+ * ```typescript
226
+ * cleanWebpackFilename('song.a1b2c3d4.mp3') // Returns: 'song.mp3'
227
+ * cleanWebpackFilename('notification.1a2b3c4d5e6f7890.wav') // Returns: 'notification.wav'
228
+ * cleanWebpackFilename('music.12345678.ogg') // Returns: 'music.ogg'
229
+ * cleanWebpackFilename('clean-file.mp3') // Returns: 'clean-file.mp3' (unchanged)
230
+ * ```
231
+ */
232
+ export const cleanWebpackFilename = (fileName: string): string => {
233
+ // Remove webpack hash pattern: filename.hash.ext → filename.ext
234
+ return fileName.replace(/\.[a-f0-9]{8,}\./i, '.');
235
+ };