audio-channel-queue 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/utils.js CHANGED
@@ -3,7 +3,92 @@
3
3
  * @fileoverview Utility functions for the audio-channel-queue package
4
4
  */
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.cleanWebpackFilename = exports.createQueueSnapshot = exports.getAudioInfoFromElement = exports.extractFileName = void 0;
6
+ exports.cleanWebpackFilename = exports.createQueueSnapshot = exports.getAudioInfoFromElement = exports.extractFileName = exports.sanitizeForDisplay = exports.validateAudioUrl = void 0;
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
+ const validateAudioUrl = (url) => {
21
+ if (!url || typeof url !== 'string') {
22
+ throw new Error('Audio URL must be a non-empty string');
23
+ }
24
+ // Trim whitespace
25
+ const trimmedUrl = url.trim();
26
+ // Check for dangerous protocols
27
+ const dangerousProtocols = [
28
+ 'javascript:',
29
+ 'data:',
30
+ 'vbscript:',
31
+ 'file:',
32
+ 'about:',
33
+ 'chrome:',
34
+ 'chrome-extension:'
35
+ ];
36
+ const lowerUrl = trimmedUrl.toLowerCase();
37
+ for (const protocol of dangerousProtocols) {
38
+ if (lowerUrl.startsWith(protocol)) {
39
+ throw new Error(`Invalid audio URL: dangerous protocol "${protocol}" is not allowed`);
40
+ }
41
+ }
42
+ // Check for path traversal attempts
43
+ if (trimmedUrl.includes('../') || trimmedUrl.includes('..\\')) {
44
+ throw new Error('Invalid audio URL: path traversal attempts are not allowed');
45
+ }
46
+ // For relative URLs, ensure they don't start with dangerous characters
47
+ if (!trimmedUrl.startsWith('http://') && !trimmedUrl.startsWith('https://')) {
48
+ // Check for protocol-less URLs that might be interpreted as protocols
49
+ if (trimmedUrl.includes(':') && !trimmedUrl.startsWith('//')) {
50
+ const colonIndex = trimmedUrl.indexOf(':');
51
+ const beforeColon = trimmedUrl.substring(0, colonIndex);
52
+ // Allow only if it looks like a Windows drive letter (e.g., C:)
53
+ if (!/^[a-zA-Z]$/.test(beforeColon)) {
54
+ throw new Error('Invalid audio URL: suspicious protocol-like pattern detected');
55
+ }
56
+ }
57
+ }
58
+ // Validate common audio file extensions (warning, not error)
59
+ const hasAudioExtension = /\.(mp3|wav|ogg|m4a|webm|aac|flac|opus|weba|mp4)$/i.test(trimmedUrl);
60
+ if (!hasAudioExtension && !trimmedUrl.includes('?')) {
61
+ // Log warning but don't throw - some valid URLs might not have extensions
62
+ // eslint-disable-next-line no-console
63
+ console.warn(`Audio URL "${trimmedUrl}" does not have a recognized audio file extension`);
64
+ }
65
+ return trimmedUrl;
66
+ };
67
+ exports.validateAudioUrl = validateAudioUrl;
68
+ /**
69
+ * Sanitizes a string for safe display in HTML contexts
70
+ * @param text - The text to sanitize
71
+ * @returns The sanitized text safe for display
72
+ * @example
73
+ * ```typescript
74
+ * sanitizeForDisplay('<script>alert("XSS")</script>'); // Returns: '&lt;script&gt;alert("XSS")&lt;/script&gt;'
75
+ * sanitizeForDisplay('normal-file.mp3'); // Returns: 'normal-file.mp3'
76
+ * ```
77
+ */
78
+ const sanitizeForDisplay = (text) => {
79
+ if (!text || typeof text !== 'string') {
80
+ return '';
81
+ }
82
+ // Replace HTML special characters
83
+ return text
84
+ .replace(/&/g, '&amp;')
85
+ .replace(/</g, '&lt;')
86
+ .replace(/>/g, '&gt;')
87
+ .replace(/"/g, '&quot;')
88
+ .replace(/'/g, '&#x27;')
89
+ .replace(/\//g, '&#x2F;');
90
+ };
91
+ exports.sanitizeForDisplay = sanitizeForDisplay;
7
92
  /**
8
93
  * Extracts the filename from a URL string
9
94
  * @param url - The URL to extract the filename from
@@ -15,18 +100,21 @@ exports.cleanWebpackFilename = exports.createQueueSnapshot = exports.getAudioInf
15
100
  * ```
16
101
  */
17
102
  const extractFileName = (url) => {
103
+ if (!url || typeof url !== 'string') {
104
+ return (0, exports.sanitizeForDisplay)('unknown');
105
+ }
106
+ // Always use simple string manipulation for consistency
107
+ const segments = url.split('/');
108
+ const lastSegment = segments[segments.length - 1] || '';
109
+ // Remove query parameters and hash
110
+ const fileName = lastSegment.split('?')[0].split('#')[0];
111
+ // Decode URI components and sanitize
18
112
  try {
19
- const urlObj = new URL(url);
20
- const pathname = urlObj.pathname;
21
- const segments = pathname.split('/');
22
- const fileName = segments[segments.length - 1];
23
- return fileName || 'unknown';
113
+ return (0, exports.sanitizeForDisplay)(decodeURIComponent(fileName || 'unknown'));
24
114
  }
25
115
  catch (_a) {
26
- // If URL parsing fails, try simple string manipulation
27
- const segments = url.split('/');
28
- const fileName = segments[segments.length - 1];
29
- return fileName || 'unknown';
116
+ // If decoding fails, return the sanitized raw filename
117
+ return (0, exports.sanitizeForDisplay)(fileName || 'unknown');
30
118
  }
31
119
  };
32
120
  exports.extractFileName = extractFileName;
@@ -56,7 +144,7 @@ const getAudioInfoFromElement = (audio, channelNumber, audioChannels) => {
56
144
  const isPlaying = !audio.paused && !audio.ended && audio.readyState > 2;
57
145
  // Calculate remainingInQueue if channel context is provided
58
146
  let remainingInQueue = 0;
59
- if (channelNumber !== undefined && audioChannels && audioChannels[channelNumber]) {
147
+ if (channelNumber !== undefined && (audioChannels === null || audioChannels === void 0 ? void 0 : audioChannels[channelNumber])) {
60
148
  const channel = audioChannels[channelNumber];
61
149
  remainingInQueue = Math.max(0, channel.queue.length - 1); // Exclude current playing audio
62
150
  }
@@ -86,6 +174,7 @@ exports.getAudioInfoFromElement = getAudioInfoFromElement;
86
174
  * ```
87
175
  */
88
176
  const createQueueSnapshot = (channelNumber, audioChannels) => {
177
+ var _a, _b;
89
178
  const channel = audioChannels[channelNumber];
90
179
  if (!channel)
91
180
  return null;
@@ -100,10 +189,10 @@ const createQueueSnapshot = (channelNumber, audioChannels) => {
100
189
  return {
101
190
  channelNumber,
102
191
  currentIndex: 0, // Current playing is always index 0 in our queue structure
103
- isPaused: channel.isPaused || false,
192
+ isPaused: (_a = channel.isPaused) !== null && _a !== void 0 ? _a : false,
104
193
  items,
105
194
  totalItems: channel.queue.length,
106
- volume: channel.volume || 1.0
195
+ volume: (_b = channel.volume) !== null && _b !== void 0 ? _b : 1.0
107
196
  };
108
197
  };
109
198
  exports.createQueueSnapshot = createQueueSnapshot;
package/dist/volume.d.ts CHANGED
@@ -32,6 +32,7 @@ export declare const transitionVolume: (channelNumber: number, targetVolume: num
32
32
  * @param volume - Volume level (0-1)
33
33
  * @param transitionDuration - Optional transition duration in milliseconds
34
34
  * @param easing - Optional easing function
35
+ * @throws Error if the channel number exceeds the maximum allowed channels
35
36
  * @example
36
37
  * ```typescript
37
38
  * setChannelVolume(0, 0.5); // Set channel 0 to 50%
@@ -76,6 +77,7 @@ export declare const setAllChannelsVolume: (volume: number) => Promise<void>;
76
77
  * Configures volume ducking for channels. When the priority channel plays audio,
77
78
  * all other channels will be automatically reduced to the ducking volume level
78
79
  * @param config - Volume ducking configuration
80
+ * @throws Error if the priority channel number exceeds the maximum allowed channels
79
81
  * @example
80
82
  * ```typescript
81
83
  * // When channel 1 plays, reduce all other channels to 20% volume
@@ -116,8 +118,19 @@ export declare const applyVolumeDucking: (activeChannelNumber: number) => Promis
116
118
  */
117
119
  export declare const fadeVolume: (channelNumber: number, targetVolume: number, duration?: number, easing?: EasingType) => Promise<void>;
118
120
  /**
119
- * Restores normal volume levels when priority channel stops with smooth transitions
121
+ * Restores normal volume levels when priority channel queue becomes empty
120
122
  * @param stoppedChannelNumber - The channel that just stopped playing
121
123
  * @internal
122
124
  */
123
125
  export declare const restoreVolumeLevels: (stoppedChannelNumber: number) => Promise<void>;
126
+ /**
127
+ * Cancels any active volume transition for a specific channel
128
+ * @param channelNumber - The channel number to cancel transitions for
129
+ * @internal
130
+ */
131
+ export declare const cancelVolumeTransition: (channelNumber: number) => void;
132
+ /**
133
+ * Cancels all active volume transitions across all channels
134
+ * @internal
135
+ */
136
+ export declare const cancelAllVolumeTransitions: () => void;
package/dist/volume.js CHANGED
@@ -12,18 +12,37 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
12
12
  });
13
13
  };
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.restoreVolumeLevels = exports.fadeVolume = exports.applyVolumeDucking = exports.clearVolumeDucking = exports.setVolumeDucking = exports.setAllChannelsVolume = exports.getAllChannelsVolume = exports.getChannelVolume = exports.setChannelVolume = exports.transitionVolume = exports.getFadeConfig = void 0;
15
+ exports.cancelAllVolumeTransitions = exports.cancelVolumeTransition = exports.restoreVolumeLevels = exports.fadeVolume = exports.applyVolumeDucking = exports.clearVolumeDucking = exports.setVolumeDucking = exports.setAllChannelsVolume = exports.getAllChannelsVolume = exports.getChannelVolume = exports.setChannelVolume = exports.transitionVolume = exports.getFadeConfig = void 0;
16
16
  const types_1 = require("./types");
17
17
  const info_1 = require("./info");
18
18
  // Store active volume transitions to handle interruptions
19
19
  const activeTransitions = new Map();
20
+ // Track which timer type was used for each channel
21
+ const timerTypes = new Map();
22
+ /**
23
+ * Global volume ducking configuration
24
+ * Stores the volume ducking settings that apply to all channels
25
+ */
26
+ let globalVolumeConfig = null;
20
27
  /**
21
28
  * Predefined fade configurations for different transition types
22
29
  */
23
- const FADE_CONFIGS = {
24
- [types_1.FadeType.Dramatic]: { duration: 800, pauseCurve: types_1.EasingType.EaseIn, resumeCurve: types_1.EasingType.EaseOut },
25
- [types_1.FadeType.Gentle]: { duration: 800, pauseCurve: types_1.EasingType.EaseOut, resumeCurve: types_1.EasingType.EaseIn },
26
- [types_1.FadeType.Linear]: { duration: 800, pauseCurve: types_1.EasingType.Linear, resumeCurve: types_1.EasingType.Linear }
30
+ const fadeConfigs = {
31
+ [types_1.FadeType.Dramatic]: {
32
+ duration: 800,
33
+ pauseCurve: types_1.EasingType.EaseIn,
34
+ resumeCurve: types_1.EasingType.EaseOut
35
+ },
36
+ [types_1.FadeType.Gentle]: {
37
+ duration: 800,
38
+ pauseCurve: types_1.EasingType.EaseOut,
39
+ resumeCurve: types_1.EasingType.EaseIn
40
+ },
41
+ [types_1.FadeType.Linear]: {
42
+ duration: 800,
43
+ pauseCurve: types_1.EasingType.Linear,
44
+ resumeCurve: types_1.EasingType.Linear
45
+ }
27
46
  };
28
47
  /**
29
48
  * Gets the fade configuration for a specific fade type
@@ -36,7 +55,7 @@ const FADE_CONFIGS = {
36
55
  * ```
37
56
  */
38
57
  const getFadeConfig = (fadeType) => {
39
- return Object.assign({}, FADE_CONFIGS[fadeType]);
58
+ return Object.assign({}, fadeConfigs[fadeType]);
40
59
  };
41
60
  exports.getFadeConfig = getFadeConfig;
42
61
  /**
@@ -46,7 +65,7 @@ const easingFunctions = {
46
65
  [types_1.EasingType.Linear]: (t) => t,
47
66
  [types_1.EasingType.EaseIn]: (t) => t * t,
48
67
  [types_1.EasingType.EaseOut]: (t) => t * (2 - t),
49
- [types_1.EasingType.EaseInOut]: (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
68
+ [types_1.EasingType.EaseInOut]: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t)
50
69
  };
51
70
  /**
52
71
  * Smoothly transitions volume for a specific channel over time
@@ -69,16 +88,28 @@ const transitionVolume = (channelNumber_1, targetVolume_1, ...args_1) => __await
69
88
  const volumeDelta = targetVolume - startVolume;
70
89
  // Cancel any existing transition for this channel
71
90
  if (activeTransitions.has(channelNumber)) {
72
- clearTimeout(activeTransitions.get(channelNumber));
91
+ const transitionId = activeTransitions.get(channelNumber);
92
+ const timerType = timerTypes.get(channelNumber);
93
+ if (transitionId) {
94
+ // Cancel based on the timer type that was actually used
95
+ if (timerType === types_1.TimerType.RequestAnimationFrame &&
96
+ typeof cancelAnimationFrame !== 'undefined') {
97
+ cancelAnimationFrame(transitionId);
98
+ }
99
+ else if (timerType === types_1.TimerType.Timeout) {
100
+ clearTimeout(transitionId);
101
+ }
102
+ }
73
103
  activeTransitions.delete(channelNumber);
104
+ timerTypes.delete(channelNumber);
74
105
  }
75
106
  // If no change needed, resolve immediately
76
107
  if (Math.abs(volumeDelta) < 0.001) {
77
108
  channel.volume = targetVolume;
78
109
  return Promise.resolve();
79
110
  }
80
- // Handle zero duration - instant change
81
- if (duration === 0) {
111
+ // Handle zero or negative duration - instant change
112
+ if (duration <= 0) {
82
113
  channel.volume = targetVolume;
83
114
  if (channel.queue.length > 0) {
84
115
  channel.queue[0].volume = targetVolume;
@@ -92,7 +123,7 @@ const transitionVolume = (channelNumber_1, targetVolume_1, ...args_1) => __await
92
123
  const elapsed = performance.now() - startTime;
93
124
  const progress = Math.min(elapsed / duration, 1);
94
125
  const easedProgress = easingFn(progress);
95
- const currentVolume = startVolume + (volumeDelta * easedProgress);
126
+ const currentVolume = startVolume + volumeDelta * easedProgress;
96
127
  const clampedVolume = Math.max(0, Math.min(1, currentVolume));
97
128
  // Apply volume to both channel config and current audio
98
129
  channel.volume = clampedVolume;
@@ -102,6 +133,7 @@ const transitionVolume = (channelNumber_1, targetVolume_1, ...args_1) => __await
102
133
  if (progress >= 1) {
103
134
  // Transition complete
104
135
  activeTransitions.delete(channelNumber);
136
+ timerTypes.delete(channelNumber);
105
137
  resolve();
106
138
  }
107
139
  else {
@@ -109,11 +141,13 @@ const transitionVolume = (channelNumber_1, targetVolume_1, ...args_1) => __await
109
141
  if (typeof requestAnimationFrame !== 'undefined') {
110
142
  const rafId = requestAnimationFrame(updateVolume);
111
143
  activeTransitions.set(channelNumber, rafId);
144
+ timerTypes.set(channelNumber, types_1.TimerType.RequestAnimationFrame);
112
145
  }
113
146
  else {
114
147
  // In test environment, use shorter intervals
115
148
  const timeoutId = setTimeout(updateVolume, 1);
116
149
  activeTransitions.set(channelNumber, timeoutId);
150
+ timerTypes.set(channelNumber, types_1.TimerType.Timeout);
117
151
  }
118
152
  }
119
153
  };
@@ -127,6 +161,7 @@ exports.transitionVolume = transitionVolume;
127
161
  * @param volume - Volume level (0-1)
128
162
  * @param transitionDuration - Optional transition duration in milliseconds
129
163
  * @param easing - Optional easing function
164
+ * @throws Error if the channel number exceeds the maximum allowed channels
130
165
  * @example
131
166
  * ```typescript
132
167
  * setChannelVolume(0, 0.5); // Set channel 0 to 50%
@@ -135,6 +170,13 @@ exports.transitionVolume = transitionVolume;
135
170
  */
136
171
  const setChannelVolume = (channelNumber, volume, transitionDuration, easing) => __awaiter(void 0, void 0, void 0, function* () {
137
172
  const clampedVolume = Math.max(0, Math.min(1, volume));
173
+ // Check channel number limits
174
+ if (channelNumber < 0) {
175
+ throw new Error('Channel number must be non-negative');
176
+ }
177
+ if (channelNumber >= types_1.MAX_CHANNELS) {
178
+ throw new Error(`Channel number ${channelNumber} exceeds maximum allowed channels (${types_1.MAX_CHANNELS})`);
179
+ }
138
180
  if (!info_1.audioChannels[channelNumber]) {
139
181
  info_1.audioChannels[channelNumber] = {
140
182
  audioCompleteCallbacks: new Set(),
@@ -177,8 +219,9 @@ exports.setChannelVolume = setChannelVolume;
177
219
  * ```
178
220
  */
179
221
  const getChannelVolume = (channelNumber = 0) => {
222
+ var _a;
180
223
  const channel = info_1.audioChannels[channelNumber];
181
- return (channel === null || channel === void 0 ? void 0 : channel.volume) || 1.0;
224
+ return (_a = channel === null || channel === void 0 ? void 0 : channel.volume) !== null && _a !== void 0 ? _a : 1.0;
182
225
  };
183
226
  exports.getChannelVolume = getChannelVolume;
184
227
  /**
@@ -193,7 +236,7 @@ exports.getChannelVolume = getChannelVolume;
193
236
  * ```
194
237
  */
195
238
  const getAllChannelsVolume = () => {
196
- return info_1.audioChannels.map((channel) => (channel === null || channel === void 0 ? void 0 : channel.volume) || 1.0);
239
+ return info_1.audioChannels.map((channel) => { var _a; return (_a = channel === null || channel === void 0 ? void 0 : channel.volume) !== null && _a !== void 0 ? _a : 1.0; });
197
240
  };
198
241
  exports.getAllChannelsVolume = getAllChannelsVolume;
199
242
  /**
@@ -216,6 +259,7 @@ exports.setAllChannelsVolume = setAllChannelsVolume;
216
259
  * Configures volume ducking for channels. When the priority channel plays audio,
217
260
  * all other channels will be automatically reduced to the ducking volume level
218
261
  * @param config - Volume ducking configuration
262
+ * @throws Error if the priority channel number exceeds the maximum allowed channels
219
263
  * @example
220
264
  * ```typescript
221
265
  * // When channel 1 plays, reduce all other channels to 20% volume
@@ -227,8 +271,18 @@ exports.setAllChannelsVolume = setAllChannelsVolume;
227
271
  * ```
228
272
  */
229
273
  const setVolumeDucking = (config) => {
230
- // First, ensure we have enough channels for the priority channel
231
- while (info_1.audioChannels.length <= config.priorityChannel) {
274
+ const { priorityChannel } = config;
275
+ // Check priority channel limits
276
+ if (priorityChannel < 0) {
277
+ throw new Error('Priority channel number must be non-negative');
278
+ }
279
+ if (priorityChannel >= types_1.MAX_CHANNELS) {
280
+ throw new Error(`Priority channel ${priorityChannel} exceeds maximum allowed channels (${types_1.MAX_CHANNELS})`);
281
+ }
282
+ // Store the configuration globally
283
+ globalVolumeConfig = config;
284
+ // Ensure we have enough channels for the priority channel
285
+ while (info_1.audioChannels.length <= priorityChannel) {
232
286
  info_1.audioChannels.push({
233
287
  audioCompleteCallbacks: new Set(),
234
288
  audioErrorCallbacks: new Set(),
@@ -242,24 +296,6 @@ const setVolumeDucking = (config) => {
242
296
  volume: 1.0
243
297
  });
244
298
  }
245
- // Apply the config to all existing channels
246
- info_1.audioChannels.forEach((channel, index) => {
247
- if (!info_1.audioChannels[index]) {
248
- info_1.audioChannels[index] = {
249
- audioCompleteCallbacks: new Set(),
250
- audioErrorCallbacks: new Set(),
251
- audioPauseCallbacks: new Set(),
252
- audioResumeCallbacks: new Set(),
253
- audioStartCallbacks: new Set(),
254
- isPaused: false,
255
- progressCallbacks: new Map(),
256
- queue: [],
257
- queueChangeCallbacks: new Set(),
258
- volume: 1.0
259
- };
260
- }
261
- info_1.audioChannels[index].volumeConfig = config;
262
- });
263
299
  };
264
300
  exports.setVolumeDucking = setVolumeDucking;
265
301
  /**
@@ -270,11 +306,7 @@ exports.setVolumeDucking = setVolumeDucking;
270
306
  * ```
271
307
  */
272
308
  const clearVolumeDucking = () => {
273
- info_1.audioChannels.forEach((channel) => {
274
- if (channel) {
275
- delete channel.volumeConfig;
276
- }
277
- });
309
+ globalVolumeConfig = null;
278
310
  };
279
311
  exports.clearVolumeDucking = clearVolumeDucking;
280
312
  /**
@@ -283,21 +315,31 @@ exports.clearVolumeDucking = clearVolumeDucking;
283
315
  * @internal
284
316
  */
285
317
  const applyVolumeDucking = (activeChannelNumber) => __awaiter(void 0, void 0, void 0, function* () {
318
+ var _a, _b;
319
+ // Check if ducking is configured and this channel is the priority channel
320
+ if (!globalVolumeConfig || globalVolumeConfig.priorityChannel !== activeChannelNumber) {
321
+ return; // No ducking configured for this channel
322
+ }
323
+ const config = globalVolumeConfig;
286
324
  const transitionPromises = [];
325
+ const duration = (_a = config.duckTransitionDuration) !== null && _a !== void 0 ? _a : 250;
326
+ const easing = (_b = config.transitionEasing) !== null && _b !== void 0 ? _b : types_1.EasingType.EaseOut;
327
+ // Duck all channels except the priority channel
287
328
  info_1.audioChannels.forEach((channel, channelNumber) => {
288
- if (channel === null || channel === void 0 ? void 0 : channel.volumeConfig) {
289
- const config = channel.volumeConfig;
290
- if (activeChannelNumber === config.priorityChannel) {
291
- const duration = config.duckTransitionDuration || 250;
292
- const easing = config.transitionEasing || types_1.EasingType.EaseOut;
293
- // Priority channel is active, duck other channels
294
- if (channelNumber === config.priorityChannel) {
295
- transitionPromises.push((0, exports.transitionVolume)(channelNumber, config.priorityVolume, duration, easing));
296
- }
297
- else {
298
- transitionPromises.push((0, exports.transitionVolume)(channelNumber, config.duckingVolume, duration, easing));
299
- }
300
- }
329
+ if (!channel || channel.queue.length === 0) {
330
+ return; // Skip channels without audio
331
+ }
332
+ if (channelNumber === activeChannelNumber) {
333
+ // This is the priority channel - set to priority volume
334
+ // Only change audio volume, preserve channel.volume as desired volume
335
+ const currentAudio = channel.queue[0];
336
+ transitionPromises.push(transitionAudioVolume(currentAudio, config.priorityVolume, duration, easing));
337
+ }
338
+ else {
339
+ // This is a background channel - duck it
340
+ // Only change audio volume, preserve channel.volume as desired volume
341
+ const currentAudio = channel.queue[0];
342
+ transitionPromises.push(transitionAudioVolume(currentAudio, config.duckingVolume, duration, easing));
301
343
  }
302
344
  });
303
345
  // Wait for all transitions to complete
@@ -322,24 +364,123 @@ const fadeVolume = (channelNumber_1, targetVolume_1, ...args_1) => __awaiter(voi
322
364
  });
323
365
  exports.fadeVolume = fadeVolume;
324
366
  /**
325
- * Restores normal volume levels when priority channel stops with smooth transitions
367
+ * Restores normal volume levels when priority channel queue becomes empty
326
368
  * @param stoppedChannelNumber - The channel that just stopped playing
327
369
  * @internal
328
370
  */
329
371
  const restoreVolumeLevels = (stoppedChannelNumber) => __awaiter(void 0, void 0, void 0, function* () {
372
+ // Check if ducking is configured and this channel is the priority channel
373
+ if (!globalVolumeConfig || globalVolumeConfig.priorityChannel !== stoppedChannelNumber) {
374
+ return; // No ducking configured for this channel
375
+ }
376
+ // Check if the priority channel queue is now empty
377
+ const priorityChannel = info_1.audioChannels[stoppedChannelNumber];
378
+ if (priorityChannel && priorityChannel.queue.length > 0) {
379
+ return; // Priority channel still has audio queued, don't restore yet
380
+ }
381
+ const config = globalVolumeConfig;
330
382
  const transitionPromises = [];
383
+ // Restore volume for all channels EXCEPT the priority channel
331
384
  info_1.audioChannels.forEach((channel, channelNumber) => {
332
- if (channel === null || channel === void 0 ? void 0 : channel.volumeConfig) {
333
- const config = channel.volumeConfig;
334
- if (stoppedChannelNumber === config.priorityChannel) {
335
- const duration = config.restoreTransitionDuration || 500;
336
- const easing = config.transitionEasing || types_1.EasingType.EaseOut;
337
- // Priority channel stopped, restore normal volumes
338
- transitionPromises.push((0, exports.transitionVolume)(channelNumber, channel.volume || 1.0, duration, easing));
339
- }
385
+ var _a, _b, _c;
386
+ // Skip the priority channel itself and channels without audio
387
+ if (channelNumber === stoppedChannelNumber || !channel || channel.queue.length === 0) {
388
+ return;
340
389
  }
390
+ // Restore this channel to its desired volume
391
+ const duration = (_a = config.restoreTransitionDuration) !== null && _a !== void 0 ? _a : 500;
392
+ const easing = (_b = config.transitionEasing) !== null && _b !== void 0 ? _b : types_1.EasingType.EaseOut;
393
+ const targetVolume = (_c = channel.volume) !== null && _c !== void 0 ? _c : 1.0;
394
+ // Only transition the audio element volume, keep channel.volume as the desired volume
395
+ const currentAudio = channel.queue[0];
396
+ transitionPromises.push(transitionAudioVolume(currentAudio, targetVolume, duration, easing));
341
397
  });
342
398
  // Wait for all transitions to complete
343
399
  yield Promise.all(transitionPromises);
344
400
  });
345
401
  exports.restoreVolumeLevels = restoreVolumeLevels;
402
+ /**
403
+ * Transitions only the audio element volume without affecting channel.volume
404
+ * This is used for ducking/restoration where channel.volume represents desired volume
405
+ * @param audio - The audio element to transition
406
+ * @param targetVolume - Target volume level (0-1)
407
+ * @param duration - Transition duration in milliseconds
408
+ * @param easing - Easing function type
409
+ * @returns Promise that resolves when transition completes
410
+ * @internal
411
+ */
412
+ const transitionAudioVolume = (audio_1, targetVolume_1, ...args_1) => __awaiter(void 0, [audio_1, targetVolume_1, ...args_1], void 0, function* (audio, targetVolume, duration = 250, easing = types_1.EasingType.EaseOut) {
413
+ const startVolume = audio.volume;
414
+ const volumeDelta = targetVolume - startVolume;
415
+ // If no change needed, resolve immediately
416
+ if (Math.abs(volumeDelta) < 0.001) {
417
+ return Promise.resolve();
418
+ }
419
+ // Handle zero or negative duration - instant change
420
+ if (duration <= 0) {
421
+ audio.volume = Math.max(0, Math.min(1, targetVolume));
422
+ return Promise.resolve();
423
+ }
424
+ const startTime = performance.now();
425
+ const easingFn = easingFunctions[easing];
426
+ return new Promise((resolve) => {
427
+ const updateVolume = () => {
428
+ const elapsed = performance.now() - startTime;
429
+ const progress = Math.min(elapsed / duration, 1);
430
+ const easedProgress = easingFn(progress);
431
+ const currentVolume = startVolume + volumeDelta * easedProgress;
432
+ const clampedVolume = Math.max(0, Math.min(1, currentVolume));
433
+ // Only apply volume to audio element, not channel.volume
434
+ audio.volume = clampedVolume;
435
+ if (progress >= 1) {
436
+ resolve();
437
+ }
438
+ else {
439
+ // Use requestAnimationFrame in browser, setTimeout in tests
440
+ if (typeof requestAnimationFrame !== 'undefined') {
441
+ requestAnimationFrame(updateVolume);
442
+ }
443
+ else {
444
+ setTimeout(updateVolume, 1);
445
+ }
446
+ }
447
+ };
448
+ updateVolume();
449
+ });
450
+ });
451
+ /**
452
+ * Cancels any active volume transition for a specific channel
453
+ * @param channelNumber - The channel number to cancel transitions for
454
+ * @internal
455
+ */
456
+ const cancelVolumeTransition = (channelNumber) => {
457
+ if (activeTransitions.has(channelNumber)) {
458
+ const transitionId = activeTransitions.get(channelNumber);
459
+ const timerType = timerTypes.get(channelNumber);
460
+ if (transitionId) {
461
+ // Cancel based on the timer type that was actually used
462
+ if (timerType === types_1.TimerType.RequestAnimationFrame &&
463
+ typeof cancelAnimationFrame !== 'undefined') {
464
+ cancelAnimationFrame(transitionId);
465
+ }
466
+ else if (timerType === types_1.TimerType.Timeout) {
467
+ clearTimeout(transitionId);
468
+ }
469
+ }
470
+ activeTransitions.delete(channelNumber);
471
+ timerTypes.delete(channelNumber);
472
+ }
473
+ };
474
+ exports.cancelVolumeTransition = cancelVolumeTransition;
475
+ /**
476
+ * Cancels all active volume transitions across all channels
477
+ * @internal
478
+ */
479
+ const cancelAllVolumeTransitions = () => {
480
+ // Get all active channel numbers to avoid modifying Map while iterating
481
+ const activeChannels = Array.from(activeTransitions.keys());
482
+ activeChannels.forEach((channelNumber) => {
483
+ (0, exports.cancelVolumeTransition)(channelNumber);
484
+ });
485
+ };
486
+ exports.cancelAllVolumeTransitions = cancelAllVolumeTransitions;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "audio-channel-queue",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "Allows you to queue audio files to different playback channels.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -13,7 +13,20 @@
13
13
  "prepare": "npm run build",
14
14
  "test": "jest",
15
15
  "test:watch": "jest --watch",
16
- "test:coverage": "jest --coverage"
16
+ "test:coverage": "jest --coverage",
17
+ "lint": "npx eslint src/**/*.ts __tests__/**/*.ts",
18
+ "lint:fix": "npx eslint src/**/*.ts __tests__/**/*.ts --fix",
19
+ "format": "npx prettier --write src/**/*.ts __tests__/**/*.ts",
20
+ "format:check": "npx prettier --check src/**/*.ts __tests__/**/*.ts",
21
+ "clean": "rimraf dist",
22
+ "prebuild": "npm run clean",
23
+ "prepack": "npm run validate && npm run build",
24
+ "validate": "npm run format:check && npm run lint && npm run test",
25
+ "publish:patch": "npm version patch && npm run publish:release",
26
+ "publish:minor": "npm version minor && npm run publish:release",
27
+ "publish:major": "npm version major && npm run publish:release",
28
+ "publish:release": "npm run validate && npm run build && npm publish",
29
+ "publish:dry": "npm run validate && npm run build && npm publish --dry-run"
17
30
  },
18
31
  "repository": {
19
32
  "type": "git",
@@ -38,7 +51,9 @@
38
51
  "@types/jest": "^29.5.13",
39
52
  "jest": "^29.7.0",
40
53
  "jest-environment-jsdom": "^29.7.0",
54
+ "rimraf": "^6.0.1",
41
55
  "ts-jest": "^29.2.5",
42
- "typescript": "^5.6.2"
56
+ "typescript": "^5.6.2",
57
+ "typescript-eslint": "^8.34.1"
43
58
  }
44
59
  }