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.
@@ -0,0 +1,378 @@
1
+ /**
2
+ * @fileoverview Queue manipulation functions for the audio-channel-queue package
3
+ * Provides advanced queue management including item removal, reordering, and clearing
4
+ */
5
+
6
+ import {
7
+ ExtendedAudioQueueChannel,
8
+ QueueManipulationResult,
9
+ QueueSnapshot,
10
+ QueueItem
11
+ } from './types';
12
+ import { audioChannels } from './info';
13
+ import { emitQueueChange } from './events';
14
+ import { cleanupProgressTracking } from './events';
15
+ import { createQueueSnapshot, getAudioInfoFromElement } from './utils';
16
+
17
+ /**
18
+ * Removes a specific item from the queue by its slot number (0-based index)
19
+ * Cannot remove the currently playing item (index 0) - use stopCurrentAudioInChannel instead
20
+ * @param queuedSlotNumber - Zero-based index of the item to remove (must be > 0)
21
+ * @param channelNumber - The channel number (defaults to 0)
22
+ * @returns Promise resolving to operation result with success status and updated queue
23
+ * @throws Error if trying to remove currently playing item or invalid slot number
24
+ * @example
25
+ * ```typescript
26
+ * // Remove the second item in queue (index 1)
27
+ * const result = await removeQueuedItem(1, 0);
28
+ * if (result.success) {
29
+ * console.log(`Removed item, queue now has ${result.updatedQueue.totalItems} items`);
30
+ * }
31
+ *
32
+ * // Remove the third item from channel 1
33
+ * await removeQueuedItem(2, 1);
34
+ * ```
35
+ */
36
+ export const removeQueuedItem = async (
37
+ queuedSlotNumber: number,
38
+ channelNumber: number = 0
39
+ ): Promise<QueueManipulationResult> => {
40
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
41
+
42
+ if (!channel) {
43
+ return {
44
+ error: `Channel ${channelNumber} does not exist`,
45
+ success: false
46
+ };
47
+ }
48
+
49
+ if (queuedSlotNumber < 0 || queuedSlotNumber >= channel.queue.length) {
50
+ return {
51
+ error:
52
+ `Invalid slot number ${queuedSlotNumber}. Queue has ${channel.queue.length} items ` +
53
+ `(indices 0-${channel.queue.length - 1})`,
54
+ success: false
55
+ };
56
+ }
57
+
58
+ if (queuedSlotNumber === 0) {
59
+ return {
60
+ error:
61
+ 'Cannot remove currently playing item (index 0). ' +
62
+ 'Use stopCurrentAudioInChannel() instead',
63
+ success: false
64
+ };
65
+ }
66
+
67
+ // Remove the audio element from the queue
68
+ const removedAudio: HTMLAudioElement = channel.queue.splice(queuedSlotNumber, 1)[0];
69
+
70
+ // Clean up any progress tracking for the removed audio
71
+ cleanupProgressTracking(removedAudio, channelNumber, audioChannels);
72
+
73
+ // Emit queue change event
74
+ emitQueueChange(channelNumber, audioChannels);
75
+
76
+ const updatedQueue: QueueSnapshot | null = createQueueSnapshot(channelNumber, audioChannels);
77
+
78
+ return {
79
+ success: true,
80
+ updatedQueue: updatedQueue ?? undefined
81
+ };
82
+ };
83
+
84
+ /**
85
+ * Reorders a queue item by moving it from one position to another
86
+ * Cannot reorder the currently playing item (index 0)
87
+ * @param currentQueuedSlotNumber - Current zero-based index of the item to move (must be > 0)
88
+ * @param newQueuedSlotNumber - New zero-based index where the item should be placed (must be > 0)
89
+ * @param channelNumber - The channel number (defaults to 0)
90
+ * @returns Promise resolving to operation result with success status and updated queue
91
+ * @throws Error if trying to reorder currently playing item or invalid slot numbers
92
+ * @example
93
+ * ```typescript
94
+ * // Move item from position 2 to position 1 (make it play next)
95
+ * const result = await reorderQueue(2, 1, 0);
96
+ * if (result.success) {
97
+ * console.log('Item moved successfully');
98
+ * }
99
+ *
100
+ * // Move item from position 1 to end of queue
101
+ * await reorderQueue(1, 4, 0); // Assuming queue has 5+ items
102
+ * ```
103
+ */
104
+ export const reorderQueue = async (
105
+ currentQueuedSlotNumber: number,
106
+ newQueuedSlotNumber: number,
107
+ channelNumber: number = 0
108
+ ): Promise<QueueManipulationResult> => {
109
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
110
+
111
+ if (!channel) {
112
+ return {
113
+ error: `Channel ${channelNumber} does not exist`,
114
+ success: false
115
+ };
116
+ }
117
+
118
+ if (currentQueuedSlotNumber < 0 || currentQueuedSlotNumber >= channel.queue.length) {
119
+ return {
120
+ error:
121
+ `Invalid current slot number ${currentQueuedSlotNumber}. Queue has ` +
122
+ `${channel.queue.length} items (indices 0-${channel.queue.length - 1})`,
123
+ success: false
124
+ };
125
+ }
126
+
127
+ if (newQueuedSlotNumber < 0 || newQueuedSlotNumber >= channel.queue.length) {
128
+ return {
129
+ error:
130
+ `Invalid new slot number ${newQueuedSlotNumber}. Queue has ` +
131
+ `${channel.queue.length} items (indices 0-${channel.queue.length - 1})`,
132
+ success: false
133
+ };
134
+ }
135
+
136
+ if (currentQueuedSlotNumber === 0) {
137
+ return {
138
+ error:
139
+ 'Cannot reorder currently playing item (index 0). ' + 'Stop current audio first if needed',
140
+ success: false
141
+ };
142
+ }
143
+
144
+ if (newQueuedSlotNumber === 0) {
145
+ return {
146
+ error:
147
+ 'Cannot move item to currently playing position (index 0). ' +
148
+ 'Use queueAudioPriority() to add items to front of queue',
149
+ success: false
150
+ };
151
+ }
152
+
153
+ if (currentQueuedSlotNumber === newQueuedSlotNumber) {
154
+ // No change needed, but return success
155
+ const updatedQueue: QueueSnapshot | null = createQueueSnapshot(channelNumber, audioChannels);
156
+ return {
157
+ success: true,
158
+ updatedQueue: updatedQueue ?? undefined
159
+ };
160
+ }
161
+
162
+ // Remove the item from its current position
163
+ const audioToMove: HTMLAudioElement = channel.queue.splice(currentQueuedSlotNumber, 1)[0];
164
+
165
+ // Insert it at the new position
166
+ channel.queue.splice(newQueuedSlotNumber, 0, audioToMove);
167
+
168
+ // Emit queue change event
169
+ emitQueueChange(channelNumber, audioChannels);
170
+
171
+ const updatedQueue: QueueSnapshot | null = createQueueSnapshot(channelNumber, audioChannels);
172
+
173
+ return {
174
+ success: true,
175
+ updatedQueue: updatedQueue ?? undefined
176
+ };
177
+ };
178
+
179
+ /**
180
+ * Clears all queued audio items after the currently playing item
181
+ * The current audio will continue playing but nothing will follow it
182
+ * @param channelNumber - The channel number (defaults to 0)
183
+ * @returns Promise resolving to operation result with success status and updated queue
184
+ * @example
185
+ * ```typescript
186
+ * // Let current song finish but clear everything after it
187
+ * const result = await clearQueueAfterCurrent(0);
188
+ * if (result.success) {
189
+ * console.log(`Cleared queue, current audio will be the last to play`);
190
+ * }
191
+ * ```
192
+ */
193
+ export const clearQueueAfterCurrent = async (
194
+ channelNumber: number = 0
195
+ ): Promise<QueueManipulationResult> => {
196
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
197
+
198
+ if (!channel) {
199
+ // For empty/non-existent channels, we can consider this a successful no-op
200
+ // since there's nothing to clear anyway
201
+ return {
202
+ success: true,
203
+ updatedQueue: {
204
+ channelNumber,
205
+ currentIndex: -1,
206
+ isPaused: false,
207
+ items: [],
208
+ totalItems: 0,
209
+ volume: 1.0
210
+ }
211
+ };
212
+ }
213
+
214
+ if (channel.queue.length <= 1) {
215
+ // Nothing to clear - either empty queue or only current audio
216
+ const updatedQueue: QueueSnapshot | null = createQueueSnapshot(channelNumber, audioChannels);
217
+ return {
218
+ success: true,
219
+ updatedQueue: updatedQueue ?? undefined
220
+ };
221
+ }
222
+
223
+ // Clean up progress tracking for all items except the current one
224
+ for (let i: number = 1; i < channel.queue.length; i++) {
225
+ cleanupProgressTracking(channel.queue[i], channelNumber, audioChannels);
226
+ }
227
+
228
+ // Keep only the currently playing audio (index 0)
229
+ channel.queue = channel.queue.slice(0, 1);
230
+
231
+ // Emit queue change event
232
+ emitQueueChange(channelNumber, audioChannels);
233
+
234
+ const updatedQueue: QueueSnapshot | null = createQueueSnapshot(channelNumber, audioChannels);
235
+
236
+ return {
237
+ success: true,
238
+ updatedQueue: updatedQueue ?? undefined
239
+ };
240
+ };
241
+
242
+ /**
243
+ * Gets information about a specific queue item by its slot number
244
+ * @param queueSlotNumber - Zero-based index of the queue item
245
+ * @param channelNumber - The channel number (defaults to 0)
246
+ * @returns QueueItem information or null if slot doesn't exist
247
+ * @example
248
+ * ```typescript
249
+ * const itemInfo = getQueueItemInfo(1, 0);
250
+ * if (itemInfo) {
251
+ * console.log(`Next to play: ${itemInfo.fileName}`);
252
+ * console.log(`Duration: ${itemInfo.duration}ms`);
253
+ * }
254
+ * ```
255
+ */
256
+ export const getQueueItemInfo = (
257
+ queueSlotNumber: number,
258
+ channelNumber: number = 0
259
+ ): QueueItem | null => {
260
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
261
+
262
+ if (!channel || queueSlotNumber < 0 || queueSlotNumber >= channel.queue.length) {
263
+ return null;
264
+ }
265
+
266
+ const audio: HTMLAudioElement = channel.queue[queueSlotNumber];
267
+ const audioInfo = getAudioInfoFromElement(audio, channelNumber, audioChannels);
268
+
269
+ if (!audioInfo) {
270
+ return null;
271
+ }
272
+
273
+ const { duration, fileName, isLooping, isPlaying, src, volume } = audioInfo;
274
+
275
+ return {
276
+ duration,
277
+ fileName,
278
+ isCurrentlyPlaying: queueSlotNumber === 0 && isPlaying,
279
+ isLooping,
280
+ src,
281
+ volume
282
+ };
283
+ };
284
+
285
+ /**
286
+ * Gets the current queue length for a specific channel
287
+ * @param channelNumber - The channel number (defaults to 0)
288
+ * @returns Number of items in the queue, or 0 if channel doesn't exist
289
+ * @example
290
+ * ```typescript
291
+ * const queueSize = getQueueLength(0);
292
+ * console.log(`Channel 0 has ${queueSize} items in queue`);
293
+ * ```
294
+ */
295
+ export const getQueueLength = (channelNumber: number = 0): number => {
296
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
297
+ return channel ? channel.queue.length : 0;
298
+ };
299
+
300
+ /**
301
+ * Swaps the positions of two queue items
302
+ * Cannot swap with the currently playing item (index 0)
303
+ * @param slotA - Zero-based index of first item to swap (must be > 0)
304
+ * @param slotB - Zero-based index of second item to swap (must be > 0)
305
+ * @param channelNumber - The channel number (defaults to 0)
306
+ * @returns Promise resolving to operation result with success status and updated queue
307
+ * @example
308
+ * ```typescript
309
+ * // Swap the second and third items in queue
310
+ * const result = await swapQueueItems(1, 2, 0);
311
+ * if (result.success) {
312
+ * console.log('Items swapped successfully');
313
+ * }
314
+ * ```
315
+ */
316
+ export const swapQueueItems = async (
317
+ slotA: number,
318
+ slotB: number,
319
+ channelNumber: number = 0
320
+ ): Promise<QueueManipulationResult> => {
321
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
322
+
323
+ if (!channel) {
324
+ return {
325
+ error: `Channel ${channelNumber} does not exist`,
326
+ success: false
327
+ };
328
+ }
329
+
330
+ if (slotA < 0 || slotA >= channel.queue.length) {
331
+ return {
332
+ error:
333
+ `Invalid slot A ${slotA}. Queue has ${channel.queue.length} items ` +
334
+ `(indices 0-${channel.queue.length - 1})`,
335
+ success: false
336
+ };
337
+ }
338
+
339
+ if (slotB < 0 || slotB >= channel.queue.length) {
340
+ return {
341
+ error:
342
+ `Invalid slot B ${slotB}. Queue has ${channel.queue.length} items ` +
343
+ `(indices 0-${channel.queue.length - 1})`,
344
+ success: false
345
+ };
346
+ }
347
+
348
+ if (slotA === 0 || slotB === 0) {
349
+ return {
350
+ error: 'Cannot swap with currently playing item (index 0)',
351
+ success: false
352
+ };
353
+ }
354
+
355
+ if (slotA === slotB) {
356
+ // No change needed, but return success
357
+ const updatedQueue: QueueSnapshot | null = createQueueSnapshot(channelNumber, audioChannels);
358
+ return {
359
+ success: true,
360
+ updatedQueue: updatedQueue ?? undefined
361
+ };
362
+ }
363
+
364
+ // Swap the audio elements
365
+ const temp: HTMLAudioElement = channel.queue[slotA];
366
+ channel.queue[slotA] = channel.queue[slotB];
367
+ channel.queue[slotB] = temp;
368
+
369
+ // Emit queue change event
370
+ emitQueueChange(channelNumber, audioChannels);
371
+
372
+ const updatedQueue: QueueSnapshot | null = createQueueSnapshot(channelNumber, audioChannels);
373
+
374
+ return {
375
+ success: true,
376
+ updatedQueue: updatedQueue ?? undefined
377
+ };
378
+ };
package/src/types.ts CHANGED
@@ -2,6 +2,17 @@
2
2
  * @fileoverview Type definitions for the audio-channel-queue package
