@kattowo/distube 6.0.2 → 6.2.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/index.js CHANGED
@@ -34,6 +34,11 @@ __export(index_exports, {
34
34
  AUDIO_CHANNELS: () => AUDIO_CHANNELS,
35
35
  AUDIO_SAMPLE_RATE: () => AUDIO_SAMPLE_RATE,
36
36
  BaseManager: () => BaseManager,
37
+ CROSSFADE_DRIFT_TOLERANCE_SECS: () => CROSSFADE_DRIFT_TOLERANCE_SECS,
38
+ DEFAULT_CROSSFADE_CURVE: () => DEFAULT_CROSSFADE_CURVE,
39
+ DEFAULT_CROSSFADE_DURATION_SECS: () => DEFAULT_CROSSFADE_DURATION_SECS,
40
+ DEFAULT_CROSSFADE_WARMUP_SECS: () => DEFAULT_CROSSFADE_WARMUP_SECS,
41
+ DEFAULT_PRELOAD_THRESHOLD_SECS: () => DEFAULT_PRELOAD_THRESHOLD_SECS,
37
42
  DEFAULT_VOLUME: () => DEFAULT_VOLUME,
38
43
  DisTube: () => DisTube,
39
44
  DisTubeBase: () => DisTubeBase,
@@ -49,6 +54,7 @@ __export(index_exports, {
49
54
  HTTP_REDIRECT_CODES: () => HTTP_REDIRECT_CODES,
50
55
  InfoExtractorPlugin: () => InfoExtractorPlugin,
51
56
  JOIN_TIMEOUT_MS: () => JOIN_TIMEOUT_MS,
57
+ MAX_CROSSFADE_DURATION_SECS: () => MAX_CROSSFADE_DURATION_SECS,
52
58
  MAX_REDIRECT_DEPTH: () => MAX_REDIRECT_DEPTH,
53
59
  Options: () => Options,
54
60
  PlayableExtractorPlugin: () => PlayableExtractorPlugin,
@@ -96,7 +102,7 @@ __export(index_exports, {
96
102
  module.exports = __toCommonJS(index_exports);
97
103
 
98
104
  // src/constant.ts
99
- var version = "6.0.2";
105
+ var version = "6.2.0";
100
106
  var AUDIO_SAMPLE_RATE = 48e3;
101
107
  var AUDIO_CHANNELS = 2;
102
108
  var DEFAULT_VOLUME = 50;
@@ -123,6 +129,12 @@ var defaultFilters = {
123
129
  surround: "surround",
124
130
  earwax: "earwax"
125
131
  };
132
+ var DEFAULT_PRELOAD_THRESHOLD_SECS = 15;
133
+ var DEFAULT_CROSSFADE_DURATION_SECS = 0;
134
+ var MAX_CROSSFADE_DURATION_SECS = 12;
135
+ var DEFAULT_CROSSFADE_CURVE = "qsin";
136
+ var DEFAULT_CROSSFADE_WARMUP_SECS = 5;
137
+ var CROSSFADE_DRIFT_TOLERANCE_SECS = 0.75;
126
138
  var defaultOptions = {
127
139
  plugins: [],
128
140
  emitNewSongOnly: false,
@@ -132,7 +144,13 @@ var defaultOptions = {
132
144
  emitAddListWhenCreatingQueue: true,
133
145
  joinNewVoiceChannel: true,
134
146
  unhandledError: "log",
135
- stuckThreshold: STUCK_THRESHOLD_MS
147
+ stuckThreshold: STUCK_THRESHOLD_MS,
148
+ preload: false,
149
+ crossfade: {
150
+ duration: DEFAULT_CROSSFADE_DURATION_SECS,
151
+ curve: DEFAULT_CROSSFADE_CURVE,
152
+ warmup: DEFAULT_CROSSFADE_WARMUP_SECS
153
+ }
136
154
  };
137
155
 
138
156
  // src/core/DisTubeBase.ts
@@ -702,7 +720,14 @@ var DisTubeVoice = class extends import_tiny_typed_emitter.TypedEmitter {
702
720
  if (newState.reason === import_voice.VoiceConnectionDisconnectReason.Manual) {
703
721
  this.leave();
704
722
  } else if (newState.reason === import_voice.VoiceConnectionDisconnectReason.WebSocketClose && newState.closeCode === 4014) {
705
- (0, import_voice.entersState)(this.connection, import_voice.VoiceConnectionStatus.Connecting, RECONNECT_TIMEOUT_MS).catch(() => {
723
+ (0, import_voice.entersState)(this.connection, import_voice.VoiceConnectionStatus.Connecting, RECONNECT_TIMEOUT_MS).then(() => (0, import_voice.entersState)(this.connection, import_voice.VoiceConnectionStatus.Ready, RECONNECT_TIMEOUT_MS)).then(() => {
724
+ const oldChannel = this.#channel;
725
+ const currentChannel = this.channel;
726
+ if (oldChannel && currentChannel && oldChannel.id !== currentChannel.id) {
727
+ this.#channel = currentChannel;
728
+ this.emit("channelChange", oldChannel, currentChannel);
729
+ }
730
+ }).catch(() => {
706
731
  if (![import_voice.VoiceConnectionStatus.Ready, import_voice.VoiceConnectionStatus.Connecting].includes(this.connection.state.status)) {
707
732
  this.leave();
708
733
  }
@@ -763,8 +788,12 @@ var DisTubeVoice = class extends import_tiny_typed_emitter.TypedEmitter {
763
788
  if (channel.full) throw new DisTubeError("VOICE_FULL");
764
789
  else throw new DisTubeError("VOICE_MISSING_PERMS");
765
790
  }
791
+ const oldChannel = this.#channel;
766
792
  this.connection = this.#join(channel);
767
793
  this.#channel = channel;
794
+ if (oldChannel && oldChannel.id !== channel.id) {
795
+ this.emit("channelChange", oldChannel, channel);
796
+ }
768
797
  }
769
798
  #join(channel) {
770
799
  return (0, import_voice.joinVoiceChannel)({
@@ -844,7 +873,7 @@ var DisTubeVoice = class extends import_tiny_typed_emitter.TypedEmitter {
844
873
  if (this.audioPlayer.state.status !== import_voice.AudioPlayerStatus.Paused) {
845
874
  this.audioPlayer.play(dtStream.audioResource);
846
875
  this.stream?.kill();
847
- dtStream.spawn();
876
+ if (!dtStream.process) dtStream.spawn();
848
877
  } else if (!this.pausingStream) {
849
878
  this.pausingStream = this.stream;
850
879
  }
@@ -1052,7 +1081,13 @@ var FilterManager = class extends BaseManager {
1052
1081
  }
1053
1082
  throw new DisTubeError("INVALID_TYPE", "FilterResolvable", filter, "filter");
1054
1083
  }
1055
- #apply() {
1084
+ #apply(oldFilters) {
1085
+ this.queue._clearPreload?.();
1086
+ const newFilters = this.names;
1087
+ const changed = oldFilters.length !== newFilters.length || !oldFilters.every((f) => newFilters.includes(f));
1088
+ if (changed) {
1089
+ this.emit("changeFilter" /* CHANGE_FILTER */, this.queue, newFilters, oldFilters);
1090
+ }
1056
1091
  void this.queue._restartStream().catch((e) => {
1057
1092
  const errorMessage = e instanceof Error ? e.message : String(e);
1058
1093
  this.queue.debug(`[${this.queue.id}] Failed to apply the filters: ${errorMessage}`);
@@ -1070,6 +1105,7 @@ var FilterManager = class extends BaseManager {
1070
1105
  * ```
1071
1106
  */
1072
1107
  add(filterOrFilters, override = false) {
1108
+ const oldFilters = this.names;
1073
1109
  if (Array.isArray(filterOrFilters)) {
1074
1110
  for (const filter of filterOrFilters) {
1075
1111
  const ft = this.#resolve(filter);
@@ -1079,7 +1115,7 @@ var FilterManager = class extends BaseManager {
1079
1115
  const ft = this.#resolve(filterOrFilters);
1080
1116
  if (override || !this.has(ft)) this.collection.set(ft.name, ft);
1081
1117
  }
1082
- this.#apply();
1118
+ this.#apply(oldFilters);
1083
1119
  return this;
1084
1120
  }
1085
1121
  /**
@@ -1098,12 +1134,13 @@ var FilterManager = class extends BaseManager {
1098
1134
  */
1099
1135
  set(filters) {
1100
1136
  if (!Array.isArray(filters)) throw new DisTubeError("INVALID_TYPE", "Array<FilterResolvable>", filters, "filters");
1137
+ const oldFilters = this.names;
1101
1138
  this.collection.clear();
1102
1139
  for (const f of filters) {
1103
1140
  const filter = this.#resolve(f);
1104
1141
  this.collection.set(filter.name, filter);
1105
1142
  }
1106
- this.#apply();
1143
+ this.#apply(oldFilters);
1107
1144
  return this;
1108
1145
  }
1109
1146
  #removeFn(f) {
@@ -1116,9 +1153,10 @@ var FilterManager = class extends BaseManager {
1116
1153
  * @throws {@link DisTubeError} When a filter is not a valid {@link FilterResolvable}
1117
1154
  */
1118
1155
  remove(filterOrFilters) {
1156
+ const oldFilters = this.names;
1119
1157
  if (Array.isArray(filterOrFilters)) filterOrFilters.forEach((f) => this.#removeFn(f));
1120
1158
  else this.#removeFn(filterOrFilters);
1121
- this.#apply();
1159
+ this.#apply(oldFilters);
1122
1160
  return this;
1123
1161
  }
1124
1162
  /**
@@ -1174,6 +1212,17 @@ var Events = /* @__PURE__ */ ((Events2) => {
1174
1212
  Events2["DELETE_QUEUE"] = "deleteQueue";
1175
1213
  Events2["FFMPEG_DEBUG"] = "ffmpegDebug";
1176
1214
  Events2["DEBUG"] = "debug";
1215
+ Events2["PAUSE_SONG"] = "pauseSong";
1216
+ Events2["RESUME_SONG"] = "resumeSong";
1217
+ Events2["SEEK_SONG"] = "seekSong";
1218
+ Events2["SET_VOLUME"] = "setVolume";
1219
+ Events2["SHUFFLE_QUEUE"] = "shuffleQueue";
1220
+ Events2["SKIP_SONG"] = "skipSong";
1221
+ Events2["CHANGE_FILTER"] = "changeFilter";
1222
+ Events2["VOICE_CHANNEL_CHANGE"] = "voiceChannelChange";
1223
+ Events2["PRELOAD_SONG"] = "preloadSong";
1224
+ Events2["PRELOADED_SONG"] = "preloadedSong";
1225
+ Events2["PRELOAD_ERROR"] = "preloadError";
1177
1226
  return Events2;
1178
1227
  })(Events || {});
1179
1228
  var SearchResultType = /* @__PURE__ */ ((SearchResultType2) => {
@@ -1307,6 +1356,7 @@ var Queue = class extends DisTubeBase {
1307
1356
  */
1308
1357
  _beginTime;
1309
1358
  #filters;
1359
+ #initialized = false;
1310
1360
  /**
1311
1361
  * Whether or not the queue is being updated manually (skip, jump, previous)
1312
1362
  * @internal
@@ -1322,6 +1372,75 @@ var Queue = class extends DisTubeBase {
1322
1372
  * @internal
1323
1373
  */
1324
1374
  _listeners;
1375
+ /**
1376
+ * Preload timer
1377
+ * @internal
1378
+ */
1379
+ _preloadTimeout;
1380
+ /**
1381
+ * Preloaded song
1382
+ * @internal
1383
+ */
1384
+ _preloadedSong;
1385
+ /**
1386
+ * Preloaded stream
1387
+ * @internal
1388
+ */
1389
+ _preloadedStream;
1390
+ /**
1391
+ * In-flight preload task
1392
+ * @internal
1393
+ */
1394
+ _preloading;
1395
+ /**
1396
+ * Bumped whenever the preloaded state is invalidated, so an in-flight
1397
+ * preload task can detect that its result is no longer needed.
1398
+ * @internal
1399
+ */
1400
+ _preloadGeneration = 0;
1401
+ #crossfade;
1402
+ /**
1403
+ * Crossfade boundary timer
1404
+ * @internal
1405
+ */
1406
+ _crossfadeTimeout;
1407
+ /**
1408
+ * The merged transition stream that is warming up before the fade window
1409
+ * starts
1410
+ * @internal
1411
+ */
1412
+ _crossfadeWarmup;
1413
+ /**
1414
+ * Clear any preloaded song and cancel pending preload timer.
1415
+ * @internal
1416
+ */
1417
+ _clearPreload() {
1418
+ this._crossfadeCancel();
1419
+ this._preloadGeneration++;
1420
+ if (this._preloadTimeout) {
1421
+ clearTimeout(this._preloadTimeout);
1422
+ this._preloadTimeout = void 0;
1423
+ }
1424
+ if (this._preloadedStream) {
1425
+ this._preloadedStream.kill();
1426
+ this._preloadedStream = void 0;
1427
+ }
1428
+ this._preloadedSong = void 0;
1429
+ }
1430
+ /**
1431
+ * Cancel a pending crossfade and kill its warming-up transition stream.
1432
+ * @internal
1433
+ */
1434
+ _crossfadeCancel() {
1435
+ if (this._crossfadeTimeout) {
1436
+ clearTimeout(this._crossfadeTimeout);
1437
+ this._crossfadeTimeout = void 0;
1438
+ }
1439
+ if (this._crossfadeWarmup) {
1440
+ this._crossfadeWarmup.kill();
1441
+ this._crossfadeWarmup = void 0;
1442
+ }
1443
+ }
1325
1444
  /**
1326
1445
  * Create a queue for the guild
1327
1446
  * @param distube - DisTube
@@ -1350,6 +1469,8 @@ var Queue = class extends DisTubeBase {
1350
1469
  input: { ...this.options.ffmpeg.args.input },
1351
1470
  output: { ...this.options.ffmpeg.args.output }
1352
1471
  };
1472
+ this.#crossfade = Number(this.options.crossfade?.duration) || 0;
1473
+ this.#initialized = true;
1353
1474
  }
1354
1475
  #addToPreviousSongs(songs) {
1355
1476
  if (Array.isArray(songs)) {
@@ -1407,6 +1528,28 @@ var Queue = class extends DisTubeBase {
1407
1528
  get currentTime() {
1408
1529
  return this.voice.playbackTime;
1409
1530
  }
1531
+ /**
1532
+ * The crossfade duration (in seconds) between this song and the next one.
1533
+ *
1534
+ * `0` (the default) disables the crossfade. Changing it takes effect for the
1535
+ * next transition; call {@link Queue#scheduleCrossfade} to re-arm the
1536
+ * transition for the currently playing song immediately.
1537
+ * @throws {@link DisTubeError} When the value is not a number between `0`
1538
+ * and the maximum crossfade duration
1539
+ */
1540
+ get crossfade() {
1541
+ return this.#crossfade;
1542
+ }
1543
+ set crossfade(value) {
1544
+ if (typeof value !== "number" || Number.isNaN(value)) {
1545
+ throw new DisTubeError("INVALID_TYPE", "number", value, "Queue.crossfade");
1546
+ }
1547
+ if (value < 0) throw new DisTubeError("NUMBER_COMPARE", "Crossfade", "bigger or equal to", 0);
1548
+ if (value > MAX_CROSSFADE_DURATION_SECS) {
1549
+ throw new DisTubeError("NUMBER_COMPARE", "Crossfade", "smaller or equal to", MAX_CROSSFADE_DURATION_SECS);
1550
+ }
1551
+ this.#crossfade = value;
1552
+ }
1410
1553
  /**
1411
1554
  * Formatted {@link Queue#currentTime} string.
1412
1555
  */
@@ -1432,7 +1575,11 @@ var Queue = class extends DisTubeBase {
1432
1575
  * negative
1433
1576
  */
1434
1577
  set volume(value) {
1578
+ const oldVolume = this.voice.volume;
1435
1579
  this.voice.volume = value;
1580
+ if (this.#initialized && oldVolume !== value) {
1581
+ this.emit("setVolume" /* SET_VOLUME */, this, value, oldVolume);
1582
+ }
1436
1583
  }
1437
1584
  /**
1438
1585
  * Add a song or a list of songs to the queue.
@@ -1461,6 +1608,7 @@ var Queue = class extends DisTubeBase {
1461
1608
  } else {
1462
1609
  this.songs.splice(position, 0, song);
1463
1610
  }
1611
+ this.queues?.schedulePreload?.(this);
1464
1612
  return this;
1465
1613
  }
1466
1614
  /**
@@ -1478,7 +1626,9 @@ var Queue = class extends DisTubeBase {
1478
1626
  try {
1479
1627
  if (this.paused) throw new DisTubeError("PAUSED");
1480
1628
  this.paused = true;
1629
+ this._clearPreload();
1481
1630
  this.voice.pause();
1631
+ this.emit("pauseSong" /* PAUSE_SONG */, this, this.songs[0]);
1482
1632
  return this;
1483
1633
  } finally {
1484
1634
  this._taskQueue.resolve();
@@ -1499,6 +1649,9 @@ var Queue = class extends DisTubeBase {
1499
1649
  if (!this.paused) throw new DisTubeError("RESUMED");
1500
1650
  this.paused = false;
1501
1651
  this.voice.unpause();
1652
+ this.emit("resumeSong" /* RESUME_SONG */, this, this.songs[0]);
1653
+ this.queues?.schedulePreload?.(this);
1654
+ this.queues?.scheduleCrossfade?.(this);
1502
1655
  return this;
1503
1656
  } finally {
1504
1657
  this._taskQueue.resolve();
@@ -1563,11 +1716,14 @@ var Queue = class extends DisTubeBase {
1563
1716
  try {
1564
1717
  const playing = this.songs.shift();
1565
1718
  if (playing === void 0) return this;
1719
+ this._clearPreload();
1566
1720
  for (let i = this.songs.length - 1; i > 0; i--) {
1567
1721
  const j = Math.floor(Math.random() * (i + 1));
1568
1722
  [this.songs[i], this.songs[j]] = [this.songs[j], this.songs[i]];
1569
1723
  }
1570
1724
  this.songs.unshift(playing);
1725
+ this.emit("shuffleQueue" /* SHUFFLE_QUEUE */, this, this.songs);
1726
+ this.queues?.schedulePreload?.(this);
1571
1727
  return this;
1572
1728
  } finally {
1573
1729
  this._taskQueue.resolve();
@@ -1605,12 +1761,14 @@ var Queue = class extends DisTubeBase {
1605
1761
  } else {
1606
1762
  this.#addToPreviousSongs(skipped);
1607
1763
  }
1764
+ this.emit("skipSong" /* SKIP_SONG */, this, skipped[0]);
1608
1765
  } else if (!this.options.savePreviousSongs) {
1609
1766
  throw new DisTubeError("DISABLED_OPTION", "savePreviousSongs");
1610
1767
  } else {
1611
1768
  const skipped = this.previousSongs.splice(position);
1612
1769
  this.songs.unshift(...skipped);
1613
1770
  }
1771
+ this._clearPreload();
1614
1772
  this.#stop();
1615
1773
  return this.songs[0];
1616
1774
  } finally {
@@ -1649,8 +1807,10 @@ var Queue = class extends DisTubeBase {
1649
1807
  try {
1650
1808
  if (typeof time !== "number") throw new DisTubeError("INVALID_TYPE", "number", time, "time");
1651
1809
  if (Number.isNaN(time) || time < 0) throw new DisTubeError("NUMBER_COMPARE", "time", "bigger or equal to", 0);
1810
+ this._clearPreload();
1652
1811
  this._beginTime = time;
1653
1812
  await this.play(false);
1813
+ this.emit("seekSong" /* SEEK_SONG */, this, this.songs[0], time);
1654
1814
  return this;
1655
1815
  } finally {
1656
1816
  this._taskQueue.resolve();
@@ -1721,6 +1881,7 @@ var Queue = class extends DisTubeBase {
1721
1881
  * @returns Nothing
1722
1882
  */
1723
1883
  remove() {
1884
+ this._clearPreload();
1724
1885
  this.paused = true;
1725
1886
  this.stopped = true;
1726
1887
  this.songs = [];
@@ -1747,6 +1908,17 @@ var Queue = class extends DisTubeBase {
1747
1908
  if (this.stopped) throw new DisTubeError("QUEUE_STOPPED");
1748
1909
  return this.queues.playSong(this, emitPlaySong);
1749
1910
  }
1911
+ /**
1912
+ * Re-arm the crossfade into the next song for the currently playing one.
1913
+ *
1914
+ * Useful after changing {@link Queue#crossfade} while a song is playing: the
1915
+ * fade may still fit into the remaining time. This is called automatically
1916
+ * after every song switch.
1917
+ * @returns Nothing
1918
+ */
1919
+ scheduleCrossfade() {
1920
+ this.queues.scheduleCrossfade(this);
1921
+ }
1750
1922
  /**
1751
1923
  * Restart the current stream with the current FFmpeg arguments, keeping the
1752
1924
  * playback position.
@@ -2394,6 +2566,16 @@ var Options = class {
2394
2566
  * @default 10000
2395
2567
  */
2396
2568
  stuckThreshold;
2569
+ /**
2570
+ * The preload options of the DisTube instance.
2571
+ * @default { enabled: false, threshold: 15 }
2572
+ */
2573
+ preload;
2574
+ /**
2575
+ * The crossfade options of the DisTube instance.
2576
+ * @default { duration: 0, curve: "qsin", warmup: 5 }
2577
+ */
2578
+ crossfade;
2397
2579
  /**
2398
2580
  * The FFmpeg options of the DisTube instance.
2399
2581
  */
@@ -2419,10 +2601,76 @@ var Options = class {
2419
2601
  this.joinNewVoiceChannel = opts.joinNewVoiceChannel;
2420
2602
  this.unhandledError = opts.unhandledError;
2421
2603
  this.stuckThreshold = opts.stuckThreshold;
2604
+ this.preload = this.#preloadOption(opts.preload);
2605
+ this.crossfade = this.#crossfadeOption(opts.crossfade);
2422
2606
  this.ffmpeg = this.#ffmpegOption(options);
2423
2607
  checkInvalidKey(opts, this, "DisTubeOptions");
2424
2608
  this.#validateOptions();
2425
2609
  }
2610
+ #preloadOption(value) {
2611
+ if (value === void 0 || value === false) {
2612
+ return { enabled: false, threshold: DEFAULT_PRELOAD_THRESHOLD_SECS };
2613
+ }
2614
+ if (value === true) {
2615
+ return { enabled: true, threshold: DEFAULT_PRELOAD_THRESHOLD_SECS };
2616
+ }
2617
+ if (typeof value !== "object" || Array.isArray(value)) {
2618
+ throw new DisTubeError("INVALID_TYPE", ["boolean", "object"], value, "DisTubeOptions.preload");
2619
+ }
2620
+ checkInvalidKey(value, ["enabled", "threshold"], "DisTubeOptions.preload");
2621
+ const { enabled, threshold } = value;
2622
+ if (enabled !== void 0 && typeof enabled !== "boolean") {
2623
+ throw new DisTubeError("INVALID_TYPE", "boolean", enabled, "DisTubeOptions.preload.enabled");
2624
+ }
2625
+ if (threshold !== void 0 && (typeof threshold !== "number" || Number.isNaN(threshold) || threshold < 0)) {
2626
+ throw new DisTubeError("INVALID_TYPE", "number", threshold, "DisTubeOptions.preload.threshold");
2627
+ }
2628
+ return {
2629
+ enabled: enabled ?? false,
2630
+ threshold: threshold ?? DEFAULT_PRELOAD_THRESHOLD_SECS
2631
+ };
2632
+ }
2633
+ #crossfadeOption(value) {
2634
+ if (value === void 0) {
2635
+ return {
2636
+ duration: DEFAULT_CROSSFADE_DURATION_SECS,
2637
+ curve: DEFAULT_CROSSFADE_CURVE,
2638
+ warmup: DEFAULT_CROSSFADE_WARMUP_SECS
2639
+ };
2640
+ }
2641
+ if (typeof value !== "object" || Array.isArray(value)) {
2642
+ throw new DisTubeError("INVALID_TYPE", "object", value, "DisTubeOptions.crossfade");
2643
+ }
2644
+ checkInvalidKey(value, ["duration", "curve", "warmup"], "DisTubeOptions.crossfade");
2645
+ const { duration, curve, warmup } = value;
2646
+ if (duration !== void 0 && (typeof duration !== "number" || Number.isNaN(duration) || duration < 0)) {
2647
+ throw new DisTubeError(
2648
+ "INVALID_TYPE",
2649
+ `number >= 0 (max ${MAX_CROSSFADE_DURATION_SECS})`,
2650
+ duration,
2651
+ "DisTubeOptions.crossfade.duration"
2652
+ );
2653
+ }
2654
+ if (duration !== void 0 && duration > MAX_CROSSFADE_DURATION_SECS) {
2655
+ throw new DisTubeError(
2656
+ "NUMBER_COMPARE",
2657
+ "Crossfade duration",
2658
+ "smaller or equal to",
2659
+ MAX_CROSSFADE_DURATION_SECS
2660
+ );
2661
+ }
2662
+ if (curve !== void 0 && (typeof curve !== "string" || !curve)) {
2663
+ throw new DisTubeError("INVALID_TYPE", "string", curve, "DisTubeOptions.crossfade.curve");
2664
+ }
2665
+ if (warmup !== void 0 && (typeof warmup !== "number" || Number.isNaN(warmup) || warmup < 0)) {
2666
+ throw new DisTubeError("INVALID_TYPE", "number >= 0", warmup, "DisTubeOptions.crossfade.warmup");
2667
+ }
2668
+ return {
2669
+ duration: duration ?? DEFAULT_CROSSFADE_DURATION_SECS,
2670
+ curve: curve ?? DEFAULT_CROSSFADE_CURVE,
2671
+ warmup: warmup ?? DEFAULT_CROSSFADE_WARMUP_SECS
2672
+ };
2673
+ }
2426
2674
  #validateOptions(options = this) {
2427
2675
  const booleanOptions = /* @__PURE__ */ new Set([
2428
2676
  "emitNewSongOnly",
@@ -2434,7 +2682,7 @@ var Options = class {
2434
2682
  ]);
2435
2683
  const numberOptions = /* @__PURE__ */ new Set(["stuckThreshold"]);
2436
2684
  const stringOptions = /* @__PURE__ */ new Set(["unhandledError"]);
2437
- const objectOptions = /* @__PURE__ */ new Set(["customFilters", "ffmpeg"]);
2685
+ const objectOptions = /* @__PURE__ */ new Set(["customFilters", "ffmpeg", "preload", "crossfade"]);
2438
2686
  const optionalOptions = /* @__PURE__ */ new Set(["customFilters"]);
2439
2687
  for (const [key, value] of Object.entries(options)) {
2440
2688
  if (value === void 0 && optionalOptions.has(key)) continue;
@@ -2552,8 +2800,40 @@ var DisTubeStream = class extends import_tiny_typed_emitter2.TypedEmitter {
2552
2800
  */
2553
2801
  constructor(url, options) {
2554
2802
  super();
2555
- const { ffmpeg, seek } = options;
2803
+ const { ffmpeg, seek, crossfade } = options;
2556
2804
  this.seekTime = typeof seek === "number" && seek > 0 ? seek : 0;
2805
+ this.#ffmpegPath = ffmpeg.path;
2806
+ this.#opts = crossfade ? this.#crossfadeArgs(url, ffmpeg, crossfade) : this.#singleInputArgs(url, ffmpeg, seek);
2807
+ this.stream = new VolumeTransformer();
2808
+ this.stream.on("close", () => this.kill()).on("error", (err) => {
2809
+ this.debug(`[stream] error: ${err.message}`);
2810
+ this.emit("error", err);
2811
+ }).on("finish", () => this.debug("[stream] log: stream finished"));
2812
+ this.audioResource = (0, import_voice2.createAudioResource)(this.stream, {
2813
+ inputType: import_voice2.StreamType.Raw,
2814
+ inlineVolume: false
2815
+ });
2816
+ }
2817
+ #flattenArgs(args) {
2818
+ return Object.entries(args).flatMap(
2819
+ ([key, value]) => Array.isArray(value) ? value.filter(Boolean).map((v) => [`-${key}`, String(v)]) : value == null || value === false ? [] : [value === true ? `-${key}` : [`-${key}`, String(value)]]
2820
+ ).flat();
2821
+ }
2822
+ /**
2823
+ * Translate a `file:` URL into a local filesystem path that FFmpeg accepts.
2824
+ *
2825
+ * `file:///C:/x` loses its drive-letter leading slash on Windows (`C:/x`),
2826
+ * UNC shares keep their `//host` prefix, POSIX paths stay untouched, and
2827
+ * other protocols return `undefined`.
2828
+ */
2829
+ #localPath(url) {
2830
+ const fileUrl = new URL(url);
2831
+ if (fileUrl.protocol !== "file:") return void 0;
2832
+ const pathname = decodeURIComponent(fileUrl.pathname);
2833
+ if (fileUrl.hostname) return `//${fileUrl.hostname}${pathname}`;
2834
+ return /^\/[A-Za-z]:\//.test(pathname) ? pathname.slice(1) : pathname;
2835
+ }
2836
+ #singleInputArgs(url, ffmpeg, seek) {
2557
2837
  const opts = {
2558
2838
  reconnect: 1,
2559
2839
  reconnect_streamed: 1,
@@ -2569,29 +2849,91 @@ var DisTubeStream = class extends import_tiny_typed_emitter2.TypedEmitter {
2569
2849
  f: "s16le"
2570
2850
  };
2571
2851
  if (typeof seek === "number" && seek > 0) opts.ss = seek.toString();
2572
- const fileUrl = new URL(url);
2573
- if (fileUrl.protocol === "file:") {
2852
+ const localPath = this.#localPath(url);
2853
+ if (localPath !== void 0) {
2574
2854
  opts.reconnect = null;
2575
2855
  opts.reconnect_streamed = null;
2576
2856
  opts.reconnect_delay_max = null;
2577
- opts.i = fileUrl.hostname + fileUrl.pathname;
2857
+ opts.i = localPath;
2578
2858
  }
2579
- this.#ffmpegPath = ffmpeg.path;
2580
- this.#opts = [
2581
- ...Object.entries(opts).flatMap(
2582
- ([key, value]) => Array.isArray(value) ? value.filter(Boolean).map((v) => [`-${key}`, String(v)]) : value == null || value === false ? [] : [value === true ? `-${key}` : [`-${key}`, String(value)]]
2583
- ).flat(),
2859
+ return [...this.#flattenArgs(opts), "pipe:1"];
2860
+ }
2861
+ /**
2862
+ * Build the arguments of a crossfade transition stream: a single FFmpeg
2863
+ * process with two inputs (the fading out song seeked to its final
2864
+ * `duration` seconds and the fading in song from its beginning) mixed by an
2865
+ * `acrossfade` filter graph.
2866
+ *
2867
+ * Both inputs are normalized (48kHz stereo s16) before the fade, and the
2868
+ * queue's `af` filter chain, when present, is folded into the filter complex
2869
+ * after the fade (FFmpeg cannot combine `-af` with `-filter_complex`).
2870
+ */
2871
+ #crossfadeArgs(url, ffmpeg, crossfade) {
2872
+ const {
2873
+ duration,
2874
+ primarySeek,
2875
+ secondaryUrl,
2876
+ outCurve = DEFAULT_CROSSFADE_CURVE,
2877
+ inCurve = DEFAULT_CROSSFADE_CURVE
2878
+ } = crossfade;
2879
+ const outputArgs = { ...ffmpeg.args.output };
2880
+ const extraFilters = typeof outputArgs.af === "string" ? outputArgs.af : Array.isArray(outputArgs.af) ? outputArgs.af.filter(Boolean).join(",") : void 0;
2881
+ delete outputArgs.af;
2882
+ const primaryOpts = {
2883
+ reconnect: 1,
2884
+ reconnect_streamed: 1,
2885
+ reconnect_delay_max: 5,
2886
+ analyzeduration: 0,
2887
+ hide_banner: true,
2888
+ ...ffmpeg.args.global,
2889
+ ...ffmpeg.args.input,
2890
+ ss: typeof primarySeek === "number" && primarySeek > 0 ? primarySeek.toString() : null,
2891
+ i: url
2892
+ };
2893
+ const secondaryOpts = {
2894
+ reconnect: 1,
2895
+ reconnect_streamed: 1,
2896
+ reconnect_delay_max: 5,
2897
+ analyzeduration: 0,
2898
+ ...ffmpeg.args.input,
2899
+ i: secondaryUrl
2900
+ };
2901
+ const primaryLocal = this.#localPath(url);
2902
+ if (primaryLocal !== void 0) {
2903
+ primaryOpts.reconnect = null;
2904
+ primaryOpts.reconnect_streamed = null;
2905
+ primaryOpts.reconnect_delay_max = null;
2906
+ primaryOpts.i = primaryLocal;
2907
+ }
2908
+ const secondaryLocal = this.#localPath(secondaryUrl);
2909
+ if (secondaryLocal !== void 0) {
2910
+ secondaryOpts.reconnect = null;
2911
+ secondaryOpts.reconnect_streamed = null;
2912
+ secondaryOpts.reconnect_delay_max = null;
2913
+ secondaryOpts.i = secondaryLocal;
2914
+ }
2915
+ const normalize = `aresample=${AUDIO_SAMPLE_RATE},aformat=sample_fmts=s16:channel_layouts=stereo`;
2916
+ const fade = `[a0][a1]acrossfade=d=${duration}:c1=${outCurve}:c2=${inCurve}[xf]`;
2917
+ const graph = [
2918
+ `[0:a]${normalize}[a0]`,
2919
+ `[1:a]${normalize}[a1]`,
2920
+ fade,
2921
+ ...extraFilters ? [`[xf]${extraFilters}[aout]`] : []
2922
+ ].join(";");
2923
+ const outputOpts = {
2924
+ ar: AUDIO_SAMPLE_RATE,
2925
+ ac: AUDIO_CHANNELS,
2926
+ ...outputArgs,
2927
+ filter_complex: graph,
2928
+ map: extraFilters ? "[aout]" : "[xf]",
2929
+ f: "s16le"
2930
+ };
2931
+ return [
2932
+ ...this.#flattenArgs(primaryOpts),
2933
+ ...this.#flattenArgs(secondaryOpts),
2934
+ ...this.#flattenArgs(outputOpts),
2584
2935
  "pipe:1"
2585
2936
  ];
2586
- this.stream = new VolumeTransformer();
2587
- this.stream.on("close", () => this.kill()).on("error", (err) => {
2588
- this.debug(`[stream] error: ${err.message}`);
2589
- this.emit("error", err);
2590
- }).on("finish", () => this.debug("[stream] log: stream finished"));
2591
- this.audioResource = (0, import_voice2.createAudioResource)(this.stream, {
2592
- inputType: import_voice2.StreamType.Raw,
2593
- inlineVolume: false
2594
- });
2595
2937
  }
2596
2938
  /**
2597
2939
  * Spawn the FFmpeg process that decodes the audio.
@@ -2822,7 +3164,10 @@ var QueueManager = class extends GuildIdManager {
2822
3164
  if (error) this.emitError(error, queue, queue.songs?.[0]);
2823
3165
  }, "disconnect"),
2824
3166
  error: /* @__PURE__ */ __name((error) => void this.#handlePlayingError(queue, error), "error"),
2825
- finish: /* @__PURE__ */ __name(() => void this.handleSongFinish(queue), "finish")
3167
+ finish: /* @__PURE__ */ __name(() => void this.handleSongFinish(queue), "finish"),
3168
+ channelChange: /* @__PURE__ */ __name((oldChannel, newChannel) => {
3169
+ this.emit("voiceChannelChange" /* VOICE_CHANNEL_CHANGE */, queue, oldChannel, newChannel);
3170
+ }, "channelChange")
2826
3171
  };
2827
3172
  for (const event of objectKeys(queue._listeners)) {
2828
3173
  queue.voice.on(event, queue._listeners[event]);
@@ -2920,12 +3265,110 @@ var QueueManager = class extends GuildIdManager {
2920
3265
  }
2921
3266
  try {
2922
3267
  const song = queue.songs[0];
2923
- this.debug(`[${queue.id}] Getting stream from: ${song}`);
2924
- await this.handler.attachStreamInfo(song);
2925
- const willPlaySong = song.stream.playFromSource ? song : song.stream.song;
3268
+ let dtStream;
3269
+ if (queue._preloadedSong === song && queue._preloadedStream) {
3270
+ this.debug(`[${queue.id}] Using preloaded stream for: ${song}`);
3271
+ dtStream = queue._preloadedStream;
3272
+ queue._preloadedSong = void 0;
3273
+ queue._preloadedStream = void 0;
3274
+ } else {
3275
+ queue._clearPreload();
3276
+ this.debug(`[${queue.id}] Getting stream from: ${song}`);
3277
+ await this.handler.attachStreamInfo(song);
3278
+ const willPlaySong = song.stream.playFromSource ? song : song.stream.song;
3279
+ const stream = willPlaySong?.stream;
3280
+ if (!willPlaySong || !stream?.playFromSource || !stream.url) throw new DisTubeError("NO_STREAM_URL", `${song}`);
3281
+ this.debug(`[${queue.id}] Creating DisTubeStream for: ${willPlaySong}`);
3282
+ const streamOptions = {
3283
+ ffmpeg: {
3284
+ path: this.options.ffmpeg.path,
3285
+ args: {
3286
+ global: { ...queue.ffmpegArgs.global },
3287
+ input: { ...queue.ffmpegArgs.input },
3288
+ output: { ...queue.ffmpegArgs.output, ...queue.filters.ffmpegArgs }
3289
+ }
3290
+ },
3291
+ seek: willPlaySong.duration ? queue._beginTime : void 0
3292
+ };
3293
+ queue._beginTime = 0;
3294
+ dtStream = new DisTubeStream(stream.url, streamOptions);
3295
+ dtStream.on("debug", (data) => this.emit("ffmpegDebug" /* FFMPEG_DEBUG */, `[${queue.id}] ${data}`));
3296
+ }
3297
+ queue._beginTime = 0;
3298
+ this.debug(`[${queue.id}] Started playing: ${song}`);
3299
+ await queue.voice.play(dtStream);
3300
+ if (emitPlaySong) this.emit("playSong" /* PLAY_SONG */, queue, song);
3301
+ this.schedulePreload(queue);
3302
+ this.scheduleCrossfade(queue);
3303
+ } catch (e) {
3304
+ const error = e instanceof Error ? e : new Error(String(e));
3305
+ if (options?.keepSongOnError) {
3306
+ this.debug(`[${queue.id}] Error while restarting the stream: ${error.stack || error.message}`);
3307
+ this.emitError(error, queue, queue.songs[0]);
3308
+ return;
3309
+ }
3310
+ await this.#handlePlayingError(queue, error);
3311
+ }
3312
+ }
3313
+ /**
3314
+ * Schedule preloading the next song in the queue before the current song finishes.
3315
+ * @param queue - The guild queue
3316
+ */
3317
+ schedulePreload(queue) {
3318
+ if (!this.options.preload.enabled || queue.stopped || queue.paused) return;
3319
+ if (queue._preloadTimeout) {
3320
+ clearTimeout(queue._preloadTimeout);
3321
+ queue._preloadTimeout = void 0;
3322
+ }
3323
+ const currentSong = queue.songs[0];
3324
+ const nextSong = queue.songs[1];
3325
+ if (!currentSong || !nextSong) return;
3326
+ if (queue._preloadedSong === nextSong && queue._preloadedStream) return;
3327
+ const threshold = this.options.preload.threshold;
3328
+ const duration = currentSong.duration ?? 0;
3329
+ const currentTime = queue.currentTime;
3330
+ const remaining = duration > 0 && Number.isFinite(currentTime) ? duration - currentTime : 0;
3331
+ if (duration === 0 || remaining <= threshold) {
3332
+ void this.preloadNextSong(queue);
3333
+ } else {
3334
+ const delayMs = Math.max(0, (remaining - threshold) * 1e3);
3335
+ queue._preloadTimeout = setTimeout(() => {
3336
+ queue._preloadTimeout = void 0;
3337
+ void this.preloadNextSong(queue);
3338
+ }, delayMs);
3339
+ queue._preloadTimeout.unref?.();
3340
+ }
3341
+ }
3342
+ /**
3343
+ * Preload the next song in the queue.
3344
+ * @param queue - The guild queue
3345
+ */
3346
+ async preloadNextSong(queue) {
3347
+ if (queue._preloading) return queue._preloading;
3348
+ const task = this.#preloadTask(queue);
3349
+ queue._preloading = task;
3350
+ try {
3351
+ await task;
3352
+ } finally {
3353
+ if (queue._preloading === task) queue._preloading = void 0;
3354
+ }
3355
+ }
3356
+ async #preloadTask(queue) {
3357
+ if (!this.options.preload.enabled || queue.stopped || queue.paused) return;
3358
+ const nextSong = queue.songs[1];
3359
+ if (!nextSong) return;
3360
+ if (queue._preloadedSong === nextSong && queue._preloadedStream) return;
3361
+ const generation = queue._preloadGeneration;
3362
+ this.debug(`[${queue.id}] Preloading next song: ${nextSong}`);
3363
+ this.emit("preloadSong" /* PRELOAD_SONG */, queue, nextSong);
3364
+ try {
3365
+ await this.handler.attachStreamInfo(nextSong);
3366
+ if (queue.stopped || queue.songs[1] !== nextSong || generation !== queue._preloadGeneration) return;
3367
+ const willPlaySong = nextSong.stream.playFromSource ? nextSong : nextSong.stream.song;
2926
3368
  const stream = willPlaySong?.stream;
2927
- if (!willPlaySong || !stream?.playFromSource || !stream.url) throw new DisTubeError("NO_STREAM_URL", `${song}`);
2928
- this.debug(`[${queue.id}] Creating DisTubeStream for: ${willPlaySong}`);
3369
+ if (!willPlaySong || !stream?.playFromSource || !stream.url) {
3370
+ throw new DisTubeError("NO_STREAM_URL", `${nextSong}`);
3371
+ }
2929
3372
  const streamOptions = {
2930
3373
  ffmpeg: {
2931
3374
  path: this.options.ffmpeg.path,
@@ -2935,22 +3378,185 @@ var QueueManager = class extends GuildIdManager {
2935
3378
  output: { ...queue.ffmpegArgs.output, ...queue.filters.ffmpegArgs }
2936
3379
  }
2937
3380
  },
2938
- seek: willPlaySong.duration ? queue._beginTime : void 0
3381
+ seek: void 0
2939
3382
  };
2940
- queue._beginTime = 0;
2941
3383
  const dtStream = new DisTubeStream(stream.url, streamOptions);
2942
- dtStream.on("debug", (data) => this.emit("ffmpegDebug" /* FFMPEG_DEBUG */, `[${queue.id}] ${data}`));
2943
- this.debug(`[${queue.id}] Started playing: ${willPlaySong}`);
2944
- await queue.voice.play(dtStream);
2945
- if (emitPlaySong) this.emit("playSong" /* PLAY_SONG */, queue, song);
3384
+ dtStream.on("debug", (data) => this.emit("ffmpegDebug" /* FFMPEG_DEBUG */, `[${queue.id}] [preload] ${data}`));
3385
+ if (queue.stopped || queue.songs[1] !== nextSong || generation !== queue._preloadGeneration) {
3386
+ dtStream.kill();
3387
+ return;
3388
+ }
3389
+ queue._preloadedSong = nextSong;
3390
+ queue._preloadedStream = dtStream;
3391
+ this.debug(`[${queue.id}] Successfully preloaded next song: ${nextSong}`);
3392
+ this.emit("preloadedSong" /* PRELOADED_SONG */, queue, nextSong);
2946
3393
  } catch (e) {
3394
+ if (queue._preloadedSong === nextSong) {
3395
+ queue._preloadedSong = void 0;
3396
+ queue._preloadedStream?.kill();
3397
+ queue._preloadedStream = void 0;
3398
+ }
2947
3399
  const error = e instanceof Error ? e : new Error(String(e));
2948
- if (options?.keepSongOnError) {
2949
- this.debug(`[${queue.id}] Error while restarting the stream: ${error.stack || error.message}`);
2950
- this.emitError(error, queue, queue.songs[0]);
3400
+ this.debug(`[${queue.id}] Preload failed for ${nextSong}: ${error.message}`);
3401
+ this.emit("preloadError" /* PRELOAD_ERROR */, queue, nextSong, error);
3402
+ }
3403
+ }
3404
+ /**
3405
+ * Schedule the crossfade into the next song: a single FFmpeg process mixes
3406
+ * the current song (seeked to its final fade window) with the next song, and
3407
+ * replaces the playback stream exactly when the fade window begins.
3408
+ *
3409
+ * Canceled automatically whenever the preloaded state is invalidated
3410
+ * (pause, seek, stop...), as both schedulers share the same generation.
3411
+ * @param queue - The guild queue
3412
+ */
3413
+ scheduleCrossfade(queue) {
3414
+ queue._crossfadeCancel();
3415
+ if (queue.stopped || queue.paused) return;
3416
+ const currentSong = queue.songs[0];
3417
+ if (!currentSong) return;
3418
+ const songDuration = currentSong.duration ?? 0;
3419
+ if (!songDuration || !Number.isFinite(songDuration)) return;
3420
+ const fade = Math.min(Number(queue.crossfade) || 0, songDuration);
3421
+ if (fade <= 0) return;
3422
+ const nextSong = queue.repeatMode === 1 /* SONG */ ? currentSong : queue.songs[1];
3423
+ if (!nextSong) return;
3424
+ const currentTime = queue.currentTime;
3425
+ if (!Number.isFinite(currentTime)) return;
3426
+ const remaining = songDuration - currentTime;
3427
+ if (remaining <= fade) return;
3428
+ const delayMs = Math.max(0, (remaining - fade - this.options.crossfade.warmup) * 1e3);
3429
+ this.debug(
3430
+ `[${queue.id}] Scheduling crossfade (${fade}s fade) in ${(delayMs / 1e3).toFixed(1)}s: ${currentSong} -> ${nextSong}`
3431
+ );
3432
+ queue._crossfadeTimeout = setTimeout(() => {
3433
+ queue._crossfadeTimeout = void 0;
3434
+ void this.#crossfadeWarmupTask(queue);
3435
+ }, delayMs);
3436
+ queue._crossfadeTimeout.unref?.();
3437
+ }
3438
+ /**
3439
+ * Spawn the merged transition stream ahead of the fade window so both of
3440
+ * its inputs are buffered when the fade must start, then arm the boundary
3441
+ * timer that swaps the playback stream.
3442
+ */
3443
+ async #crossfadeWarmupTask(queue) {
3444
+ let transitionStream;
3445
+ try {
3446
+ if (queue.stopped || queue.paused) return;
3447
+ const song = queue.songs[0];
3448
+ const generation = queue._preloadGeneration;
3449
+ const repeatSong = queue.repeatMode === 1 /* SONG */;
3450
+ const nextSong = repeatSong ? song : queue.songs[1];
3451
+ if (!song || !nextSong) return;
3452
+ const songDuration = song.duration ?? 0;
3453
+ const fade = Math.min(Number(queue.crossfade) || 0, songDuration);
3454
+ if (fade <= 0) return;
3455
+ const remaining = songDuration - queue.currentTime;
3456
+ if (remaining <= fade) return;
3457
+ if (!repeatSong) {
3458
+ if (this.options.preload.enabled) await this.preloadNextSong(queue);
3459
+ else await this.handler.attachStreamInfo(nextSong);
3460
+ }
3461
+ if (queue.stopped || queue.paused || queue.songs[0] !== song || generation !== queue._preloadGeneration) return;
3462
+ if (repeatSong ? queue.songs[0] !== song : queue.songs[1] !== nextSong) return;
3463
+ const primary = song.stream.playFromSource ? song : song.stream.song;
3464
+ const secondary = repeatSong ? primary : nextSong.stream.playFromSource ? nextSong : nextSong.stream.song;
3465
+ if (!primary?.stream.playFromSource || !primary.stream.url) return;
3466
+ if (!secondary?.stream.playFromSource || !secondary.stream.url) return;
3467
+ this.debug(`[${queue.id}] Warming up crossfade stream: ${song} -> ${nextSong}`);
3468
+ transitionStream = new DisTubeStream(primary.stream.url, {
3469
+ ffmpeg: {
3470
+ path: this.options.ffmpeg.path,
3471
+ args: {
3472
+ global: { ...queue.ffmpegArgs.global },
3473
+ input: { ...queue.ffmpegArgs.input },
3474
+ output: { ...queue.ffmpegArgs.output, ...queue.filters.ffmpegArgs }
3475
+ }
3476
+ },
3477
+ crossfade: {
3478
+ duration: fade,
3479
+ primarySeek: Math.max(0, songDuration - fade),
3480
+ secondaryUrl: secondary.stream.url,
3481
+ inCurve: this.options.crossfade.curve,
3482
+ outCurve: this.options.crossfade.curve
3483
+ }
3484
+ });
3485
+ transitionStream.on("debug", (data) => this.emit("ffmpegDebug" /* FFMPEG_DEBUG */, `[${queue.id}] [crossfade] ${data}`));
3486
+ if (queue.stopped || queue.paused || queue.songs[0] !== song || generation !== queue._preloadGeneration) {
3487
+ transitionStream.kill();
2951
3488
  return;
2952
3489
  }
2953
- await this.#handlePlayingError(queue, error);
3490
+ queue._crossfadeWarmup = transitionStream;
3491
+ transitionStream.spawn();
3492
+ const boundaryMs = Math.max(0, (remaining - fade) * 1e3);
3493
+ queue._crossfadeTimeout = setTimeout(() => {
3494
+ queue._crossfadeTimeout = void 0;
3495
+ void this.#crossfadeSwapTask(queue, transitionStream);
3496
+ }, boundaryMs);
3497
+ queue._crossfadeTimeout.unref?.();
3498
+ } catch (e) {
3499
+ const errorMessage = e instanceof Error ? e.message : String(e);
3500
+ this.debug(`[${queue.id}] Crossfade warmup failed, using the regular transition: ${errorMessage}`);
3501
+ if (transitionStream) transitionStream.kill();
3502
+ }
3503
+ }
3504
+ /**
3505
+ * Swap the playing stream for the merged transition stream and update the
3506
+ * queue as if the current song had just finished.
3507
+ */
3508
+ async #crossfadeSwapTask(queue, transitionStream) {
3509
+ const abort = /* @__PURE__ */ __name(() => {
3510
+ if (queue._crossfadeWarmup === transitionStream) queue._crossfadeWarmup = void 0;
3511
+ transitionStream.kill();
3512
+ }, "abort");
3513
+ try {
3514
+ if (queue.stopped || queue.paused || queue._crossfadeWarmup !== transitionStream) return abort();
3515
+ const song = queue.songs[0];
3516
+ if (!song) return abort();
3517
+ const songDuration = song.duration ?? 0;
3518
+ const fade = Math.min(Number(queue.crossfade) || 0, songDuration);
3519
+ if (fade <= 0) return abort();
3520
+ const drift = queue.currentTime - (songDuration - fade);
3521
+ if (Math.abs(drift) > CROSSFADE_DRIFT_TOLERANCE_SECS) {
3522
+ this.debug(`[${queue.id}] Crossfade aborted: playback drifted ${drift.toFixed(2)}s from the fade boundary`);
3523
+ return abort();
3524
+ }
3525
+ await queue._taskQueue.queuing();
3526
+ try {
3527
+ if (queue.stopped || queue.paused || queue.songs[0] !== song || queue._crossfadeWarmup !== transitionStream) {
3528
+ return abort();
3529
+ }
3530
+ this.debug(`[${queue.id}] Starting crossfade: ${song} -> ${queue.songs.length > 1 ? queue.songs[1] : song}`);
3531
+ await queue.voice.play(transitionStream);
3532
+ queue._crossfadeWarmup = void 0;
3533
+ this.emit("finishSong" /* FINISH_SONG */, queue, song);
3534
+ if (queue.repeatMode === 2 /* QUEUE */) queue.songs.push(song);
3535
+ if (queue.repeatMode !== 1 /* SONG */) {
3536
+ const prev = queue.songs.shift();
3537
+ if (this.options.savePreviousSongs) queue.previousSongs.push(prev);
3538
+ else queue.previousSongs.push({ id: prev.id });
3539
+ } else {
3540
+ }
3541
+ if (song !== queue.songs[0]) {
3542
+ const playedSong = song.stream.playFromSource ? song : song.stream.song;
3543
+ if (playedSong?.stream.playFromSource) delete playedSong.stream.url;
3544
+ }
3545
+ if (queue._preloadedStream) {
3546
+ queue._preloadedStream.kill();
3547
+ queue._preloadedStream = void 0;
3548
+ queue._preloadedSong = void 0;
3549
+ }
3550
+ this.emit("playSong" /* PLAY_SONG */, queue, queue.songs[0]);
3551
+ this.schedulePreload(queue);
3552
+ this.scheduleCrossfade(queue);
3553
+ } finally {
3554
+ queue._taskQueue.resolve();
3555
+ }
3556
+ } catch (e) {
3557
+ abort();
3558
+ const errorMessage = e instanceof Error ? e.message : String(e);
3559
+ this.debug(`[${queue.id}] Crossfade swap failed, using the regular transition: ${errorMessage}`);
2954
3560
  }
2955
3561
  }
2956
3562
  };
@@ -3967,6 +4573,11 @@ var PlayableExtractorPlugin = class extends ExtractorPlugin {
3967
4573
  AUDIO_CHANNELS,
3968
4574
  AUDIO_SAMPLE_RATE,
3969
4575
  BaseManager,
4576
+ CROSSFADE_DRIFT_TOLERANCE_SECS,
4577
+ DEFAULT_CROSSFADE_CURVE,
4578
+ DEFAULT_CROSSFADE_DURATION_SECS,
4579
+ DEFAULT_CROSSFADE_WARMUP_SECS,
4580
+ DEFAULT_PRELOAD_THRESHOLD_SECS,
3970
4581
  DEFAULT_VOLUME,
3971
4582
  DisTube,
3972
4583
  DisTubeBase,
@@ -3982,6 +4593,7 @@ var PlayableExtractorPlugin = class extends ExtractorPlugin {
3982
4593
  HTTP_REDIRECT_CODES,
3983
4594
  InfoExtractorPlugin,
3984
4595
  JOIN_TIMEOUT_MS,
4596
+ MAX_CROSSFADE_DURATION_SECS,
3985
4597
  MAX_REDIRECT_DEPTH,
3986
4598
  Options,
3987
4599
  PlayableExtractorPlugin,