ziplayer 0.2.7 → 0.3.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.
Files changed (63) hide show
  1. package/AI-Guide.md +624 -956
  2. package/README.md +277 -10
  3. package/dist/extensions/BaseExtension.d.ts +1 -0
  4. package/dist/extensions/BaseExtension.d.ts.map +1 -1
  5. package/dist/extensions/BaseExtension.js.map +1 -1
  6. package/dist/extensions/index.d.ts +38 -3
  7. package/dist/extensions/index.d.ts.map +1 -1
  8. package/dist/extensions/index.js +259 -41
  9. package/dist/extensions/index.js.map +1 -1
  10. package/dist/persistence/PersistenceManager.d.ts +95 -0
  11. package/dist/persistence/PersistenceManager.d.ts.map +1 -0
  12. package/dist/persistence/PersistenceManager.js +975 -0
  13. package/dist/persistence/PersistenceManager.js.map +1 -0
  14. package/dist/plugins/BasePlugin.js +1 -1
  15. package/dist/plugins/BasePlugin.js.map +1 -1
  16. package/dist/plugins/index.d.ts +73 -8
  17. package/dist/plugins/index.d.ts.map +1 -1
  18. package/dist/plugins/index.js +647 -116
  19. package/dist/plugins/index.js.map +1 -1
  20. package/dist/structures/FilterManager.js +3 -3
  21. package/dist/structures/FilterManager.js.map +1 -1
  22. package/dist/structures/PersistenceManager.d.ts +96 -0
  23. package/dist/structures/PersistenceManager.d.ts.map +1 -0
  24. package/dist/structures/PersistenceManager.js +1008 -0
  25. package/dist/structures/PersistenceManager.js.map +1 -0
  26. package/dist/structures/Player.d.ts +157 -14
  27. package/dist/structures/Player.d.ts.map +1 -1
  28. package/dist/structures/Player.js +1163 -188
  29. package/dist/structures/Player.js.map +1 -1
  30. package/dist/structures/PlayerManager.d.ts +106 -91
  31. package/dist/structures/PlayerManager.d.ts.map +1 -1
  32. package/dist/structures/PlayerManager.js +365 -124
  33. package/dist/structures/PlayerManager.js.map +1 -1
  34. package/dist/structures/Queue.d.ts +136 -31
  35. package/dist/structures/Queue.d.ts.map +1 -1
  36. package/dist/structures/Queue.js +265 -46
  37. package/dist/structures/Queue.js.map +1 -1
  38. package/dist/structures/StreamManager.d.ts +137 -0
  39. package/dist/structures/StreamManager.d.ts.map +1 -0
  40. package/dist/structures/StreamManager.js +420 -0
  41. package/dist/structures/StreamManager.js.map +1 -0
  42. package/dist/types/index.d.ts +181 -8
  43. package/dist/types/index.d.ts.map +1 -1
  44. package/dist/types/index.js.map +1 -1
  45. package/dist/types/persistence.d.ts +77 -0
  46. package/dist/types/persistence.d.ts.map +1 -0
  47. package/dist/types/persistence.js +3 -0
  48. package/dist/types/persistence.js.map +1 -0
  49. package/package.json +3 -2
  50. package/src/extensions/BaseExtension.ts +1 -0
  51. package/src/extensions/index.ts +320 -37
  52. package/src/plugins/BasePlugin.ts +1 -1
  53. package/src/plugins/index.ts +801 -139
  54. package/src/structures/FilterManager.ts +3 -3
  55. package/src/structures/Player.ts +2797 -1693
  56. package/src/structures/PlayerManager.ts +438 -129
  57. package/src/structures/Queue.ts +300 -55
  58. package/src/structures/StreamManager.ts +524 -0
  59. package/src/types/extension.ts +129 -129
  60. package/src/types/fillter.ts +264 -264
  61. package/src/types/index.ts +187 -12
  62. package/src/types/plugin.ts +59 -59
  63. package/tsconfig.json +0 -1
@@ -3,11 +3,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Player = void 0;
4
4
  const events_1 = require("events");
5
5
  const voice_1 = require("@discordjs/voice");
6
+ const lru_cache_1 = require("lru-cache");
6
7
  const Queue_1 = require("./Queue");
7
8
  const plugins_1 = require("../plugins");
8
9
  const extensions_1 = require("../extensions");
9
- const timeout_1 = require("../utils/timeout");
10
10
  const FilterManager_1 = require("./FilterManager");
11
+ const StreamManager_1 = require("./StreamManager");
11
12
  /**
12
13
  * Represents a music player for a specific Discord guild.
13
14
  *
@@ -51,15 +52,79 @@ class Player extends events_1.EventEmitter {
51
52
  this.volume = 100;
52
53
  this.isPlaying = false;
53
54
  this.isPaused = false;
55
+ this._lastActivity = Date.now();
54
56
  this.leaveTimeout = null;
55
57
  this.currentResource = null;
56
58
  this.volumeInterval = null;
59
+ this.stuckTimer = null;
57
60
  this.skipLoop = false;
58
- // Cache for search results to avoid duplicate calls
59
- this.searchCache = new Map();
61
+ this.refreshLock = false;
62
+ //preloaded resource
63
+ this.preloadState = {
64
+ resource: null,
65
+ track: null,
66
+ abortController: null,
67
+ timeoutId: null,
68
+ isValid: false,
69
+ isBeingUsed: false,
70
+ };
71
+ this.isPreloading = false;
72
+ this.currentSlot = {
73
+ resource: null,
74
+ track: null,
75
+ streamId: null,
76
+ abortController: null,
77
+ isValid: false,
78
+ isLoading: false,
79
+ loadPromise: null,
80
+ };
81
+ this.preloadSlot = {
82
+ resource: null,
83
+ track: null,
84
+ streamId: null,
85
+ abortController: null,
86
+ isValid: false,
87
+ isLoading: false,
88
+ loadPromise: null,
89
+ };
90
+ this.preloadLock = false;
91
+ this.preloadEnabled = true;
92
+ this.crossfadeEnabled = true;
93
+ this.crossfadeDurationMs = 500;
94
+ this.lowPerformanceMode = false;
95
+ this.crossfadeTransitionLock = false;
96
+ this.smartTransitionEnabled = true;
97
+ this.smartTransitionGenreAware = true;
98
+ this.smartTransitionBeatAlign = true;
99
+ this.smartTransitionBaseMs = 800;
100
+ this.smartTransitionMinMs = 120;
101
+ this.smartTransitionMaxMs = 8000;
102
+ this.smartTransitionGenreDurations = {
103
+ chill: 700,
104
+ ambient: 750,
105
+ lofi: 650,
106
+ pop: 450,
107
+ rock: 350,
108
+ edm: 220,
109
+ house: 250,
110
+ techno: 200,
111
+ };
112
+ this.smartTransitionBeatAlignMaxWaitMs = 180;
113
+ this.antiStuckEnabled = true;
114
+ this.antiStuckMaxRetries = 2;
115
+ this.antiStuckRetryDelayMs = 900;
116
+ this.antiStuckReusePreloadFirst = true;
117
+ this.antiStuckReduceQualityOnRetry = true;
118
+ this.antiStuckControlledSkipThreshold = 3;
119
+ this.antiStuckConsecutiveFailures = 0;
120
+ this.loudnessNormalizationEnabled = false;
121
+ this.loudnessTargetLUFS = -14;
122
+ this.loudnessMaxBoostDb = 8;
123
+ this.loudnessMaxCutDb = 10;
124
+ this.loudnessLimiterCeiling = 0.95;
60
125
  this.SEARCH_CACHE_TTL = 2 * 60 * 1000; // 2 minutes
61
- this.searchCacheTimestamps = new Map();
62
126
  this.ttsPlayer = null;
127
+ this.lastDuration = 0;
63
128
  this.debug(`[Player] Constructor called for guildId: ${guildId}`);
64
129
  this.guildId = guildId;
65
130
  this.queue = new Queue_1.Queue();
@@ -84,17 +149,81 @@ class Player extends events_1.EventEmitter {
84
149
  createPlayer: false,
85
150
  interrupt: true,
86
151
  volume: 100,
87
- Max_Time_TTS: 60_000,
152
+ maxTimeTts: 60_000,
88
153
  ...(options?.tts || {}),
89
154
  },
90
155
  };
156
+ this.lowPerformanceMode = this.options.lowPerformance ?? this.options.quality === "low";
157
+ const preloadOptions = this.options.preload || {};
158
+ const preloadAutoDisable = preloadOptions.autoDisableInLowPerformance ?? true;
159
+ this.preloadEnabled = preloadOptions.enabled ?? true;
160
+ if (this.lowPerformanceMode && preloadAutoDisable) {
161
+ this.preloadEnabled = false;
162
+ }
163
+ const crossfadeOptions = this.options.crossfade || {};
164
+ const crossfadeAutoEnable = crossfadeOptions.autoEnable ?? true;
165
+ const crossfadeAutoDisable = crossfadeOptions.autoDisableInLowPerformance ?? true;
166
+ this.crossfadeDurationMs = Math.max(0, crossfadeOptions.durationMs ?? 5000);
167
+ if (typeof crossfadeOptions.enabled === "boolean") {
168
+ this.crossfadeEnabled = crossfadeOptions.enabled;
169
+ }
170
+ else {
171
+ this.crossfadeEnabled = crossfadeAutoEnable;
172
+ }
173
+ if (this.lowPerformanceMode && crossfadeAutoDisable) {
174
+ this.crossfadeEnabled = false;
175
+ }
176
+ const smartTransitionOptions = this.options.smartTransition || {};
177
+ this.smartTransitionEnabled = smartTransitionOptions.enabled ?? true;
178
+ this.smartTransitionGenreAware = smartTransitionOptions.genreAware ?? true;
179
+ this.smartTransitionBeatAlign = smartTransitionOptions.beatAlign ?? true;
180
+ this.smartTransitionBaseMs = Math.max(0, smartTransitionOptions.baseDurationMs ?? this.crossfadeDurationMs);
181
+ this.smartTransitionMinMs = Math.max(0, smartTransitionOptions.minDurationMs ?? 1200);
182
+ this.smartTransitionMaxMs = Math.max(this.smartTransitionMinMs, smartTransitionOptions.maxDurationMs ?? 8000);
183
+ this.smartTransitionBeatAlignMaxWaitMs = Math.max(0, smartTransitionOptions.beatAlignMaxWaitMs ?? 1200);
184
+ this.smartTransitionGenreDurations = {
185
+ ...this.smartTransitionGenreDurations,
186
+ ...(smartTransitionOptions.genreDurations || {}),
187
+ };
188
+ const antiStuckOptions = this.options.antiStuck || {};
189
+ this.antiStuckEnabled = antiStuckOptions.enabled ?? true;
190
+ this.antiStuckMaxRetries = Math.max(0, antiStuckOptions.maxRetries ?? 2);
191
+ this.antiStuckRetryDelayMs = Math.max(0, antiStuckOptions.retryDelayMs ?? 900);
192
+ this.antiStuckReusePreloadFirst = antiStuckOptions.reusePreloadFirst ?? true;
193
+ this.antiStuckReduceQualityOnRetry = antiStuckOptions.reduceQualityOnRetry ?? true;
194
+ this.antiStuckControlledSkipThreshold = Math.max(1, antiStuckOptions.controlledSkipThreshold ?? 3);
195
+ const loudnessOptions = this.options.loudnessNormalization || {};
196
+ this.loudnessNormalizationEnabled = loudnessOptions.enabled ?? false;
197
+ this.loudnessTargetLUFS = loudnessOptions.targetLUFS ?? -14;
198
+ this.loudnessMaxBoostDb = Math.max(0, loudnessOptions.maxBoostDb ?? 8);
199
+ this.loudnessMaxCutDb = Math.max(0, loudnessOptions.maxCutDb ?? 10);
200
+ this.loudnessLimiterCeiling = Math.min(1, Math.max(0.1, loudnessOptions.limiterCeiling ?? 0.95));
201
+ this.debug(`[Player] Runtime options resolved: lowPerformance=${this.lowPerformanceMode}, preload=${this.preloadEnabled}, crossfade=${this.crossfadeEnabled} (${this.crossfadeDurationMs}ms), smartTransition=${this.smartTransitionEnabled}, antiStuck=${this.antiStuckEnabled}, loudnessNormalization=${this.loudnessNormalizationEnabled}`);
91
202
  this.filter = new FilterManager_1.FilterManager(this, this.manager);
92
203
  this.extensionManager = new extensions_1.ExtensionManager(this, this.manager);
93
204
  this.pluginManager = new plugins_1.PluginManager(this, this.manager, {
94
205
  extractorTimeout: this.options.extractorTimeout,
95
206
  });
207
+ this.streamManager = new StreamManager_1.StreamManager({
208
+ maxConcurrentStreams: 20,
209
+ streamTimeout: 5 * 60 * 1000,
210
+ maxListenersPerStream: 15,
211
+ enableMetrics: true,
212
+ autoDestroy: true,
213
+ });
96
214
  this.volume = this.options.volume || 100;
97
215
  this.userdata = this.options.userdata;
216
+ this.searchCache = new lru_cache_1.LRUCache({
217
+ max: 200,
218
+ ttl: this.SEARCH_CACHE_TTL,
219
+ dispose: (value, key, reason) => {
220
+ if (this.listenerCount("debug") > 0) {
221
+ this.debug(`[SearchCache] Disposed cache entry: ${key}, reason: ${reason}`);
222
+ }
223
+ },
224
+ allowStale: false,
225
+ updateAgeOnGet: true,
226
+ });
98
227
  this.setupEventListeners();
99
228
  // Initialize filters from options
100
229
  if (this.options.filters && this.options.filters.length > 0) {
@@ -114,11 +243,12 @@ class Player extends events_1.EventEmitter {
114
243
  * @private
115
244
  */