3
3
  */
4
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
+
5
16
  /**
6
17
  * Array of HTMLAudioElement objects representing an audio queue
7
18
  */
@@ -10,7 +21,7 @@ export type AudioQueue = HTMLAudioElement[];
10
21
  /**
11
22
  * Basic audio queue channel structure
12
23
  */
13
- export type AudioQueueChannel = {
24
+ export interface AudioQueueChannel {
14
25
  queue: AudioQueue;
15
26
  }
16
27
 
@@ -33,19 +44,33 @@ export interface VolumeConfig {
33
44
  }
34
45
 
35
46
  /**
36
- * Audio file configuration for queueing
47
+ * Configuration options for queuing audio
37
48
  */
38
49
  export interface AudioQueueOptions {
39
- /** Whether to add the audio to the front of the queue (defaults to false) */
50
+ /** Whether to add this audio to the front of the queue (after currently playing) */
40
51
  addToFront?: boolean;
41
52
  /** Whether the audio should loop when it finishes */
42
53
  loop?: boolean;
43
- /** Whether to add the audio with priority (same as addToFront) */
54
+ /** Maximum number of items allowed in the queue (defaults to unlimited) */
55
+ maxQueueSize?: number;
56
+ /** @deprecated Use addToFront instead. Legacy support for priority queuing */
44
57
  priority?: boolean;
45
- /** Volume level for this specific audio file (0-1, defaults to channel volume) */
58
+ /** Volume level for this specific audio (0-1) */
46
59
  volume?: number;
47
60
  }
48
61
 
62
+ /**
63
+ * Global queue configuration options
64
+ */
65
+ export interface QueueConfig {
66
+ /** Default maximum queue size across all channels (defaults to unlimited) */
67
+ defaultMaxQueueSize?: number;
68
+ /** Whether to drop oldest items when queue is full (defaults to false - reject new items) */
69
+ dropOldestWhenFull?: boolean;
70
+ /** Whether to show warnings when queue limits are reached (defaults to true) */
71
+ showQueueWarnings?: boolean;
72
+ }
73
+
49
74
  /**
50
75
  * Comprehensive audio information interface providing metadata about currently playing audio
51
76
  */
@@ -136,6 +161,18 @@ export interface QueueSnapshot {
136
161
  volume: number;
137
162
  }
138
163
 
164
+ /**
165
+ * Information about a queue manipulation operation result
166
+ */
167
+ export interface QueueManipulationResult {
168
+ /** Error message if operation failed */
169
+ error?: string;
170
+ /** Whether the operation was successful */
171
+ success: boolean;
172
+ /** The queue snapshot after the operation (if successful) */
173
+ updatedQueue?: QueueSnapshot;
174
+ }
175
+
139
176
  /**
140
177
  * Callback function type for audio progress updates
141
178
  * @param info Current audio information
@@ -218,7 +255,7 @@ export interface ErrorRecoveryOptions {
218
255
  export type AudioErrorCallback = (errorInfo: AudioErrorInfo) => void;
219
256
 
220
257
  /**
221
- * Extended audio queue channel with error handling capabilities
258
+ * Extended audio channel with queue management and callback support
222
259
  */
223
260
  export interface ExtendedAudioQueueChannel {
224
261
  audioCompleteCallbacks: Set<AudioCompleteCallback>;
@@ -227,13 +264,16 @@ export interface ExtendedAudioQueueChannel {
227
264
  audioResumeCallbacks: Set<AudioResumeCallback>;
228
265
  audioStartCallbacks: Set<AudioStartCallback>;
229
266
  fadeState?: ChannelFadeState;
230
- isPaused?: boolean;
231
- progressCallbacks: Map<HTMLAudioElement | null, Set<ProgressCallback>>;
267
+ isPaused: boolean;
268
+ /** Active operation lock to prevent race conditions */
269
+ isLocked?: boolean;
270
+ /** Maximum allowed queue size for this channel */
271
+ maxQueueSize?: number;
272
+ progressCallbacks: Map<HTMLAudioElement | typeof GLOBAL_PROGRESS_KEY, Set<ProgressCallback>>;
232
273
  queue: HTMLAudioElement[];
233
274
  queueChangeCallbacks: Set<QueueChangeCallback>;
234
275
  retryConfig?: RetryConfig;
235
- volume?: number;
236
- volumeConfig?: VolumeConfig;
276
+ volume: number;
237
277
  }
238
278
 
239
279
  /**
@@ -255,6 +295,14 @@ export enum FadeType {
255
295
  Dramatic = 'dramatic'
256
296
  }
257
297
 
298
+ /**
299
+ * Timer types for volume transitions to ensure proper cleanup
300
+ */
301
+ export enum TimerType {
302
+ RequestAnimationFrame = 'raf',
303
+ Timeout = 'timeout'
304
+ }
305
+
258
306
  /**
259
307
  * Configuration for fade transitions
260
308
  */
@@ -277,4 +325,8 @@ export interface ChannelFadeState {
277
325
  fadeType: FadeType;
278
326
  /** Whether the channel is currently paused due to fade */
279
327
  isPaused: boolean;
280
- }
328
+ /** Custom duration in milliseconds if specified (overrides fade type default) */
329
+ customDuration?: number;
330
+ /** Whether the channel is currently transitioning (during any fade operation) to prevent capturing intermediate volumes during rapid pause/resume toggles */
331
+ isTransitioning?: boolean;
332
+ }
package/src/utils.ts CHANGED
@@ -4,6 +4,101 @@
4
4
 
5
5
  import { AudioInfo, QueueSnapshot, ExtendedAudioQueueChannel, QueueItem } from './types';
6
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
+
7
102
  /**
8
103
  * Extracts the filename from a URL string
9
104
  * @param url - The URL to extract the filename from
@@ -15,17 +110,23 @@ import { AudioInfo, QueueSnapshot, ExtendedAudioQueueChannel, QueueItem } from '
15
110
  * ```
16
111
  */
17
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
18
125
  try {
19
- const urlObj: URL = new URL(url);
20
- const pathname: string = urlObj.pathname;
21
- const segments: string[] = pathname.split('/');
22
- const fileName: string = segments[segments.length - 1];
23
- return fileName || 'unknown';
126
+ return sanitizeForDisplay(decodeURIComponent(fileName || 'unknown'));
24
127
  } catch {
25
- // If URL parsing fails, try simple string manipulation
26
- const segments: string[] = url.split('/');
27
- const fileName: string = segments[segments.length - 1];
28
- return fileName || 'unknown';
128
+ // If decoding fails, return the sanitized raw filename
129
+ return sanitizeForDisplay(fileName || 'unknown');
29
130
  }
30
131
  };
