ziplayer 0.2.7 → 0.3.1

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 +74 -8
  17. package/dist/plugins/index.d.ts.map +1 -1
  18. package/dist/plugins/index.js +657 -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 +158 -14
  27. package/dist/structures/Player.d.ts.map +1 -1
  28. package/dist/structures/Player.js +1175 -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 +809 -139
  54. package/src/structures/FilterManager.ts +3 -3
  55. package/src/structures/Player.ts +2810 -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,72 +1098,97 @@ 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
+ }
1135
+ if (!this.pluginManager.hasStreamCandidate(track)) {
1136
+ throw new Error(`UNRECOVERABLE_NO_PLUGIN:${track.title}`);
1137
+ }
491
1138
  throw new Error(`No stream available for track: ${track.title}`);
492
1139
  }
1140
+ isUnrecoverableStreamError(error) {
1141
+ if (!(error instanceof Error))
1142
+ return false;
1143
+ return error.message.startsWith("UNRECOVERABLE_NO_PLUGIN:");
1144
+ }
493
1145
  /**
494
1146
  * Start playing a specific track immediately, replacing the current resource.
495
1147
  */
496
1148
  async startTrack(track) {
497
1149
  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;
1150
+ // Try to use preloaded resource
1151
+ if (this.preloadSlot.isValid &&
1152
+ this.preloadSlot.track?.id === track.id &&
1153
+ this.preloadSlot.resource &&
1154
+ this.preloadSlot.resource.playStream?.readable !== false) {
1155
+ this.debug(`[Player] Using preloaded stream for: ${track.title}`);
1156
+ // Stop current playback
1157
+ this.audioPlayer.stop(true);
1158
+ // Clean up old current stream (but delay to be safe)
1159
+ const oldStreamId = this.currentSlot.streamId;
1160
+ if (oldStreamId && this.streamManager) {
1161
+ setTimeout(() => {
1162
+ if (this.currentSlot.streamId === oldStreamId) {
1163
+ this.streamManager.unregisterStream(oldStreamId, true);
1164
+ }
1165
+ }, 3000);
515
1166
  }
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
- }
1167
+ // Set current slot from preload
1168
+ this.promotePreloadToCurrent(track);
1169
+ const currentResource = this.currentSlot.resource;
1170
+ if (!currentResource) {
1171
+ return false;
538
1172
  }
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);
1173
+ const targetVolume = this.getTrackTargetVolume(track);
1174
+ // Apply volume
1175
+ if (currentResource.volume) {
1176
+ currentResource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
1177
+ }
1178
+ // Play
1179
+ await this.maybeAlignToBeatBoundary();
1180
+ this.audioPlayer.play(currentResource);
1181
+ await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 10_000);
1182
+ await this.applyCrossfadeIn(currentResource, track);
1183
+ // Start preloading next track (async, don't await)
1184
+ this.preloadNextTrack().catch((err) => {
1185
+ this.debug(`[Player] Preload error:`, err);
1186
+ });
546
1187
  return true;
547
1188
  }
548
- else {
549
- throw new Error(`No stream available for track: ${track.title}`);
550
- }
1189
+ // No valid preload, load fresh
1190
+ this.debug(`[Player] No preload available, loading fresh: ${track.title}`);
1191
+ return await this.loadFreshStream(track);
551
1192
  }
552
1193
  catch (error) {
553
1194
  this.debug(`[Player] startTrack error:`, error);
@@ -555,8 +1196,113 @@ class Player extends events_1.EventEmitter {
555
1196
  return false;
556
1197
  }
557
1198
  }