116
245
  destroyCurrentStream() {
246
+ this.audioPlayer.stop(true);
117
247
  if (!this.currentResource)
118
248
  return;
119
249
  const stream = this.currentResource?.metadata?.stream ?? this.currentResource?.stream;
120
- if (stream?.destroy) {
121
- stream.destroy();
250
+ if (stream && typeof stream.destroy === "function") {
251
+ stream.destroy().catch((e) => this.debug("Stream destroy error:", e));
122
252
  }
123
253
  this.currentResource = null;
124
254
  }
@@ -135,12 +265,7 @@ class Player extends events_1.EventEmitter {
135
265
  */
136
266
  async search(query, requestedBy) {
137
267
  this.debug(`[Player] Search called with query: ${query}, requestedBy: ${requestedBy}`);
138
- // Clear expired search cache periodically
139
- if (Math.random() < 0.1) {
140
- // 10% chance to clean cache
141
- this.clearExpiredSearchCache();
142
- }
143
- // Check cache first
268
+ // Check player cache first (LRU)
144
269
  const cachedResult = this.getCachedSearchResult(query);
145
270
  if (cachedResult) {
146
271
  return cachedResult;
@@ -152,44 +277,18 @@ class Player extends events_1.EventEmitter {
152
277
  this.cacheSearchResult(query, extensionResult);
153
278
  return extensionResult;
154
279
  }
155
- // Get plugins and filter out TTS for regular searches
156
- const allPlugins = this.pluginManager.getAll();
157
- const plugins = allPlugins.filter((p) => {
158
- // Skip TTS plugin for regular searches (unless query starts with "tts:")
159
- if (p.name.toLowerCase() === "tts" && !query.toLowerCase().startsWith("tts:")) {
160
- this.debug(`[Player] Skipping TTS plugin for regular search: ${query}`);
161
- return false;
162
- }
163
- return true;
164
- });
165
- this.debug(`[Player] Using ${plugins.length} plugins for search (filtered from ${allPlugins.length})`);
166
- let lastError = null;
167
- let searchAttempts = 0;
168
- for (const p of plugins) {
169
- searchAttempts++;
170
- try {
171
- this.debug(`[Player] Trying plugin for search: ${p.name} (attempt ${searchAttempts}/${plugins.length})`);
172
- const startTime = Date.now();
173
- const res = await (0, timeout_1.withTimeout)(p.search(query, requestedBy), this.options.extractorTimeout ?? 15000, `Search operation timed out for ${p.name}`);
174
- const duration = Date.now() - startTime;
175
- if (res && Array.isArray(res.tracks) && res.tracks.length > 0) {
176
- this.debug(`[Player] Plugin '${p.name}' returned ${res.tracks.length} tracks in ${duration}ms`);
177
- this.cacheSearchResult(query, res);
178
- return res;
179
- }
180
- this.debug(`[Player] Plugin '${p.name}' returned no tracks in ${duration}ms`);
181
- }
182
- catch (error) {
183
- const errorMessage = error instanceof Error ? error.message : String(error);
184
- this.debug(`[Player] Search via plugin '${p.name}' failed: ${errorMessage}`);
185
- lastError = error;
186
- // Continue to next plugin
280
+ // Use PluginManager for search with deduplication and evaluation
281
+ const pluginResult = await this.pluginManager.search(query, requestedBy);
282
+ if (pluginResult && pluginResult.tracks.length > 0) {
283
+ this.debug(`[Player] Plugin search returned ${pluginResult.tracks.length} tracks (score: ${pluginResult.score?.score}%)`);
284
+ if (pluginResult.score) {
285
+ this.debug(`[Player] Search evaluation - ${pluginResult.score.reason}`);
187
286
  }
287
+ this.cacheSearchResult(query, pluginResult);
288
+ return pluginResult;
188
289
  }
189
- this.debug(`[Player] No plugins returned results for query: ${query} (tried ${searchAttempts} plugins)`);
190
- if (lastError)
191
- this.emit("playerError", lastError);
192
- throw new Error(`No plugin found to handle: ${query}`);
290
+ this.debug(`[Player] No search results for query: ${query}`);
291
+ throw new Error(`No results found for: ${query}`);
193
292
  }
194
293
  /**
195
294
  * Get cached search result or null if not found/expired
@@ -198,14 +297,10 @@ class Player extends events_1.EventEmitter {
198
297
  */
199
298
  getCachedSearchResult(query) {
200
299
  const cacheKey = query.toLowerCase().trim();
201
- const now = Date.now();
202
- const cachedTimestamp = this.searchCacheTimestamps.get(cacheKey);
203
- if (cachedTimestamp && now - cachedTimestamp < this.SEARCH_CACHE_TTL) {
204
- const cachedResult = this.searchCache.get(cacheKey);
205
- if (cachedResult) {
206
- this.debug(`[SearchCache] Using cached search result for: ${query}`);
207
- return cachedResult;
208
- }
300
+ const cached = this.searchCache.get(cacheKey);
301
+ if (cached) {
302
+ this.debug(`[SearchCache] Using cached search result for: ${query}`);
303
+ return cached;
209
304
  }
210
305
  return null;
211
306
  }
@@ -216,23 +311,15 @@ class Player extends events_1.EventEmitter {
216
311
  */
217
312
  cacheSearchResult(query, result) {
218
313
  const cacheKey = query.toLowerCase().trim();
219
- const now = Date.now();
220
314
  this.searchCache.set(cacheKey, result);
221
- this.searchCacheTimestamps.set(cacheKey, now);
222
315
  this.debug(`[SearchCache] Cached search result for: ${query} (${result.tracks.length} tracks)`);
223
316
  }
224
317
  /**
225
318
  * Clear expired search cache entries
226
319
  */
227
320
  clearExpiredSearchCache() {
228
- const now = Date.now();
229
- for (const [key, timestamp] of this.searchCacheTimestamps.entries()) {
230
- if (now - timestamp >= this.SEARCH_CACHE_TTL) {
231
- this.searchCache.delete(key);
232
- this.searchCacheTimestamps.delete(key);
233
- this.debug(`[SearchCache] Cleared expired cache entry: ${key}`);
234
- }
235
- }
321
+ this.searchCache.purgeStale();
322
+ this.debug(`[SearchCache] Purged stale search cache entries`);
236
323
  }
237
324
  /**
238
325
  * Clear all search cache entries
@@ -242,7 +329,6 @@ class Player extends events_1.EventEmitter {
242
329
  clearSearchCache() {
243
330
  const cacheSize = this.searchCache.size;
244
331
  this.searchCache.clear();
245
- this.searchCacheTimestamps.clear();
246
332
  this.debug(`[SearchCache] Cleared all ${cacheSize} search cache entries`);
247
333
  }
248
334
  /**
@@ -252,9 +338,8 @@ class Player extends events_1.EventEmitter {
252
338
  */
253
339
  debugSearchQuery(query) {
254
340
  const cacheKey = query.toLowerCase().trim();
255
- const now = Date.now();
256
- const cachedTimestamp = this.searchCacheTimestamps.get(cacheKey);
257
- const isCached = cachedTimestamp && now - cachedTimestamp < this.SEARCH_CACHE_TTL;
341
+ const cached = this.searchCache.get(cacheKey);
342
+ const isCached = !!cached;
258
343
  const allPlugins = this.pluginManager.getAll();
259
344
  const plugins = allPlugins.filter((p) => {
260
345
  if (p.name.toLowerCase() === "tts" && !query.toLowerCase().startsWith("tts:")) {
@@ -263,8 +348,8 @@ class Player extends events_1.EventEmitter {
263
348
  return true;
264
349
  });
265
350
  return {
266
- isCached: !!isCached,
267
- cacheAge: cachedTimestamp ? now - cachedTimestamp : undefined,
351
+ isCached,
352
+ cacheAge: undefined,
268
353
  pluginCount: plugins.length,
269
354
  ttsFiltered: allPlugins.length > plugins.length,
270
355
  };
@@ -334,7 +419,7 @@ class Player extends events_1.EventEmitter {
334
419
  }
335
420
  else {
336
421
  // Handle other types (string, Track)
337
- const hookOutcome = await this.extensionManager.BeforePlayHooks(effectiveRequest);
422
+ const hookOutcome = await this.extensionManager.beforePlayHooks(effectiveRequest);
338
423
  effectiveRequest = hookOutcome.request;
339
424
  hookResponse = hookOutcome.response;
340
425
  if (effectiveRequest.requestedBy === undefined) {
@@ -350,7 +435,7 @@ class Player extends events_1.EventEmitter {
350
435
  isPlaylist: hookResponse.isPlaylist ?? false,
351
436
  error: hookResponse.error,
352
437
  };
353
- await this.extensionManager.AfterPlayHooks(handledPayload);
438
+ await this.extensionManager.afterPlayHooks(handledPayload);
354
439
  if (hookResponse.error) {
355
440
  this.emit("playerError", hookResponse.error);
356
441
  }
@@ -393,7 +478,7 @@ class Player extends events_1.EventEmitter {
393
478
  (isTTS(tracksToAdd[0]) || queryLooksTTS)) {
394
479
  this.debug(`[Player] Interrupting with TTS: ${tracksToAdd[0].title}`);
395
480
  await this.interruptWithTTSTrack(tracksToAdd[0]);
396
- await this.extensionManager.AfterPlayHooks({
481
+ await this.extensionManager.afterPlayHooks({
397
482
  success: true,
398
483
  query: effectiveRequest.query,
399
484
  requestedBy: effectiveRequest.requestedBy,
@@ -411,7 +496,7 @@ class Player extends events_1.EventEmitter {
411
496
  this.emit("queueAdd", tracksToAdd[0]);
412
497
  }
413
498
  const started = !this.isPlaying ? await this.playNext() : true;
414
- await this.extensionManager.AfterPlayHooks({
499
+ await this.extensionManager.afterPlayHooks({
415
500
  success: started,
416
501
  query: effectiveRequest.query,
417
502
  requestedBy: effectiveRequest.requestedBy,
@@ -421,7 +506,7 @@ class Player extends events_1.EventEmitter {
421
506
  return started;
422
507
  }
423
508
  catch (error) {
424
- await this.extensionManager.AfterPlayHooks({
509
+ await this.extensionManager.afterPlayHooks({
425
510
  success: false,
426
511
  query: effectiveRequest.query,
427
512
  requestedBy: effectiveRequest.requestedBy,
@@ -434,6 +519,537 @@ class Player extends events_1.EventEmitter {
434
519
  return false;
435
520
  }
436
521
  }
522
+ //#endregion
523
+ //#region Preload
524
+ /**
525
+ * Main preload method - only one at a time
526
+ */
527
+ async preloadNextTrack() {
528
+ if (!this.preloadEnabled) {
529
+ this.debug(`[Preload] Disabled by options/runtime profile`);
530
+ return;
531
+ }
532
+ // Prevent concurrent preloads
533
+ if (this.preloadLock) {
534
+ this.debug(`[Preload] Already preloading, skipping`);
535
+ return;
536
+ }
537
+ const nextTrack = this.queue.nextTrack;
538
+ if (!nextTrack) {
539
+ this.debug(`[Preload] No next track to preload`);
540
+ return;
541
+ }
542
+ // Check if already preloaded correctly
543
+ if (this.preloadSlot.isValid && this.preloadSlot.track?.id === nextTrack.id && this.preloadSlot.resource) {
544
+ this.debug(`[Preload] Already have valid preload for: ${nextTrack.title}`);
545
+ return;
546
+ }
547
+ // Check if currently loading the same track
548
+ if (this.preloadSlot.isLoading && this.preloadSlot.track?.id === nextTrack.id) {
549
+ this.debug(`[Preload] Currently loading same track, waiting...`);
550
+ if (this.preloadSlot.loadPromise) {
551
+ await this.preloadSlot.loadPromise;
552
+ }
553
+ return;
554
+ }
555
+ // Cancel old preload if different track
556
+ if (this.preloadSlot.isValid && this.preloadSlot.track?.id !== nextTrack.id) {
557
+ this.debug(`[Preload] Cancelling old preload for different track: ${this.preloadSlot.track?.title}`);
558
+ await this.safeCancelPreload();
559
+ }
560
+ this.preloadLock = true;
561
+ // Create new abort controller
562
+ const abortController = new AbortController();
563
+ // Setup preload slot
564
+ this.preloadSlot.track = nextTrack;
565
+ this.preloadSlot.abortController = abortController;
566
+ this.preloadSlot.isLoading = true;
567
+ // Create load promise
568
+ const loadPromise = this.executePreload(nextTrack, abortController);
569
+ this.preloadSlot.loadPromise = loadPromise;
570
+ try {
571
+ await loadPromise;
572
+ }
573
+ catch (err) {
574
+ if (err instanceof Error && err.message === "PRELOAD_CANCELLED") {
575
+ this.debug(`[Preload] Cancelled for ${nextTrack.title}`);
576
+ }
577
+ else {
578
+ this.debug(`[Preload] Failed for ${nextTrack.title}:`, err);
579
+ }
580
+ this.clearSlot(this.preloadSlot);
581
+ }
582
+ finally {
583
+ this.preloadLock = false;
584
+ this.preloadSlot.isLoading = false;
585
+ this.preloadSlot.loadPromise = null;
586
+ }
587
+ }
588
+ /**
589
+ * Execute actual preload
590
+ */
591
+ async executePreload(track, abortController) {
592
+ this.debug(`[Preload] Starting preload for: ${track.title}`);
593
+ // Check for cancellation
594
+ if (abortController.signal.aborted) {
595
+ throw new Error("PRELOAD_CANCELLED");
596
+ }
597
+ // Check if track still relevant
598
+ if (this.queue.nextTrack?.id !== track.id) {
599
+ this.debug(`[Preload] Track changed, cancelling`);
600
+ throw new Error("PRELOAD_CANCELLED");
601
+ }
602
+ try {
603
+ // Get stream with abort support - NO TIMEOUT
604
+ const streamInfo = await this.getStreamWithCancel(track, abortController.signal);
605
+ // Check cancellation
606
+ if (abortController.signal.aborted) {
607
+ throw new Error("PRELOAD_CANCELLED");
608
+ }
609
+ // Check track relevance again
610
+ if (this.queue.nextTrack?.id !== track.id) {
611
+ this.debug(`[Preload] Track changed after stream fetch`);
612
+ throw new Error("PRELOAD_CANCELLED");
613
+ }
614
+ if (!streamInfo?.stream) {
615
+ throw new Error(`No stream available`);
616
+ }
617
+ // Register with StreamManager as preload
618
+ const streamId = this.streamManager.registerStream(streamInfo.stream, track, {
619
+ source: track.source || "preload",
620
+ isPreload: true,
621
+ priority: 5,
622
+ });
623
+ // Create resource
624
+ const resource = (0, voice_1.createAudioResource)(streamInfo.stream, {
625
+ inlineVolume: true,
626
+ metadata: { ...track, preloaded: true },
627
+ });
628
+ // Verify resource is valid
629
+ if (!resource.playStream || resource.playStream.readable === false) {
630
+ throw new Error("Resource not readable");
631
+ }
632
+ // Update preload slot
633
+ this.preloadSlot.resource = resource;
634
+ this.preloadSlot.streamId = streamId;
635
+ this.preloadSlot.isValid = true;
636
+ this.preloadSlot.track = track;
637
+ this.debug(`[Preload] Successfully preloaded: ${track.title} (Stream ID: ${streamId})`);
638
+ }
639
+ catch (err) {
640
+ if (err instanceof Error && err.message === "PRELOAD_CANCELLED") {
641
+ throw err;
642
+ }
643
+ this.debug(`[Preload] Error during preload:`, err);
644
+ throw err;
645
+ }
646
+ }
647
+ /**
648
+ * Safe cancel preload - doesn't throw
649
+ */
650
+ async safeCancelPreload() {
651
+ if (!this.preloadSlot.abortController && !this.preloadSlot.resource) {
652
+ return;
653
+ }
654
+ this.debug(`[Preload] Safely cancelling preload for: ${this.preloadSlot.track?.title || "unknown"}`);
655
+ // Abort the operation
656
+ if (this.preloadSlot.abortController) {
657
+ this.preloadSlot.abortController.abort();
658
+ this.preloadSlot.abortController = null;
659
+ }
660
+ // Clean up stream
661
+ if (this.preloadSlot.streamId && this.streamManager) {
662
+ this.streamManager.unregisterStream(this.preloadSlot.streamId, true);
663
+ }
664
+ // Clean up resource
665
+ if (this.preloadSlot.resource) {
666
+ try {
667
+ const stream = this.preloadSlot.resource.playStream;
668
+ if (stream && typeof stream.destroy === "function" && !stream.destroyed) {
669
+ stream.destroy();
670
+ }
671
+ }
672
+ catch (err) {
673
+ // Ignore destroy errors
674
+ }
675
+ }
676
+ // Clear slot
677
+ this.clearSlot(this.preloadSlot);
678
+ }
679
+ /**
680
+ * Get stream with proper cancellation
681
+ */
682
+ async getStreamWithCancel(track, signal) {
683
+ // Create abort promise
684
+ const abortPromise = new Promise((_, reject) => {
685
+ if (signal.aborted) {
686
+ reject(new Error("PRELOAD_CANCELLED"));
687
+ return;
688
+ }
689
+ const handler = () => {
690
+ signal.removeEventListener("abort", handler);
691
+ reject(new Error("PRELOAD_CANCELLED"));
692
+ };
693
+ signal.addEventListener("abort", handler);
694
+ });
695
+ try {
696
+ // Check if stream already exists and is valid
697
+ const existingStream = this.streamManager.getStreamByTrack(track.id || track.title);
698
+ if (existingStream && !existingStream.destroyed && existingStream.readable !== false) {
699
+ this.debug(`[Stream] Using existing stream for preload: ${track.title}`);
700
+ return { stream: existingStream, type: "arbitrary" };
701
+ }
702
+ // Race between stream fetch and abort
703
+ const streamPromise = this.getStream(track);
704
+ const result = await Promise.race([streamPromise, abortPromise]);
705
+ return result;
706
+ }
707
+ catch (err) {
708
+ if (err instanceof Error && err.message === "PRELOAD_CANCELLED") {
709
+ throw err;
710
+ }
711
+ throw err;
712
+ }
713
+ }
714
+ /**
715
+ * Preload next track with proper error handling and cleanup
716
+ */
717
+ async preloadNext() {
718
+ if (!this.preloadEnabled) {
719
+ this.debug(`[Preload] Disabled by options/runtime profile`);
720
+ return;
721
+ }
722
+ this.cancelPreload();
723
+ const next = this.queue.nextTrack;
724
+ if (!next || this.isPreloading) {
725
+ this.debug(`[Preload] Skipped - ${!next ? "no next track" : "already preloading"}`);
726
+ return;
727
+ }
728
+ this.isPreloading = true;
729
+ // Create new AbortController
730
+ const abortController = new AbortController();
731
+ const timeoutId = setTimeout(() => {
732
+ // this.debug(`[Preload] Timeout for track: ${next.title}`);
733
+ // abortController.abort();
734
+ }, 30000);
735
+ this.preloadState.abortController = abortController;
736
+ this.preloadState.timeoutId = timeoutId;
737
+ try {
738
+ this.debug(`[Preload] Starting preload for: ${next.title}`);
739
+ // Check if already aborted
740
+ if (abortController.signal.aborted) {
741
+ throw new Error("Preload aborted before start");
742
+ }
743
+ // Check if this track is still the next one
744
+ if (this.queue.nextTrack?.id !== next.id) {
745
+ this.debug(`[Preload] Track changed, cancelling preload`);
746
+ return;
747
+ }
748
+ const streamInfo = await this.getStreamWithCancel(next, abortController.signal);
749
+ // Double check
750
+ if (abortController.signal.aborted) {
751
+ throw new Error("Preload aborted after stream fetch");
752
+ }
753
+ if (this.queue.nextTrack?.id !== next.id) {
754
+ this.debug(`[Preload] Track changed after stream fetch`);
755
+ return;
756
+ }
757
+ if (!streamInfo?.stream) {
758
+ throw new Error(`No stream available`);
759
+ }
760
+ // Register with StreamManager
761
+ const streamId = this.streamManager.registerStream(streamInfo.stream, next, {
762
+ source: next.source || "preload",
763
+ isPreload: true,
764
+ priority: 8,
765
+ });
766
+ // Create resource
767
+ const resource = (0, voice_1.createAudioResource)(streamInfo.stream, {
768
+ inlineVolume: true,
769
+ metadata: { ...next, preloaded: true },
770
+ });
771
+ // Store preload state
772
+ this.preloadState = {
773
+ resource,
774
+ track: next,
775
+ abortController,
776
+ timeoutId,
777
+ isValid: true,
778
+ isBeingUsed: false,
779
+ streamId,
780
+ };
781
+ this.debug(`[Preload] Successfully preloaded: ${next.title} (Stream ID: ${streamId})`);
782
+ }
783
+ catch (err) {
784
+ if (err instanceof Error && err.message.includes("aborted")) {
785
+ this.debug(`[Preload] Cancelled for ${next.title}`);
786
+ }
787
+ else {
788
+ this.debug(`[Preload] Failed for ${next?.title}:`, err);
789
+ }
790
+ this.cancelPreload();
791
+ }
792
+ finally {
793
+ this.isPreloading = false;
794
+ }
795
+ }
796
+ async fadeResourceVolume(resource, from, to, durationMs) {
797
+ if (!resource.volume)
798
+ return;
799
+ const safeDuration = Math.max(0, durationMs);
800
+ if (safeDuration === 0) {
801
+ resource.volume.setVolume(to);
802
+ return;
803
+ }
804
+ const steps = Math.max(1, Math.floor(safeDuration / 50));
805
+ const stepDuration = Math.max(20, Math.floor(safeDuration / steps));
806
+ const delta = (to - from) / steps;
807
+ resource.volume.setVolume(from);
808
+ for (let i = 1; i <= steps; i++) {
809
+ await new Promise((resolve) => setTimeout(resolve, stepDuration));
810
+ resource.volume.setVolume(from + delta * i);
811
+ }
812
+ }
813
+ async applyCrossfadeIn(resource, track) {
814
+ if (!this.crossfadeEnabled || !resource.volume)
815
+ return;
816
+ const targetVolume = this.getTrackTargetVolume(track);
817
+ const transitionMs = this.resolveSmartTransitionDuration(track);
818
+ await this.fadeResourceVolume(resource, 0, targetVolume, transitionMs);
819
+ }
820
+ async applyCrossfadeOutCurrent() {
821
+ if (!this.crossfadeEnabled)
822
+ return;
823
+ const current = this.currentSlot.resource || this.currentResource;
824
+ if (!current?.volume)
825
+ return;
826
+ const currentVolume = current.volume.volume ?? this.volume / 100;
827
+ const currentTrack = this.queue.currentTrack;
828
+ const transitionMs = currentTrack ? this.resolveSmartTransitionDuration(currentTrack) : this.resolveSmartTransitionDuration({});
829
+ await this.fadeResourceVolume(current, currentVolume, 0, transitionMs);
830
+ }
831
+ async crossfadeSkipAndStop() {
832
+ if (!this.crossfadeEnabled) {
833
+ this.audioPlayer.stop();
834
+ return;
835
+ }
836
+ if (this.crossfadeTransitionLock) {
837
+ return;
838
+ }
839
+ this.crossfadeTransitionLock = true;
840
+ try {
841
+ await this.applyCrossfadeOutCurrent();
842
+ this.audioPlayer.stop();
843
+ }
844
+ finally {
845
+ this.crossfadeTransitionLock = false;
846
+ }
847
+ }
848
+ getTrackMetadataValue(track, key) {
849
+ const md = track?.metadata;
850
+ if (!md)
851
+ return undefined;
852
+ return md[key];
853
+ }
854
+ resolveSmartTransitionDuration(track) {
855
+ if (!this.smartTransitionEnabled) {
856
+ return this.crossfadeDurationMs;
857
+ }
858
+ let duration = this.smartTransitionBaseMs;
859
+ if (this.smartTransitionGenreAware) {
860
+ const rawGenre = this.getTrackMetadataValue(track, "genre");
861
+ const genre = typeof rawGenre === "string" ? rawGenre.toLowerCase().trim() : "";
862
+ if (genre && this.smartTransitionGenreDurations[genre] !== undefined) {
863
+ duration = this.smartTransitionGenreDurations[genre];
864
+ }
865
+ }
866
+ return Math.min(this.smartTransitionMaxMs, Math.max(this.smartTransitionMinMs, duration));
867
+ }
868
+ async maybeAlignToBeatBoundary() {
869
+ if (!this.smartTransitionEnabled || !this.smartTransitionBeatAlign)
870
+ return;
871
+ const currentTrack = this.queue.currentTrack;
872
+ if (!currentTrack || !this.currentResource)
873
+ return;
874
+ const bpmRaw = this.getTrackMetadataValue(currentTrack, "bpm");
875
+ const bpm = typeof bpmRaw === "number" ? bpmRaw : Number(bpmRaw);
876
+ if (!Number.isFinite(bpm) || bpm <= 0)
877
+ return;
878
+ const beatMs = 60000 / bpm;
879
+ const positionMs = this.currentResource.playbackDuration;
880
+ const remainder = positionMs % beatMs;
881
+ const waitMs = beatMs - remainder;
882
+ if (waitMs > 0 && waitMs <= this.smartTransitionBeatAlignMaxWaitMs) {
883
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
884
+ }
885
+ }
886
+ getTrackTargetVolume(track) {
887
+ const base = this.volume / 100;
888
+ if (!this.loudnessNormalizationEnabled) {
889
+ return base;
890
+ }
891
+ const lufsRaw = this.getTrackMetadataValue(track, "lufs");
892
+ const trackLufs = typeof lufsRaw === "number" ? lufsRaw : Number(lufsRaw);
893
+ if (!Number.isFinite(trackLufs)) {
894
+ return Math.min(base, this.loudnessLimiterCeiling);
895
+ }
896
+ const deltaDbRaw = this.loudnessTargetLUFS - trackLufs;
897
+ const maxBoost = this.loudnessMaxBoostDb;
898
+ const maxCut = this.loudnessMaxCutDb;
899
+ const deltaDb = Math.max(-maxCut, Math.min(maxBoost, deltaDbRaw));
900
+ const multiplier = Math.pow(10, deltaDb / 20);
901
+ const adjusted = base * multiplier;
902
+ return Math.min(this.loudnessLimiterCeiling, Math.max(0, adjusted));
903
+ }
904
+ async attemptTrackRecovery(track, reason) {
905
+ if (!this.antiStuckEnabled)
906
+ return false;
907
+ this.debug(`[AntiStuck] Recovery started for: ${track.title}`, reason);
908
+ const originalQuality = this.options.quality;
909
+ let attempted = 0;
910
+ while (attempted < this.antiStuckMaxRetries) {
911
+ attempted++;
912
+ if (this.antiStuckReduceQualityOnRetry) {
913
+ this.options.quality = "low";
914
+ }
915
+ if (this.antiStuckRetryDelayMs > 0) {
916
+ await new Promise((resolve) => setTimeout(resolve, this.antiStuckRetryDelayMs));
917
+ }
918
+ try {
919
+ if (this.antiStuckReusePreloadFirst && this.preloadSlot.isValid && this.preloadSlot.track?.id === track.id) {
920
+ const startedFromPreload = await this.startTrack(track);
921
+ if (startedFromPreload) {
922
+ this.antiStuckConsecutiveFailures = 0;
923
+ this.options.quality = originalQuality;
924
+ return true;
925
+ }
926
+ }
927
+ const started = await this.loadFreshStream(track);
928
+ if (started) {
929
+ this.antiStuckConsecutiveFailures = 0;
930
+ this.options.quality = originalQuality;
931
+ return true;
932
+ }
933
+ }
934
+ catch (error) {
935
+ this.debug(`[AntiStuck] Retry ${attempted} failed for ${track.title}:`, error);
936
+ }
937
+ }
938
+ this.options.quality = originalQuality;
939
+ this.antiStuckConsecutiveFailures++;
940
+ if (this.antiStuckConsecutiveFailures >= this.antiStuckControlledSkipThreshold) {
941
+ this.debug(`[AntiStuck] Controlled skip threshold reached for ${track.title}`);
942
+ return false;
943
+ }
944
+ // Avoid hard skip storm by leaving track for next natural retry window.
945
+ this.debug(`[AntiStuck] Keeping track for controlled retry window: ${track.title}`);
946
+ return false;
947
+ }
948
+ /**
949
+ * Clear preloaded resource with proper cleanup
950
+ */
951
+ clearPreload() {
952
+ // Abort ongoing preload
953
+ if (this.preloadState.abortController) {
954
+ this.preloadState.abortController.abort();
955
+ this.preloadState.abortController = null;
956
+ }
957
+ // Clean up stream
958
+ const stream = this.preloadState.stream;
959
+ if (stream && typeof stream.destroy === "function") {
960
+ try {
961
+ stream.destroy();
962
+ }
963
+ catch (err) {
964
+ this.debug(`[Preload] Error destroying stream:`, err);
965
+ }
966
+ }
967
+ // Clean up resource
968
+ if (this.preloadState.resource) {
969
+ try {
970
+ const playStream = this.preloadState.resource.playStream;
971
+ if (playStream && typeof playStream.destroy === "function") {
972
+ playStream.destroy();
973
+ }
974
+ }
975
+ catch (err) {
976
+ this.debug(`[Preload] Error destroying resource:`, err);
977
+ }
978
+ }
979
+ this.preloadState = {
980
+ resource: null,
981
+ track: null,
982
+ abortController: null,
983
+ timeoutId: null,
984
+ isValid: false,
985
+ isBeingUsed: false,
986
+ streamId: undefined,
987
+ };
988
+ }
989
+ /**
990
+ * Cancel preload (when skipping or stopping)
991
+ */
992
+ cancelPreload() {
993
+ if (this.preloadSlot.abortController) {
994
+ this.debug(`[Preload] Cancelling preload for: ${this.preloadSlot.track?.title}`);
995
+ this.preloadSlot.abortController.abort();
996
+ }
997
+ if (this.preloadSlot.streamId && this.streamManager) {
998
+ this.streamManager.unregisterStream(this.preloadSlot.streamId, true);
999
+ }
1000
+ this.clearSlot(this.preloadSlot);
1001
+ }
1002
+ /**
1003
+ * Clear a stream slot
1004
+ */
1005
+ clearSlot(slot) {
1006
+ if (slot.resource) {
1007
+ try {
1008
+ const stream = slot.resource.playStream;
1009
+ if (stream && typeof stream.destroy === "function" && !stream.destroyed) {
1010
+ stream.destroy();
1011
+ }
1012
+ }
1013
+ catch (err) {
1014
+ // Ignore
1015
+ }
1016
+ }
1017
+ if (slot.streamId && this.streamManager) {
1018
+ // Don't wait for unregister
1019
+ this.streamManager.unregisterStream(slot.streamId, true);
1020
+ }
1021
+ slot.resource = null;
1022
+ slot.track = null;
1023
+ slot.streamId = null;
1024
+ slot.abortController = null;
1025
+ slot.isValid = false;
1026
+ slot.isLoading = false;
1027
+ slot.loadPromise = null;
1028
+ }
1029
+ /**
1030
+ * Promote preload slot to current slot without destroying promoted stream.
1031
+ */
1032
+ promotePreloadToCurrent(track) {
1033
+ const promotedResource = this.preloadSlot.resource;
1034
+ const promotedStreamId = this.preloadSlot.streamId;
1035
+ // Move ownership to current slot.
1036
+ this.currentSlot.resource = promotedResource;
1037
+ this.currentSlot.track = track;
1038
+ this.currentSlot.streamId = promotedStreamId;
1039
+ this.currentSlot.abortController = null;
1040
+ this.currentSlot.isValid = !!promotedResource;
1041
+ this.currentSlot.isLoading = false;
1042
+ this.currentSlot.loadPromise = null;
1043
+ this.currentResource = promotedResource;
1044
+ // Reset preload slot only (do not destroy promoted resource/stream).
1045
+ this.preloadSlot.resource = null;
1046
+ this.preloadSlot.track = null;
1047
+ this.preloadSlot.streamId = null;
1048
+ this.preloadSlot.abortController = null;
1049
+ this.preloadSlot.isValid = false;
1050
+ this.preloadSlot.isLoading = false;
1051
+ this.preloadSlot.loadPromise = null;
1052
+ }
437
1053
  /**
438
1054
  * Create AudioResource with filters and seek applied
439
1055
  *
@@ -482,12 +1098,40 @@ class Player extends events_1.EventEmitter {
482
1098
  }
483
1099
  }
484
1100
  async getStream(track) {
1101
+ const trackId = track.id || track.url || track.title;
1102
+ const existingStream = this.streamManager.getStreamByTrack(trackId);
1103
+ if (existingStream && !existingStream.destroyed) {
1104
+ this.debug(`[Stream] Using existing stream from manager for: ${track.title}`);
1105
+ return { stream: existingStream, type: "arbitrary" };
1106
+ }
485
1107
  let stream = await this.extensionManager.provideStream(track);
486
- if (stream?.stream)
1108
+ if (stream?.stream) {
1109
+ // Register with StreamManager
1110
+ const streamId = this.streamManager.registerStream(stream.stream, track, {
1111
+ source: "extension",
1112
+ isPreload: false,
1113
+ priority: 10,
1114
+ });
1115
+ this.debug(`[Stream] Extension stream registered with ID: ${streamId}`);
487
1116
  return stream;
1117
+ }
488
1118
  stream = await this.pluginManager.getStream(track);
489
- if (stream?.stream)
1119
+ if (stream?.stream) {
1120
+ const existingAgain = this.streamManager.getStreamByTrack(trackId);
1121
+ if (existingAgain && !existingAgain.destroyed) {
1122
+ if (stream.stream.destroy)
1123
+ stream.stream.destroy();
1124
+ return { stream: existingAgain, type: "arbitrary" };
1125
+ }
1126
+ // Register with StreamManager
1127
+ const streamId = this.streamManager.registerStream(stream.stream, track, {
1128
+ source: track.source || "plugin",
1129
+ isPreload: false,
1130
+ priority: 5,
1131
+ });
1132
+ this.debug(`[Stream] Plugin stream registered with ID: ${streamId}`);
490
1133
  return stream;
1134
+ }
491
1135
  throw new Error(`No stream available for track: ${track.title}`);
492
1136
  }
493
1137
  /**
@@ -495,59 +1139,48 @@ class Player extends events_1.EventEmitter {
495
1139
  */
496
1140
  async startTrack(track) {
497
1141
  try {
498
- let streamInfo = await this.getStream(track);
499
- this.debug(`[Player] Using stream for track: ${track.title}`);
500
- // Kiểm tra nếu có stream thực sự để tạo AudioResource
501
- if (streamInfo && streamInfo.stream) {
502
- try {
503
- // Destroy the old stream and resource before creating a new one
504
- this.destroyCurrentStream();
505
- this.currentResource = await this.createResource(streamInfo, track, 0);
506
- if (this.volumeInterval) {
507
- clearInterval(this.volumeInterval);
508
- this.volumeInterval = null;
509
- }
510
- this.currentResource.volume?.setVolume(this.volume / 100);
511
- this.debug(`[Player] Playing resource for track: ${track.title}`);
512
- this.audioPlayer.play(this.currentResource);
513
- await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 5_000);
514
- return true;
1142
+ // Try to use preloaded resource
1143
+ if (this.preloadSlot.isValid &&
1144
+ this.preloadSlot.track?.id === track.id &&
1145
+ this.preloadSlot.resource &&
1146
+ this.preloadSlot.resource.playStream?.readable !== false) {
1147
+ this.debug(`[Player] Using preloaded stream for: ${track.title}`);
1148
+ // Stop current playback
1149
+ this.audioPlayer.stop(true);
1150
+ // Clean up old current stream (but delay to be safe)
1151
+ const oldStreamId = this.currentSlot.streamId;
1152
+ if (oldStreamId && this.streamManager) {
1153
+ setTimeout(() => {
1154
+ if (this.currentSlot.streamId === oldStreamId) {
1155
+ this.streamManager.unregisterStream(oldStreamId, true);
1156
+ }
1157
+ }, 3000);
515
1158
  }
516
- catch (resourceError) {
517
- this.debug(`[Player] Error creating/playing resource:`, resourceError);
518
- // Try fallback without filters
519
- try {
520
- this.debug(`[Player] Attempting fallback without filters`);
521
- const fallbackResource = (0, voice_1.createAudioResource)(streamInfo.stream, {
522
- metadata: track,
523
- inputType: streamInfo.type === "webm/opus" ? voice_1.StreamType.WebmOpus
524
- : streamInfo.type === "ogg/opus" ? voice_1.StreamType.OggOpus
525
- : voice_1.StreamType.Arbitrary,
526
- inlineVolume: true,
527
- });
528
- this.currentResource = fallbackResource;
529
- this.currentResource.volume?.setVolume(this.volume / 100);
530
- this.audioPlayer.play(this.currentResource);
531
- await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 5_000);
532
- return true;
533
- }
534
- catch (fallbackError) {
535
- this.debug(`[Player] Fallback also failed:`, fallbackError);
536
- throw fallbackError;
537
- }
1159
+ // Set current slot from preload
1160
+ this.promotePreloadToCurrent(track);
1161
+ const currentResource = this.currentSlot.resource;
1162
+ if (!currentResource) {
1163
+ return false;
538
1164
  }
539
- }
540
- else if (streamInfo && !streamInfo.stream) {
541
- // Extension đang xử lý phát nhạc (như Lavalink) - chỉ đánh dấu đang phát
542
- this.debug(`[Player] Extension is handling playback for track: ${track.title}`);
543
- this.isPlaying = true;
544
- this.isPaused = false;
545
- this.emit("trackStart", track);
1165
+ const targetVolume = this.getTrackTargetVolume(track);
1166
+ // Apply volume
1167
+ if (currentResource.volume) {
1168
+ currentResource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
1169
+ }
1170
+ // Play
1171
+ await this.maybeAlignToBeatBoundary();
1172
+ this.audioPlayer.play(currentResource);
1173
+ await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 10_000);
1174
+ await this.applyCrossfadeIn(currentResource, track);
1175
+ // Start preloading next track (async, don't await)
1176
+ this.preloadNextTrack().catch((err) => {
1177
+ this.debug(`[Player] Preload error:`, err);
1178
+ });
546
1179
  return true;
547
1180
  }
548
- else {
549
- throw new Error(`No stream available for track: ${track.title}`);
550
- }
1181
+ // No valid preload, load fresh
1182
+ this.debug(`[Player] No preload available, loading fresh: ${track.title}`);
1183
+ return await this.loadFreshStream(track);
551
1184
  }
552
1185
  catch (error) {
553
1186
  this.debug(`[Player] startTrack error:`, error);
@@ -555,8 +1188,113 @@ class Player extends events_1.EventEmitter {
555
1188
  return false;
556
1189
  }
557
1190
  }
1191
+ /**
1192
+ * Swap preload slot to current slot
1193
+ */
1194
+ async swapToCurrent(track) {
1195
+ // Store preload resource
1196
+ const newResource = this.preloadSlot.resource;
1197
+ const oldStreamId = this.currentSlot.streamId;
1198
+ if (!newResource) {
1199
+ return false;
1200
+ }
1201
+ // Stop current playback
1202
+ this.audioPlayer.stop(true);
1203
+ // Clean up old current stream (but keep it for a moment)
1204
+ if (oldStreamId && this.streamManager) {
1205
+ // Delay cleanup to avoid destroying if still needed
1206
+ setTimeout(() => {
1207
+ if (this.currentSlot.streamId === oldStreamId) {
1208
+ this.streamManager.unregisterStream(oldStreamId, true);
1209
+ }
1210
+ }, 5000);
1211
+ }
1212
+ // Set new current
1213
+ this.promotePreloadToCurrent(track);
1214
+ const currentResource = this.currentSlot.resource;
1215
+ if (!currentResource) {
1216
+ return false;
1217
+ }
1218
+ const targetVolume = this.getTrackTargetVolume(track);
1219
+ // Apply volume
1220
+ if (currentResource.volume) {
1221
+ currentResource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
1222
+ }
1223
+ // Play
1224
+ await this.maybeAlignToBeatBoundary();
1225
+ this.audioPlayer.play(currentResource);
1226
+ try {
1227
+ await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 10_000);
1228
+ await this.applyCrossfadeIn(currentResource, track);
1229
+ // Start preloading next track
1230
+ this.preloadNextTrack().catch((err) => {
1231
+ this.debug(`[Player] Preload error:`, err);
1232
+ });
1233
+ return true;
1234
+ }
1235
+ catch (err) {
1236
+ this.debug(`[Player] Failed to play swapped track:`, err);
1237
+ return false;
1238
+ }
1239
+ }
1240
+ /**
1241
+ * Load fresh stream when no preload available
1242
+ */
1243
+ async loadFreshStream(track) {
1244
+ // Cancel preload to free resources
1245
+ await this.safeCancelPreload();
1246
+ try {
1247
+ const streamInfo = await this.getStream(track);
1248
+ if (!streamInfo?.stream) {
1249
+ throw new Error(`No stream available`);
1250
+ }
1251
+ // Register with StreamManager
1252
+ const streamId = this.streamManager.registerStream(streamInfo.stream, track, {
1253
+ source: track.source || "stream",
1254
+ isPreload: false,
1255
+ priority: 10,
1256
+ });
1257
+ // Create resource
1258
+ const resource = await this.createResource(streamInfo, track, 0);
1259
+ // Clean up old current
1260
+ if (this.currentSlot.streamId && this.currentSlot.streamId !== streamId) {
1261
+ this.streamManager.unregisterStream(this.currentSlot.streamId, true);
1262
+ }
1263
+ // Set current slot
1264
+ this.currentSlot.resource = resource;
1265
+ this.currentSlot.track = track;
1266
+ this.currentSlot.streamId = streamId;
1267
+ this.currentSlot.isValid = true;
1268
+ this.currentResource = resource;
1269
+ // Apply volume
1270
+ const targetVolume = this.getTrackTargetVolume(track);
1271
+ if (resource.volume) {
1272
+ resource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
1273
+ }
1274
+ // Play
1275
+ await this.maybeAlignToBeatBoundary();
1276
+ this.audioPlayer.stop(true);
1277
+ this.audioPlayer.play(resource);
1278
+ await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 10_000);
1279
+ await this.applyCrossfadeIn(resource, track);
1280
+ // Preload next (async)
1281
+ this.preloadNextTrack().catch((err) => {
1282
+ this.debug(`[Player] Preload error:`, err);
1283
+ });
1284
+ return true;
1285
+ }
1286
+ catch (error) {
1287
+ this.debug(`[Player] loadFreshStream error:`, error);
1288
+ throw error;
1289
+ }
1290
+ }
1291
+ /**
1292
+ * Play the next track in the queue, handling errors and edge cases gracefully
1293
+ */
558
1294
  async playNext() {
559
- this.debug(`[Player] playNext called by ${new Error().stack?.split("\n")[2]?.trim()}`);
1295
+ this.debug("[Player] playNext called");
1296
+ // Don't cancel preload here unless absolutely necessary
1297
+ // Let startTrack handle it
560
1298
  while (true) {
561
1299
  const track = this.queue.next(this.skipLoop);
562
1300
  this.skipLoop = false;
@@ -571,20 +1309,47 @@ class Player extends events_1.EventEmitter {
571
1309
  this.debug(`[Player] No next track in queue`);
572
1310
  this.isPlaying = false;
573
1311
  this.emit("queueEnd");
1312
+ // Clean up both slots when queue is empty
1313
+ this.clearSlot(this.currentSlot);
1314
+ await this.safeCancelPreload();
574
1315
  if (this.options.leaveOnEnd) {
575
1316
  this.scheduleLeave();
576
1317
  }
577
1318
  return false;
578
1319
  }
579
- this.generateWillNext();
1320
+ this.generateWillNext().catch((err) => this.debug("[Player] generateWillNext error:", err));
580
1321
  this.clearLeaveTimeout();
581
1322
  this.debug(`[Player] playNext called for track: ${track.title}`);
582
1323
  try {
583
- return await this.startTrack(track);
1324
+ const started = await this.startTrack(track);
1325
+ if (started) {
1326
+ this.antiStuckConsecutiveFailures = 0;
1327
+ return true;
1328
+ }
1329
+ const recovered = await this.attemptTrackRecovery(track, new Error("TRACK_START_RETURNED_FALSE"));
1330
+ if (recovered) {
1331
+ return true;
1332
+ }
1333
+ if (this.antiStuckEnabled && this.antiStuckConsecutiveFailures < this.antiStuckControlledSkipThreshold) {
1334
+ this.queue.insert(track, 0);
1335
+ if (this.antiStuckRetryDelayMs > 0) {
1336
+ await new Promise((resolve) => setTimeout(resolve, this.antiStuckRetryDelayMs));
1337
+ }
1338
+ }
584
1339
  }
585
1340
  catch (err) {
586
1341
  this.debug(`[Player] playNext error:`, err);
587
1342
  this.emit("playerError", err, track);
1343
+ const recovered = await this.attemptTrackRecovery(track, err);
1344
+ if (recovered) {
1345
+ return true;
1346
+ }
1347
+ if (this.antiStuckEnabled && this.antiStuckConsecutiveFailures < this.antiStuckControlledSkipThreshold) {
1348
+ this.queue.insert(track, 0);
1349
+ if (this.antiStuckRetryDelayMs > 0) {
1350
+ await new Promise((resolve) => setTimeout(resolve, this.antiStuckRetryDelayMs));
1351
+ }
1352
+ }
588
1353
  continue;
589
1354
  }
590
1355
  }
@@ -624,12 +1389,12 @@ class Player extends events_1.EventEmitter {
624
1389
  // Build resource from plugin stream
625
1390
  const streamInfo = await this.pluginManager.getStream(track);
626
1391
  if (!streamInfo) {
627
- throw new Error("No stream available for track: ${track.title}");
1392
+ throw new Error(`No stream available for track: ${track.title}`);
628
1393
  }
629
1394
  ttsStream = streamInfo.stream;
630
1395
  const resource = await this.createResource(streamInfo, track);
631
1396
  if (!resource) {
632
- throw new Error("No resource available for track: ${track.title}");
1397
+ throw new Error(`No resource available for track: ${track.title}`);
633
1398
  }
634
1399
  ttsResource = resource;
635
1400
  if (resource.volume) {
@@ -656,7 +1421,7 @@ class Player extends events_1.EventEmitter {
656
1421
  declared
657
1422
  : declared * 1000
658
1423
  : undefined;
659
- const cap = this.options?.tts?.Max_Time_TTS ?? 60_000;
1424
+ const cap = this.options?.tts?.maxTimeTts ?? 60_000;
660
1425
  const idleTimeout = declaredMs ? Math.min(cap, Math.max(1_000, declaredMs + 1_500)) : cap;
661
1426
  await (0, voice_1.entersState)(ttsPlayer, voice_1.AudioPlayerStatus.Idle, idleTimeout).catch(() => null);
662
1427
  // Swap back and resume if needed
@@ -707,9 +1472,21 @@ class Player extends events_1.EventEmitter {
707
1472
  });
708
1473
  await (0, voice_1.entersState)(connection, voice_1.VoiceConnectionStatus.Ready, 50_000);
709
1474
  this.connection = connection;
710
- connection.on(voice_1.VoiceConnectionStatus.Disconnected, () => {
711
- this.debug(`[Player] VoiceConnectionStatus.Disconnected`);
712
- this.destroy();
1475
+ connection.on(voice_1.VoiceConnectionStatus.Disconnected, async () => {
1476
+ try {
1477
+ // move channel
1478
+ await Promise.race([
1479
+ (0, voice_1.entersState)(connection, voice_1.VoiceConnectionStatus.Signalling, 5_000),
1480
+ (0, voice_1.entersState)(connection, voice_1.VoiceConnectionStatus.Connecting, 5_000),
1481
+ ]);
1482
+ // Signalling/Connecting → reconnect
1483
+ this.debug(`[Player] Reconnecting after channel move...`);
1484
+ }
1485
+ catch {
1486
+ // no reconnect in 5 giây → disconnect
1487
+ this.debug(`[Player] Truly disconnected, destroying player`);
1488
+ this.destroy();
1489
+ }
713
1490
  });
714
1491
  connection.on("error", (error) => {
715
1492
  this.debug(`[Player] Voice connection error:`, error);
@@ -757,7 +1534,7 @@ class Player extends events_1.EventEmitter {
757
1534
  const track = this.queue.currentTrack;
758
1535
  if (track) {
759
1536
  this.debug(`[Player] Player resumed on track: ${track.title}`);
760
- this.emit("playerResume", track);
1537
+ // this.emit("playerResume", track); //đã có trong stateChange
761
1538
  }
762
1539
  }
763
1540
  return result;
@@ -774,6 +1551,8 @@ class Player extends events_1.EventEmitter {
774
1551
  */
775
1552
  stop() {
776
1553
  this.debug(`[Player] stop called`);
1554
+ // Cancel preload when stopping
1555
+ this.cancelPreload();
777
1556
  this.queue.clear();
778
1557
  const result = this.audioPlayer.stop();
779
1558
  this.destroyCurrentStream();
@@ -808,12 +1587,7 @@ class Player extends events_1.EventEmitter {
808
1587
  this.debug(`[Player] Invalid seek position: ${position}ms (track duration: ${totalDuration}ms)`);
809
1588
  return false;
810
1589
  }
811
- const streaminfo = await this.getStream(track);
812
- if (!streaminfo?.stream) {
813
- this.debug(`[Player] No stream to seek`);
814
- return false;
815
- }
816
- await this.refeshPlayerResource(true, position);
1590
+ await this.refreshPlayerResource(true, position);
817
1591
  return true;
818
1592
  }
819
1593
  /**
@@ -830,26 +1604,23 @@ class Player extends events_1.EventEmitter {
830
1604
  this.debug(`[Player] skip called with index: ${index}`);
831
1605
  try {
832
1606
  if (typeof index === "number" && index >= 0) {
833
- // Skip to specific index
834
1607
  const targetTrack = this.queue.getTrack(index);
835
1608
  if (!targetTrack) {
836
1609
  this.debug(`[Player] No track found at index ${index}`);
837
1610
  return false;
838
1611
  }
839
- // Remove tracks from 0 to index-1
840
1612
  for (let i = 0; i < index; i++) {
841
1613
  this.queue.remove(0);
842
1614
  }
843
1615
  this.debug(`[Player] Skipped to track at index ${index}: ${targetTrack.title}`);
844
- if (this.isPlaying || this.isPaused) {
845
- this.skipLoop = true;
846
- return this.audioPlayer.stop();
847
- }
848
- return true;
849
1616
  }
850
1617
  if (this.isPlaying || this.isPaused) {
851
1618
  this.skipLoop = true;
852
- return this.audioPlayer.stop();
1619
+ void this.crossfadeSkipAndStop().catch((error) => {
1620
+ this.debug(`[Player] crossfade skip error:`, error);
1621
+ this.audioPlayer.stop();
1622
+ });
1623
+ return true;
853
1624
  }
854
1625
  return true;
855
1626
  }
@@ -919,7 +1690,7 @@ class Player extends events_1.EventEmitter {
919
1690
  saveOptions = options;
920
1691
  }
921
1692
  try {
922
- // Try extensions first
1693
+ // Skip extension manager for saving - we want the raw stream without filters/seek applied, and extensions may not support this
923
1694
  let streamInfo = await this.pluginManager.getStream(track);
924
1695
  if (!streamInfo || !streamInfo.stream) {
925
1696
  throw new Error(`No save stream available for track: ${track.title}`);
@@ -1133,21 +1904,96 @@ class Player extends events_1.EventEmitter {
1133
1904
  * @example
1134
1905
  * const progressBar = player.getProgressBar();
1135
1906
  * console.log(`Progress bar: ${progressBar}`);
1907
+ *
1908
+ * // Custom options
1909
+ * const customBar = player.getProgressBar({
1910
+ * size: 30,
1911
+ * barChar: "─",
1912
+ * progressChar: "●",
1913
+ * timeFormat: "compact" // "compact" = 1:22:12, "full" = 01:22:12
1914
+ * });
1136
1915
  */
1137
1916
  getProgressBar(options = {}) {
1138
- const { size = 20, barChar = "▬", progressChar = "🔘" } = options;
1917
+ const { size = 20, barChar = "▬", progressChar = "🔘", timeFormat = "compact", // "compact" or "full"
1918
+ showPercentage = false, showTime = true, } = options;
1139
1919
  const track = this.queue.currentTrack;
1140
1920
  const resource = this.currentResource;
1141
- if (!track || !resource)
1921
+ // Handle live stream
1922
+ if (this.isLive || !track || !resource) {
1923
+ if (this.isLive)
1924
+ return "🔴 LIVE";
1142
1925
  return "";
1926
+ }
1143
1927
  const total = track.duration > 1000 ? track.duration : track.duration * 1000;
1144
1928
  if (!total)
1145
- return this.formatTime(resource.playbackDuration);
1929
+ return this.formatTimeCompact(resource.playbackDuration);
1146
1930
  const current = resource.playbackDuration;
1147
- const ratio = Math.min(current / total, 1);
1931
+ const ratio = Math.min(Math.max(current / total, 0), 1);
1148
1932
  const progress = Math.round(ratio * size);
1149
- const bar = barChar.repeat(progress) + progressChar + barChar.repeat(size - progress);
1150
- return `${this.formatTime(current)} | ${bar} | ${this.formatTime(total)}`;
1933
+ // Build progress bar
1934
+ let bar = "";
1935
+ if (progressChar === "none" || options.hideProgressChar) {
1936
+ // Continuous bar without separator
1937
+ const filled = barChar.repeat(progress);
1938
+ const empty = barChar.repeat(size - progress);
1939
+ bar = filled + empty;
1940
+ }
1941
+ else {
1942
+ // Bar with progress character
1943
+ const filled = barChar.repeat(progress);
1944
+ const empty = barChar.repeat(Math.max(0, size - progress));
1945
+ bar = filled + progressChar + empty;
1946
+ }
1947
+ // Format time based on option
1948
+ const formatTimeFn = timeFormat === "compact" ? this.formatTimeCompact.bind(this) : this.formatTime.bind(this);
1949
+ const currentTimeStr = formatTimeFn(current);
1950
+ const totalTimeStr = formatTimeFn(total);
1951
+ // Build result
1952
+ let result = "";
1953
+ if (showTime) {
1954
+ result = `${currentTimeStr} ${bar} ${totalTimeStr}`;
1955
+ }
1956
+ else {
1957
+ result = bar;
1958
+ }
1959
+ // Add percentage if requested
1960
+ if (showPercentage) {
1961
+ const percent = Math.round(ratio * 100);
1962
+ result += ` (${percent}%)`;
1963
+ }
1964
+ return result;
1965
+ }
1966
+ /**
1967
+ * Format time with leading zeros (00:00 or 00:00:00)
1968
+ * @param ms - Time in milliseconds
1969
+ * @returns Formatted time string with leading zeros
1970
+ */
1971
+ formatTime(ms) {
1972
+ const totalSeconds = Math.floor(ms / 1000);
1973
+ const hours = Math.floor(totalSeconds / 3600);
1974
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
1975
+ const seconds = totalSeconds % 60;
1976
+ const parts = [];
1977
+ if (hours > 0)
1978
+ parts.push(String(hours).padStart(2, "0"));
1979
+ parts.push(String(minutes).padStart(2, "0"));
1980
+ parts.push(String(seconds).padStart(2, "0"));
1981
+ return parts.join(":");
1982
+ }
1983
+ /**
1984
+ * Format time without leading zeros for hours (1:22:12 or 3:45)
1985
+ * @param ms - Time in milliseconds
1986
+ * @returns Compact formatted time string
1987
+ */
1988
+ formatTimeCompact(ms) {
1989
+ const totalSeconds = Math.floor(ms / 1000);
1990
+ const hours = Math.floor(totalSeconds / 3600);
1991
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
1992
+ const seconds = totalSeconds % 60;
1993
+ if (hours > 0) {
1994
+ return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
1995
+ }
1996
+ return `${minutes}:${String(seconds).padStart(2, "0")}`;
1151
1997
  }
1152
1998
  /**
1153
1999
  * Get the time of the current track
@@ -1156,44 +2002,44 @@ class Player extends events_1.EventEmitter {
1156
2002
  * @example
1157
2003
  * const time = player.getTime();
1158
2004
  * console.log(`Time: ${time.current}`);
2005
+ * console.log(`Formatted: ${time.formatted.current}`); // "1:22:12" or "3:45"
1159
2006
  */
1160
2007
  getTime() {
2008
+ if (this.isLive)
2009
+ return {
2010
+ current: 0,
2011
+ total: 0,
2012
+ format: "LIVE",
2013
+ formatted: {
2014
+ current: "LIVE",
2015
+ total: "LIVE",
2016
+ },
2017
+ };
1161
2018
  const resource = this.currentResource;
1162
2019
  const track = this.queue.currentTrack;
1163
- if (!track || !resource)
2020
+ if (!track || !resource) {
1164
2021
  return {
1165
2022
  current: 0,
1166
2023
  total: 0,
1167
2024
  format: "00:00",
2025
+ formatted: {
2026
+ current: "00:00",
2027
+ total: "00:00",
2028
+ },
1168
2029
  };
2030
+ }
1169
2031
  const total = track.duration > 1000 ? track.duration : track.duration * 1000;
2032
+ const current = resource.playbackDuration;
1170
2033
  return {
1171
- current: resource?.playbackDuration,
2034
+ current: current,
1172
2035
  total: total,
1173
- format: this.formatTime(resource.playbackDuration),
2036
+ format: this.formatTime(current),
2037
+ formatted: {
2038
+ current: this.formatTimeCompact(current),
2039
+ total: this.formatTimeCompact(total),
2040
+ },
1174
2041
  };
1175
2042
  }
1176
- /**
1177
- * Format the time in the format of HH:MM:SS
1178
- *
1179
- * @param {number} ms - The time in milliseconds
1180
- * @returns {string} The formatted time
1181
- * @example
1182
- * const formattedTime = player.formatTime(1000);
1183
- * console.log(`Formatted time: ${formattedTime}`);
1184
- */
1185
- formatTime(ms) {
1186
- const totalSeconds = Math.floor(ms / 1000);
1187
- const hours = Math.floor(totalSeconds / 3600);
1188
- const minutes = Math.floor((totalSeconds % 3600) / 60);
1189
- const seconds = totalSeconds % 60;
1190
- const parts = [];
1191
- if (hours > 0)
1192
- parts.push(String(hours).padStart(2, "0"));
1193
- parts.push(String(minutes).padStart(2, "0"));
1194
- parts.push(String(seconds).padStart(2, "0"));
1195
- return parts.join(":");
1196
- }
1197
2043
  /**
1198
2044
  * Destroy the player
1199
2045
  *
@@ -1207,10 +2053,14 @@ class Player extends events_1.EventEmitter {
1207
2053
  clearTimeout(this.leaveTimeout);
1208
2054
  this.leaveTimeout = null;
1209
2055
  }
2056
+ this.streamManager.destroyAll(true);
1210
2057
  // Destroy current stream before stopping audio
1211
2058
  this.destroyCurrentStream();
2059
+ this.clearSlot(this.currentSlot);
2060
+ this.clearSlot(this.preloadSlot);
1212
2061
  this.audioPlayer.removeAllListeners();
1213
2062
  this.audioPlayer.stop(true);
2063
+ this.clearPreload();
1214
2064
  if (this.ttsPlayer) {
1215
2065
  try {
1216
2066
  this.ttsPlayer.stop(true);
@@ -1243,7 +2093,7 @@ class Player extends events_1.EventEmitter {
1243
2093
  if (this.leaveTimeout) {
1244
2094
  clearTimeout(this.leaveTimeout);
1245
2095
  }
1246
- if (this.options.leaveOnEmpty && this.options.leaveTimeout) {
2096
+ if (this.options.leaveOnEnd && this.options.leaveTimeout) {
1247
2097
  this.leaveTimeout = setTimeout(() => {
1248
2098
  this.debug(`[Player] Leaving voice channel after timeoutMs`);
1249
2099
  this.destroy();
@@ -1257,13 +2107,16 @@ class Player extends events_1.EventEmitter {
1257
2107
  * @param {number} position - Position to seek to in milliseconds
1258
2108
  * @returns {Promise<boolean>}
1259
2109
  * @example
1260
- * const refreshed = await player.refeshPlayerResource(true, 1000);
2110
+ * const refreshed = await player.refreshPlayerResource(true, 1000);
1261
2111
  * console.log(`Refreshed: ${refreshed}`);
1262
2112
  */
1263
- async refeshPlayerResource(applyToCurrent = true, position = -1) {
2113
+ async refreshPlayerResource(applyToCurrent = true, position = -1) {
1264
2114
  if (!applyToCurrent || !this.queue.currentTrack || !(this.isPlaying || this.isPaused)) {
1265
2115
  return false;
1266
2116
  }
2117
+ if (this.refreshLock)
2118
+ return false;
2119
+ this.refreshLock = true;
1267
2120
  try {
1268
2121
  const track = this.queue.currentTrack;
1269
2122
  this.debug(`[Player] Refreshing player resource for track: ${track.title}`);
@@ -1290,7 +2143,10 @@ class Player extends events_1.EventEmitter {
1290
2143
  }
1291
2144
  }
1292
2145
  catch (error) {
1293
- this.debug(`[Player] Error destroying old stream in refeshPlayerResource:`, error);
2146
+ this.debug(`[Player] Error destroying old stream in refreshPlayerResource:`, error);
2147
+ }
2148
+ finally {
2149
+ this.refreshLock = false;
1294
2150
  }
1295
2151
  this.currentResource = resource;
1296
2152
  // Subscribe to new resource
@@ -1407,11 +2263,42 @@ class Player extends events_1.EventEmitter {
1407
2263
  }
1408
2264
  else if (newState.status === voice_1.AudioPlayerStatus.Buffering) {
1409
2265
  this.debug(`[Player] AudioPlayerStatus.Buffering`);
2266
+ this.lastDuration = this.currentResource?.playbackDuration || 0;
2267
+ this.stuckTimer = setTimeout(() => {
2268
+ if (this.currentResource?.playbackDuration === this.lastDuration) {
2269
+ this.emit("trackStuck", this.currentTrack);
2270
+ const stuckTrack = this.currentTrack;
2271
+ if (stuckTrack && this.antiStuckEnabled) {
2272
+ void this.attemptTrackRecovery(stuckTrack, new Error("TRACK_STUCK")).then((recovered) => {
2273
+ if (!recovered) {
2274
+ this.skip();
2275
+ }
2276
+ });
2277
+ return;
2278
+ }
2279
+ this.skip();
2280
+ }
2281
+ }, 10000);
2282
+ }
2283
+ else {
2284
+ if (this.stuckTimer) {
2285
+ clearTimeout(this.stuckTimer);
2286
+ this.stuckTimer = null;
2287
+ }
1410
2288
  }
1411
2289
  });
1412
2290
  this.audioPlayer.on("error", (error) => {
1413
2291
  this.debug(`[Player] AudioPlayer error:`, error);
1414
2292
  this.emit("playerError", error, this.queue.currentTrack || undefined);
2293
+ const track = this.queue.currentTrack;
2294
+ if (track && this.antiStuckEnabled) {
2295
+ void this.attemptTrackRecovery(track, error).then((recovered) => {
2296
+ if (!recovered) {
2297
+ this.playNext();
2298
+ }
2299
+ });
2300
+ return;
2301
+ }
1415
2302
  this.playNext();
1416
2303
  });
1417
2304
  this.audioPlayer.on("debug", (...args) => {
@@ -1419,6 +2306,20 @@ class Player extends events_1.EventEmitter {
1419
2306
  this.emit("debug", ...args);
1420
2307
  }
1421
2308
  });
2309
+ //stream Manager events
2310
+ this.streamManager.on("streamError", ({ streamId, error }) => {
2311
+ this.debug(`[StreamManager] Error for stream ${streamId}:`, error);
2312
+ this.emit("streamError", error, this.queue.currentTrack || null);
2313
+ });
2314
+ this.streamManager.on("streamRegistered", ({ streamId, track, metadata }) => {
2315
+ this.debug(`[StreamManager] Stream registered: ${track.title} (preload: ${metadata.isPreload})`);
2316
+ });
2317
+ this.streamManager.on("streamUnregistered", ({ streamId, track, reason }) => {
2318
+ this.debug(`[StreamManager] Stream unregistered: ${track.title} (reason: ${reason})`);
2319
+ });
2320
+ this.streamManager.on("debug", (...args) => {
2321
+ this.debug(...args);
2322
+ });
1422
2323
  }
1423
2324
  addPlugin(plugin) {
1424
2325
  this.debug(`[Player] Adding plugin: ${plugin.name}`);
@@ -1428,6 +2329,77 @@ class Player extends events_1.EventEmitter {
1428
2329
  this.debug(`[Player] Removing plugin: ${name}`);
1429
2330
  return this.pluginManager.unregister(name);
1430
2331
  }
2332
+ /**
2333
+ * Save the current session of the player, including queue, current track, position, volume, loop mode, auto-play mode, and active extensions/plugins.
2334
+ *
2335
+ * @returns {PlayerSession} The saved session data
2336
+ */
2337
+ saveSession() {
2338
+ return {
2339
+ guildId: this.guildId,
2340
+ currentTrack: this.currentTrack,
2341
+ position: this.currentResource?.playbackDuration || null,
2342
+ volume: this.volume,
2343
+ queue: this.queue.getTracks(),
2344
+ loopMode: this.queue.loop(),
2345
+ autoPlay: this.queue.autoPlay(),
2346
+ extensions: this.extensionManager.getAll().map((ext) => ext.name),
2347
+ plugins: this.pluginManager.getAll().map((plugin) => plugin.name),
2348
+ };
2349
+ }
2350
+ /**
2351
+ * Get serializable state (for manual persistence)
2352
+ */
2353
+ getSerializableState() {
2354
+ return {
2355
+ guildId: this.guildId,
2356
+ queue: this.queue.getTracks(),
2357
+ currentTrack: this.currentTrack,
2358
+ volume: this.volume,
2359
+ isPlaying: this.isPlaying,
2360
+ isPaused: this.isPaused,
2361
+ loopMode: this.queue.loop(),
2362
+ autoPlay: this.queue.autoPlay(),
2363
+ filters: this.filter.getFilterString(),
2364
+ timestamp: Date.now(),
2365
+ };
2366
+ }
2367
+ /**
2368
+ * Restore from saved state
2369
+ */
2370
+ async restoreState(state) {
2371
+ try {
2372
+ if (state.volume)
2373
+ this.setVolume(state.volume);
2374
+ if (state.loopMode)
2375
+ this.queue.loop(state.loopMode);
2376
+ if (typeof state.autoPlay === "boolean")
2377
+ this.queue.autoPlay(state.autoPlay);
2378
+ if (state.filters)
2379
+ await this.filter.applyFilters(state.filters.split(","));
2380
+ // Restore queue
2381
+ if (state.queue && Array.isArray(state.queue)) {
2382
+ this.queue.clear();
2383
+ this.queue.addMultiple(state.queue);
2384
+ }
2385
+ this.debug("[Player] State restored");
2386
+ return true;
2387
+ }
2388
+ catch (error) {
2389
+ this.debug("[Player] Failed to restore state:", error);
2390
+ return false;
2391
+ }
2392
+ }
2393
+ /**
2394
+ * Get stream manager stats
2395
+ */
2396
+ getStreamManagerStats() {
2397
+ return {
2398
+ metrics: this.streamManager.getMetrics(),
2399
+ stats: this.streamManager.getStats(),
2400
+ totalStreams: this.streamManager.getStreamCount(),
2401
+ };
2402
+ }
1431
2403
  //#endregion
1432
2404
  //#region Getters
1433
2405
  /**
@@ -1507,6 +2479,9 @@ class Player extends events_1.EventEmitter {
1507
2479
  get relatedTracks() {
1508
2480
  return this.queue.relatedTracks();
1509
2481
  }
2482
+ get isLive() {
2483
+ return this.currentTrack?.isLive === true;
2484
+ }
1510
2485
  }
1511
2486
  exports.Player = Player;
1512
2487
  //# sourceMappingURL=Player.js.map