31
132
 
@@ -40,14 +141,14 @@ export const extractFileName = (url: string): string => {
40
141
  * const audioElement = new Audio('song.mp3');
41
142
  * const info = getAudioInfoFromElement(audioElement);
42
143
  * console.log(info?.progress); // Current progress as decimal (0-1)
43
- *
144
+ *
44
145
  * // With channel context for remainingInQueue
45
146
  * const infoWithQueue = getAudioInfoFromElement(audioElement, 0, audioChannels);
46
147
  * console.log(infoWithQueue?.remainingInQueue); // Number of items left in queue
47
148
  * ```
48
149
  */
49
150
  export const getAudioInfoFromElement = (
50
- audio: HTMLAudioElement,
151
+ audio: HTMLAudioElement,
51
152
  channelNumber?: number,
52
153
  audioChannels?: ExtendedAudioQueueChannel[]
53
154
  ): AudioInfo | null => {
@@ -60,7 +161,7 @@ export const getAudioInfoFromElement = (
60
161
 
61
162
  // Calculate remainingInQueue if channel context is provided
62
163
  let remainingInQueue: number = 0;
63
- if (channelNumber !== undefined && audioChannels && audioChannels[channelNumber]) {
164
+ if (channelNumber !== undefined && audioChannels?.[channelNumber]) {
64
165
  const channel = audioChannels[channelNumber];
65
166
  remainingInQueue = Math.max(0, channel.queue.length - 1); // Exclude current playing audio
66
167
  }
@@ -91,7 +192,7 @@ export const getAudioInfoFromElement = (
91
192
  * ```
92
193
  */
93
194
  export const createQueueSnapshot = (
94
- channelNumber: number,
195
+ channelNumber: number,
95
196
  audioChannels: ExtendedAudioQueueChannel[]
96
197
  ): QueueSnapshot | null => {
97
198
  const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
@@ -109,10 +210,10 @@ export const createQueueSnapshot = (
109
210
  return {
110
211
  channelNumber,
111
212
  currentIndex: 0, // Current playing is always index 0 in our queue structure
112
- isPaused: channel.isPaused || false,
213
+ isPaused: channel.isPaused ?? false,
113
214
  items,
114
215
  totalItems: channel.queue.length,
115
- volume: channel.volume || 1.0
216
+ volume: channel.volume ?? 1.0
116
217
  };
117
218
  };
118
219
 
@@ -131,4 +232,4 @@ export const createQueueSnapshot = (
131
232
  export const cleanWebpackFilename = (fileName: string): string => {
132
233
  // Remove webpack hash pattern: filename.hash.ext → filename.ext
133
234
  return fileName.replace(/\.[a-f0-9]{8,}\./i, '.');
134
- };
235
+ };