1199
+ /**
1200
+ * Swap preload slot to current slot
1201
+ */
1202
+ async swapToCurrent(track) {
1203
+ // Store preload resource
1204
+ const newResource = this.preloadSlot.resource;
1205
+ const oldStreamId = this.currentSlot.streamId;
1206
+ if (!newResource) {
1207
+ return false;
1208
+ }
1209
+ // Stop current playback
1210
+ this.audioPlayer.stop(true);
1211
+ // Clean up old current stream (but keep it for a moment)
1212
+ if (oldStreamId && this.streamManager) {
1213
+ // Delay cleanup to avoid destroying if still needed
1214
+ setTimeout(() => {
1215
+ if (this.currentSlot.streamId === oldStreamId) {
1216
+ this.streamManager.unregisterStream(oldStreamId, true);
1217
+ }
1218
+ }, 5000);
1219
+ }
1220
+ // Set new current
1221
+ this.promotePreloadToCurrent(track);
1222
+ const currentResource = this.currentSlot.resource;
1223
+ if (!currentResource) {
1224
+ return false;
1225
+ }
1226
+ const targetVolume = this.getTrackTargetVolume(track);
1227
+ // Apply volume
1228
+ if (currentResource.volume) {
1229
+ currentResource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
1230
+ }
1231
+ // Play
1232
+ await this.maybeAlignToBeatBoundary();
1233
+ this.audioPlayer.play(currentResource);
1234
+ try {
1235
+ await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 10_000);
1236
+ await this.applyCrossfadeIn(currentResource, track);
1237
+ // Start preloading next track
1238
+ this.preloadNextTrack().catch((err) => {
1239
+ this.debug(`[Player] Preload error:`, err);
1240
+ });
1241
+ return true;
1242
+ }
1243
+ catch (err) {
1244
+ this.debug(`[Player] Failed to play swapped track:`, err);
1245
+ return false;
1246
+ }
1247
+ }
1248
+ /**
1249
+ * Load fresh stream when no preload available
1250
+ */
1251
+ async loadFreshStream(track) {
1252
+ // Cancel preload to free resources
1253
+ await this.safeCancelPreload();
1254
+ try {
1255
+ const streamInfo = await this.getStream(track);
1256
+ if (!streamInfo?.stream) {
1257
+ throw new Error(`No stream available`);
1258
+ }
1259
+ // Register with StreamManager
1260
+ const streamId = this.streamManager.registerStream(streamInfo.stream, track, {
1261
+ source: track.source || "stream",
1262
+ isPreload: false,
1263
+ priority: 10,
1264
+ });
1265
+ // Create resource
1266
+ const resource = await this.createResource(streamInfo, track, 0);
1267
+ // Clean up old current
1268
+ if (this.currentSlot.streamId && this.currentSlot.streamId !== streamId) {
1269
+ this.streamManager.unregisterStream(this.currentSlot.streamId, true);
1270
+ }
1271
+ // Set current slot
1272
+ this.currentSlot.resource = resource;
1273
+ this.currentSlot.track = track;
1274
+ this.currentSlot.streamId = streamId;
1275
+ this.currentSlot.isValid = true;
1276
+ this.currentResource = resource;
1277
+ // Apply volume
1278
+ const targetVolume = this.getTrackTargetVolume(track);
1279
+ if (resource.volume) {
1280
+ resource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
1281
+ }
1282
+ // Play
1283
+ await this.maybeAlignToBeatBoundary();
1284
+ this.audioPlayer.stop(true);
1285
+ this.audioPlayer.play(resource);
1286
+ await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 10_000);
1287
+ await this.applyCrossfadeIn(resource, track);
1288
+ // Preload next (async)
1289
+ this.preloadNextTrack().catch((err) => {
1290
+ this.debug(`[Player] Preload error:`, err);
1291
+ });
1292
+ return true;
1293
+ }
1294
+ catch (error) {
1295
+ this.debug(`[Player] loadFreshStream error:`, error);
1296
+ throw error;
1297
+ }
1298
+ }
1299
+ /**
1300
+ * Play the next track in the queue, handling errors and edge cases gracefully
1301
+ */
558
1302
  async playNext() {
559
- this.debug(`[Player] playNext called by ${new Error().stack?.split("\n")[2]?.trim()}`);
1303
+ this.debug("[Player] playNext called");
1304
+ // Don't cancel preload here unless absolutely necessary
1305
+ // Let startTrack handle it
560
1306
  while (true) {
561
1307
  const track = this.queue.next(this.skipLoop);
562
1308
  this.skipLoop = false;
@@ -571,20 +1317,51 @@ class Player extends events_1.EventEmitter {
571
1317
  this.debug(`[Player] No next track in queue`);
572
1318
  this.isPlaying = false;
573
1319
  this.emit("queueEnd");
1320
+ // Clean up both slots when queue is empty
1321
+ this.clearSlot(this.currentSlot);
1322
+ await this.safeCancelPreload();
574
1323
  if (this.options.leaveOnEnd) {
575
1324
  this.scheduleLeave();
576
1325
  }
577
1326
  return false;
578
1327
  }
579
- this.generateWillNext();
1328
+ this.generateWillNext().catch((err) => this.debug("[Player] generateWillNext error:", err));
580
1329
  this.clearLeaveTimeout();
581
1330
  this.debug(`[Player] playNext called for track: ${track.title}`);
582
1331
  try {
583
- return await this.startTrack(track);
1332
+ const started = await this.startTrack(track);
1333
+ if (started) {
1334
+ this.antiStuckConsecutiveFailures = 0;
1335
+ return true;
1336
+ }
1337
+ const recovered = await this.attemptTrackRecovery(track, new Error("TRACK_START_RETURNED_FALSE"));
1338
+ if (recovered) {
1339
+ return true;
1340
+ }
1341
+ if (this.antiStuckEnabled && this.antiStuckConsecutiveFailures < this.antiStuckControlledSkipThreshold) {
1342
+ this.queue.insert(track, 0);
1343
+ if (this.antiStuckRetryDelayMs > 0) {
1344
+ await new Promise((resolve) => setTimeout(resolve, this.antiStuckRetryDelayMs));
1345
+ }
1346
+ }
584
1347
  }
585
1348
  catch (err) {
586
1349
  this.debug(`[Player] playNext error:`, err);
587
1350
  this.emit("playerError", err, track);
1351
+ if (this.isUnrecoverableStreamError(err)) {
1352
+ this.debug(`[Player] Skipping unrecoverable track (no plugin): ${track.title}`);
1353
+ continue;
1354
+ }
1355
+ const recovered = await this.attemptTrackRecovery(track, err);
1356
+ if (recovered) {
1357
+ return true;
1358
+ }
1359
+ if (this.antiStuckEnabled && this.antiStuckConsecutiveFailures < this.antiStuckControlledSkipThreshold) {
1360
+ this.queue.insert(track, 0);
1361
+ if (this.antiStuckRetryDelayMs > 0) {
1362
+ await new Promise((resolve) => setTimeout(resolve, this.antiStuckRetryDelayMs));
1363
+ }
1364
+ }
588
1365
  continue;
589
1366
  }
590
1367
  }
@@ -624,12 +1401,12 @@ class Player extends events_1.EventEmitter {
624
1401
  // Build resource from plugin stream
625
1402
  const streamInfo = await this.pluginManager.getStream(track);
626
1403
  if (!streamInfo) {
627
- throw new Error("No stream available for track: ${track.title}");
1404
+ throw new Error(`No stream available for track: ${track.title}`);
628
1405
  }
629
1406
  ttsStream = streamInfo.stream;
630
1407
  const resource = await this.createResource(streamInfo, track);
631
1408
  if (!resource) {
632
- throw new Error("No resource available for track: ${track.title}");
1409
+ throw new Error(`No resource available for track: ${track.title}`);
633
1410
  }
634
1411
  ttsResource = resource;
635
1412
  if (resource.volume) {
@@ -656,7 +1433,7 @@ class Player extends events_1.EventEmitter {
656
1433
  declared
657
1434
  : declared * 1000
658
1435
  : undefined;
659
- const cap = this.options?.tts?.Max_Time_TTS ?? 60_000;
1436
+ const cap = this.options?.tts?.maxTimeTts ?? 60_000;
660
1437
  const idleTimeout = declaredMs ? Math.min(cap, Math.max(1_000, declaredMs + 1_500)) : cap;
661
1438
  await (0, voice_1.entersState)(ttsPlayer, voice_1.AudioPlayerStatus.Idle, idleTimeout).catch(() => null);
662
1439
  // Swap back and resume if needed
@@ -707,9 +1484,21 @@ class Player extends events_1.EventEmitter {
707
1484
  });
708
1485
  await (0, voice_1.entersState)(connection, voice_1.VoiceConnectionStatus.Ready, 50_000);
709
1486
  this.connection = connection;
710
- connection.on(voice_1.VoiceConnectionStatus.Disconnected, () => {
711
- this.debug(`[Player] VoiceConnectionStatus.Disconnected`);
712
- this.destroy();
1487
+ connection.on(voice_1.VoiceConnectionStatus.Disconnected, async () => {
1488
+ try {
1489
+ // move channel
1490
+ await Promise.race([
1491
+ (0, voice_1.entersState)(connection, voice_1.VoiceConnectionStatus.Signalling, 5_000),
1492
+ (0, voice_1.entersState)(connection, voice_1.VoiceConnectionStatus.Connecting, 5_000),
1493
+ ]);
1494
+ // Signalling/Connecting → reconnect
1495
+ this.debug(`[Player] Reconnecting after channel move...`);
1496
+ }
1497
+ catch {
1498
+ // no reconnect in 5 giây → disconnect
1499
+ this.debug(`[Player] Truly disconnected, destroying player`);
1500
+ this.destroy();
1501
+ }
713
1502
  });
714
1503
  connection.on("error", (error) => {
715
1504
  this.debug(`[Player] Voice connection error:`, error);
@@ -757,7 +1546,7 @@ class Player extends events_1.EventEmitter {
757
1546
  const track = this.queue.currentTrack;
758
1547
  if (track) {
759
1548
  this.debug(`[Player] Player resumed on track: ${track.title}`);
760
- this.emit("playerResume", track);
1549
+ // this.emit("playerResume", track); //đã có trong stateChange
761
1550
  }
762
1551
  }
763
1552
  return result;
@@ -774,6 +1563,8 @@ class Player extends events_1.EventEmitter {
774
1563
  */
775
1564
  stop() {
776
1565
  this.debug(`[Player] stop called`);
1566
+ // Cancel preload when stopping
1567
+ this.cancelPreload();
777
1568
  this.queue.clear();
778
1569
  const result = this.audioPlayer.stop();
779
1570
  this.destroyCurrentStream();
@@ -808,12 +1599,7 @@ class Player extends events_1.EventEmitter {
808
1599
  this.debug(`[Player] Invalid seek position: ${position}ms (track duration: ${totalDuration}ms)`);
809
1600
  return false;
810
1601
  }
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);
1602
+ await this.refreshPlayerResource(true, position);
817
1603
  return true;
818
1604
  }
819
1605
  /**
@@ -830,26 +1616,23 @@ class Player extends events_1.EventEmitter {
830
1616
  this.debug(`[Player] skip called with index: ${index}`);
831
1617
  try {
832
1618
  if (typeof index === "number" && index >= 0) {
833
- // Skip to specific index
834
1619
  const targetTrack = this.queue.getTrack(index);
835
1620
  if (!targetTrack) {
836
1621
  this.debug(`[Player] No track found at index ${index}`);
837
1622
  return false;
838
1623
  }
839
- // Remove tracks from 0 to index-1
840
1624
  for (let i = 0; i < index; i++) {
841
1625
  this.queue.remove(0);
842
1626
  }
843
1627
  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
1628
  }
850
1629
  if (this.isPlaying || this.isPaused) {
851
1630
  this.skipLoop = true;
852
- return this.audioPlayer.stop();
1631
+ void this.crossfadeSkipAndStop().catch((error) => {
1632
+ this.debug(`[Player] crossfade skip error:`, error);
1633
+ this.audioPlayer.stop();
1634
+ });
1635
+ return true;
853
1636
  }
854
1637
  return true;
855
1638
  }
@@ -919,7 +1702,7 @@ class Player extends events_1.EventEmitter {
919
1702
  saveOptions = options;
920
1703
  }
921
1704
  try {
922
- // Try extensions first
1705
+ // Skip extension manager for saving - we want the raw stream without filters/seek applied, and extensions may not support this
923
1706
  let streamInfo = await this.pluginManager.getStream(track);
924
1707
  if (!streamInfo || !streamInfo.stream) {
925
1708
  throw new Error(`No save stream available for track: ${track.title}`);
@@ -1133,21 +1916,96 @@ class Player extends events_1.EventEmitter {
1133
1916
  * @example
1134
1917
  * const progressBar = player.getProgressBar();
1135
1918
  * console.log(`Progress bar: ${progressBar}`);
1919
+ *
1920
+ * // Custom options
1921
+ * const customBar = player.getProgressBar({
1922
+ * size: 30,
1923
+ * barChar: "─",
1924
+ * progressChar: "●",
1925
+ * timeFormat: "compact" // "compact" = 1:22:12, "full" = 01:22:12
1926
+ * });
1136
1927
  */
1137
1928
  getProgressBar(options = {}) {
1138
- const { size = 20, barChar = "▬", progressChar = "🔘" } = options;
1929
+ const { size = 20, barChar = "▬", progressChar = "🔘", timeFormat = "compact", // "compact" or "full"
1930
+ showPercentage = false, showTime = true, } = options;
1139
1931
  const track = this.queue.currentTrack;
1140
1932
  const resource = this.currentResource;
1141
- if (!track || !resource)
1933
+ // Handle live stream
1934
+ if (this.isLive || !track || !resource) {
1935
+ if (this.isLive)
1936
+ return "🔴 LIVE";
1142
1937
  return "";
1938
+ }
1143
1939
  const total = track.duration > 1000 ? track.duration : track.duration * 1000;
1144
1940
  if (!total)
1145
- return this.formatTime(resource.playbackDuration);
1941
+ return this.formatTimeCompact(resource.playbackDuration);
1146
1942
  const current = resource.playbackDuration;
1147
- const ratio = Math.min(current / total, 1);
1943
+ const ratio = Math.min(Math.max(current / total, 0), 1);
1148
1944
  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)}`;
1945
+ // Build progress bar
1946
+ let bar = "";
1947
+ if (progressChar === "none" || options.hideProgressChar) {
1948
+ // Continuous bar without separator
1949
+ const filled = barChar.repeat(progress);
1950
+ const empty = barChar.repeat(size - progress);
1951
+ bar = filled + empty;
1952
+ }
1953
+ else {
1954
+ // Bar with progress character
1955
+ const filled = barChar.repeat(progress);
1956
+ const empty = barChar.repeat(Math.max(0, size - progress));
1957
+ bar = filled + progressChar + empty;
1958
+ }
1959
+ // Format time based on option
1960
+ const formatTimeFn = timeFormat === "compact" ? this.formatTimeCompact.bind(this) : this.formatTime.bind(this);
1961
+ const currentTimeStr = formatTimeFn(current);
1962
+ const totalTimeStr = formatTimeFn(total);
1963
+ // Build result
1964
+ let result = "";
1965
+ if (showTime) {
1966
+ result = `${currentTimeStr} ${bar} ${totalTimeStr}`;
1967
+ }
1968
+ else {
1969
+ result = bar;
1970
+ }
1971
+ // Add percentage if requested
1972
+ if (showPercentage) {
1973
+ const percent = Math.round(ratio * 100);
1974
+ result += ` (${percent}%)`;
1975
+ }
1976
+ return result;
1977
+ }
1978
+ /**
1979
+ * Format time with leading zeros (00:00 or 00:00:00)
1980
+ * @param ms - Time in milliseconds
1981
+ * @returns Formatted time string with leading zeros
1982
+ */
1983
+ formatTime(ms) {
1984
+ const totalSeconds = Math.floor(ms / 1000);
1985
+ const hours = Math.floor(totalSeconds / 3600);
1986
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
1987
+ const seconds = totalSeconds % 60;
1988
+ const parts = [];
1989
+ if (hours > 0)
1990
+ parts.push(String(hours).padStart(2, "0"));
1991
+ parts.push(String(minutes).padStart(2, "0"));
1992
+ parts.push(String(seconds).padStart(2, "0"));
1993
+ return parts.join(":");
1994
+ }
1995
+ /**
1996
+ * Format time without leading zeros for hours (1:22:12 or 3:45)
1997
+ * @param ms - Time in milliseconds
1998
+ * @returns Compact formatted time string
1999
+ */
2000
+ formatTimeCompact(ms) {
2001
+ const totalSeconds = Math.floor(ms / 1000);
2002
+ const hours = Math.floor(totalSeconds / 3600);
2003
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
2004
+ const seconds = totalSeconds % 60;
2005
+ if (hours > 0) {
2006
+ return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
2007
+ }
2008
+ return `${minutes}:${String(seconds).padStart(2, "0")}`;
1151
2009
  }
1152
2010
  /**
1153
2011
  * Get the time of the current track
@@ -1156,44 +2014,44 @@ class Player extends events_1.EventEmitter {
1156
2014
  * @example
1157
2015
  * const time = player.getTime();
1158
2016
  * console.log(`Time: ${time.current}`);
2017
+ * console.log(`Formatted: ${time.formatted.current}`); // "1:22:12" or "3:45"
1159
2018
  */
1160
2019
  getTime() {
2020
+ if (this.isLive)
2021
+ return {
2022
+ current: 0,
2023
+ total: 0,
2024
+ format: "LIVE",
2025
+ formatted: {
2026
+ current: "LIVE",
2027
+ total: "LIVE",
2028
+ },
2029
+ };
1161
2030
  const resource = this.currentResource;
1162
2031
  const track = this.queue.currentTrack;
1163
- if (!track || !resource)
2032
+ if (!track || !resource) {
1164
2033
  return {
1165
2034
  current: 0,
1166
2035
  total: 0,
1167
2036
  format: "00:00",
2037
+ formatted: {
2038
+ current: "00:00",
2039
+ total: "00:00",
2040
+ },
1168
2041
  };
2042
+ }
1169
2043
  const total = track.duration > 1000 ? track.duration : track.duration * 1000;
2044
+ const current = resource.playbackDuration;
1170
2045
  return {
1171
- current: resource?.playbackDuration,
2046
+ current: current,
1172
2047
  total: total,
1173
- format: this.formatTime(resource.playbackDuration),
2048
+ format: this.formatTime(current),
2049
+ formatted: {
2050
+ current: this.formatTimeCompact(current),
2051
+ total: this.formatTimeCompact(total),
2052
+ },
1174
2053
  };
1175
2054
  }
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
2055
  /**
1198
2056
  * Destroy the player
1199
2057
  *
@@ -1207,10 +2065,14 @@ class Player extends events_1.EventEmitter {
1207
2065
  clearTimeout(this.leaveTimeout);
1208
2066
  this.leaveTimeout = null;
1209
2067
  }
2068
+ this.streamManager.destroyAll(true);
1210
2069
  // Destroy current stream before stopping audio
1211
2070
  this.destroyCurrentStream();
2071
+ this.clearSlot(this.currentSlot);
2072
+ this.clearSlot(this.preloadSlot);
1212
2073
  this.audioPlayer.removeAllListeners();
1213
2074
  this.audioPlayer.stop(true);
2075
+ this.clearPreload();
1214
2076
  if (this.ttsPlayer) {
1215
2077
  try {
1216
2078
  this.ttsPlayer.stop(true);
@@ -1243,7 +2105,7 @@ class Player extends events_1.EventEmitter {
1243
2105
  if (this.leaveTimeout) {
1244
2106
  clearTimeout(this.leaveTimeout);
1245
2107
  }
1246
- if (this.options.leaveOnEmpty && this.options.leaveTimeout) {
2108
+ if (this.options.leaveOnEnd && this.options.leaveTimeout) {
1247
2109
  this.leaveTimeout = setTimeout(() => {
1248
2110
  this.debug(`[Player] Leaving voice channel after timeoutMs`);
1249
2111
  this.destroy();
@@ -1257,13 +2119,16 @@ class Player extends events_1.EventEmitter {
1257
2119
  * @param {number} position - Position to seek to in milliseconds
1258
2120
  * @returns {Promise<boolean>}
1259
2121
  * @example
1260
- * const refreshed = await player.refeshPlayerResource(true, 1000);
2122
+ * const refreshed = await player.refreshPlayerResource(true, 1000);
1261
2123
  * console.log(`Refreshed: ${refreshed}`);
1262
2124
  */
1263
- async refeshPlayerResource(applyToCurrent = true, position = -1) {
2125
+ async refreshPlayerResource(applyToCurrent = true, position = -1) {
1264
2126
  if (!applyToCurrent || !this.queue.currentTrack || !(this.isPlaying || this.isPaused)) {
1265
2127
  return false;
1266
2128
  }
2129
+ if (this.refreshLock)
2130
+ return false;
2131
+ this.refreshLock = true;
1267
2132
  try {
1268
2133
  const track = this.queue.currentTrack;
1269
2134
  this.debug(`[Player] Refreshing player resource for track: ${track.title}`);
@@ -1290,7 +2155,10 @@ class Player extends events_1.EventEmitter {
1290
2155
  }
1291
2156
  }
1292
2157
  catch (error) {
1293
- this.debug(`[Player] Error destroying old stream in refeshPlayerResource:`, error);
2158
+ this.debug(`[Player] Error destroying old stream in refreshPlayerResource:`, error);
2159
+ }
2160
+ finally {
2161
+ this.refreshLock = false;
1294
2162
  }
1295
2163
  this.currentResource = resource;
1296
2164
  // Subscribe to new resource
@@ -1407,11 +2275,42 @@ class Player extends events_1.EventEmitter {
1407
2275
  }
1408
2276
  else if (newState.status === voice_1.AudioPlayerStatus.Buffering) {
1409
2277
  this.debug(`[Player] AudioPlayerStatus.Buffering`);
2278
+ this.lastDuration = this.currentResource?.playbackDuration || 0;
2279
+ this.stuckTimer = setTimeout(() => {
2280
+ if (this.currentResource?.playbackDuration === this.lastDuration) {
2281
+ this.emit("trackStuck", this.currentTrack);
2282
+ const stuckTrack = this.currentTrack;
2283
+ if (stuckTrack && this.antiStuckEnabled) {
2284
+ void this.attemptTrackRecovery(stuckTrack, new Error("TRACK_STUCK")).then((recovered) => {
2285
+ if (!recovered) {
2286
+ this.skip();
2287
+ }
2288
+ });
2289
+ return;
2290
+ }
2291
+ this.skip();
2292
+ }
2293
+ }, 10000);
2294
+ }
2295
+ else {
2296
+ if (this.stuckTimer) {
2297
+ clearTimeout(this.stuckTimer);
2298
+ this.stuckTimer = null;
2299
+ }
1410
2300
  }
1411
2301
  });
1412
2302
  this.audioPlayer.on("error", (error) => {
1413
2303
  this.debug(`[Player] AudioPlayer error:`, error);
1414
2304
  this.emit("playerError", error, this.queue.currentTrack || undefined);
2305
+ const track = this.queue.currentTrack;
2306
+ if (track && this.antiStuckEnabled) {
2307
+ void this.attemptTrackRecovery(track, error).then((recovered) => {
2308
+ if (!recovered) {
2309
+ this.playNext();
2310
+ }
2311
+ });
2312
+ return;
2313
+ }
1415
2314
  this.playNext();
1416
2315
  });
1417
2316
  this.audioPlayer.on("debug", (...args) => {
@@ -1419,6 +2318,20 @@ class Player extends events_1.EventEmitter {
1419
2318
  this.emit("debug", ...args);
1420
2319
  }
1421
2320
  });
2321
+ //stream Manager events
2322
+ this.streamManager.on("streamError", ({ streamId, error }) => {
2323
+ this.debug(`[StreamManager] Error for stream ${streamId}:`, error);
2324
+ this.emit("streamError", error, this.queue.currentTrack || null);
2325
+ });
2326
+ this.streamManager.on("streamRegistered", ({ streamId, track, metadata }) => {
2327
+ this.debug(`[StreamManager] Stream registered: ${track.title} (preload: ${metadata.isPreload})`);
2328
+ });
2329
+ this.streamManager.on("streamUnregistered", ({ streamId, track, reason }) => {
2330
+ this.debug(`[StreamManager] Stream unregistered: ${track.title} (reason: ${reason})`);
2331
+ });
2332
+ this.streamManager.on("debug", (...args) => {
2333
+ this.debug(...args);
2334
+ });
1422
2335
  }
1423
2336
  addPlugin(plugin) {
1424
2337
  this.debug(`[Player] Adding plugin: ${plugin.name}`);
@@ -1428,6 +2341,77 @@ class Player extends events_1.EventEmitter {
1428
2341
  this.debug(`[Player] Removing plugin: ${name}`);
1429
2342
  return this.pluginManager.unregister(name);
1430
2343
  }
2344
+ /**
2345
+ * Save the current session of the player, including queue, current track, position, volume, loop mode, auto-play mode, and active extensions/plugins.
2346
+ *
2347
+ * @returns {PlayerSession} The saved session data
2348
+ */
2349
+ saveSession() {
2350
+ return {
2351
+ guildId: this.guildId,
2352
+ currentTrack: this.currentTrack,
2353
+ position: this.currentResource?.playbackDuration || null,
2354
+ volume: this.volume,
2355
+ queue: this.queue.getTracks(),
2356
+ loopMode: this.queue.loop(),
2357
+ autoPlay: this.queue.autoPlay(),
2358
+ extensions: this.extensionManager.getAll().map((ext) => ext.name),
2359
+ plugins: this.pluginManager.getAll().map((plugin) => plugin.name),
2360
+ };
2361
+ }
2362
+ /**
2363
+ * Get serializable state (for manual persistence)
2364
+ */
2365
+ getSerializableState() {
2366
+ return {
2367
+ guildId: this.guildId,
2368
+ queue: this.queue.getTracks(),
2369
+ currentTrack: this.currentTrack,
2370
+ volume: this.volume,
2371
+ isPlaying: this.isPlaying,
2372
+ isPaused: this.isPaused,
2373
+ loopMode: this.queue.loop(),
2374
+ autoPlay: this.queue.autoPlay(),
2375
+ filters: this.filter.getFilterString(),
2376
+ timestamp: Date.now(),
2377
+ };
2378
+ }
2379
+ /**
2380
+ * Restore from saved state
2381
+ */
2382
+ async restoreState(state) {
2383
+ try {
2384
+ if (state.volume)
2385
+ this.setVolume(state.volume);
2386
+ if (state.loopMode)
2387
+ this.queue.loop(state.loopMode);
2388
+ if (typeof state.autoPlay === "boolean")
2389
+ this.queue.autoPlay(state.autoPlay);
2390
+ if (state.filters)
2391
+ await this.filter.applyFilters(state.filters.split(","));
2392
+ // Restore queue
2393
+ if (state.queue && Array.isArray(state.queue)) {
2394
+ this.queue.clear();
2395
+ this.queue.addMultiple(state.queue);
2396
+ }
2397
+ this.debug("[Player] State restored");
2398
+ return true;
2399
+ }
2400
+ catch (error) {
2401
+ this.debug("[Player] Failed to restore state:", error);
2402
+ return false;
2403
+ }
2404
+ }
2405
+ /**
2406
+ * Get stream manager stats
2407
+ */
2408
+ getStreamManagerStats() {
2409
+ return {
2410
+ metrics: this.streamManager.getMetrics(),
2411
+ stats: this.streamManager.getStats(),
2412
+ totalStreams: this.streamManager.getStreamCount(),
2413
+ };
2414
+ }
1431
2415
  //#endregion
1432
2416
  //#region Getters
1433
2417
  /**
@@ -1507,6 +2491,9 @@ class Player extends events_1.EventEmitter {
1507
2491
  get relatedTracks() {
1508
2492
  return this.queue.relatedTracks();
1509
2493
  }
2494
+ get isLive() {
2495
+ return this.currentTrack?.isLive === true;
2496
+ }
1510
2497
  }
1511
2498
  exports.Player = Player;
1512
2499
  //# sourceMappingURL=Player.js.map