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
@@ -1,1693 +1,2797 @@
1
- import { EventEmitter } from "events";
2
- import {
3
- createAudioPlayer,
4
- createAudioResource,
5
- entersState,
6
- AudioPlayerStatus,
7
- VoiceConnection,
8
- AudioPlayer as DiscordAudioPlayer,
9
- VoiceConnectionStatus,
10
- NoSubscriberBehavior,
11
- joinVoiceChannel,
12
- AudioResource,
13
- StreamType,
14
- } from "@discordjs/voice";
15
-
16
- import { Readable } from "stream";
17
- import type { BaseExtension } from "../extensions";
18
- import type {
19
- Track,
20
- PlayerOptions,
21
- PlayerEvents,
22
- SourcePlugin,
23
- SearchResult,
24
- ProgressBarOptions,
25
- LoopMode,
26
- StreamInfo,
27
- SaveOptions,
28
- VoiceChannel,
29
- ExtensionPlayRequest,
30
- ExtensionPlayResponse,
31
- ExtensionAfterPlayPayload,
32
- } from "../types";
33
- import type { PlayerManager } from "./PlayerManager";
34
-
35
- import { Queue } from "./Queue";
36
- import { PluginManager } from "../plugins";
37
- import { ExtensionManager } from "../extensions";
38
- import { withTimeout } from "../utils/timeout";
39
- import { FilterManager } from "./FilterManager";
40
-
41
- export declare interface Player {
42
- on<K extends keyof PlayerEvents>(event: K, listener: (...args: PlayerEvents[K]) => void): this;
43
- emit<K extends keyof PlayerEvents>(event: K, ...args: PlayerEvents[K]): boolean;
44
- }
45
-
46
- /**
47
- * Represents a music player for a specific Discord guild.
48
- *
49
- * @example
50
- * // Create and configure player
51
- * const player = await manager.create(guildId, {
52
- * tts: { interrupt: true, volume: 1 },
53
- * leaveOnEnd: true,
54
- * leaveTimeout: 30000
55
- * });
56
- *
57
- * // Connect to voice channel
58
- * await player.connect(voiceChannel);
59
- *
60
- * // Play different types of content
61
- * await player.play("Never Gonna Give You Up", userId); // Search query
62
- * await player.play("https://youtube.com/watch?v=dQw4w9WgXcQ", userId); // Direct URL
63
- * await player.play("tts: Hello everyone!", userId); // Text-to-Speech
64
- *
65
- * // Player controls
66
- * player.pause(); // Pause current track
67
- * player.resume(); // Resume paused track
68
- * player.skip(); // Skip to next track
69
- * player.stop(); // Stop and clear queue
70
- * player.setVolume(0.5); // Set volume to 50%
71
- *
72
- * // Event handling
73
- * player.on("trackStart", (player, track) => {
74
- * console.log(`Now playing: ${track.title}`);
75
- * });
76
- *
77
- * player.on("queueEnd", (player) => {
78
- * console.log("Queue finished");
79
- * });
80
- *
81
- */
82
- export class Player extends EventEmitter {
83
- public readonly guildId: string;
84
- public connection: VoiceConnection | null = null;
85
- public audioPlayer: DiscordAudioPlayer;
86
- public queue: Queue;
87
- public volume: number = 100;
88
- public isPlaying: boolean = false;
89
- public isPaused: boolean = false;
90
- public options: PlayerOptions;
91
- public pluginManager: PluginManager;
92
- public extensionManager: ExtensionManager;
93
- public userdata?: Record<string, any>;
94
- private manager: PlayerManager;
95
- private leaveTimeout: NodeJS.Timeout | null = null;
96
- private currentResource: AudioResource | null = null;
97
- private volumeInterval: NodeJS.Timeout | null = null;
98
- private skipLoop = false;
99
- private filter!: FilterManager;
100
-
101
- // Cache for search results to avoid duplicate calls
102
- private searchCache = new Map<string, SearchResult>();
103
- private readonly SEARCH_CACHE_TTL = 2 * 60 * 1000; // 2 minutes
104
- private searchCacheTimestamps = new Map<string, number>();
105
- private ttsPlayer: DiscordAudioPlayer | null = null;
106
-
107
- constructor(guildId: string, options: PlayerOptions = {}, manager: PlayerManager) {
108
- super();
109
- this.debug(`[Player] Constructor called for guildId: ${guildId}`);
110
- this.guildId = guildId;
111
- this.queue = new Queue();
112
- this.manager = manager;
113
- this.audioPlayer = createAudioPlayer({
114
- behaviors: {
115
- noSubscriber: NoSubscriberBehavior.Pause,
116
- maxMissedFrames: 100,
117
- },
118
- });
119
-
120
- this.options = {
121
- leaveOnEnd: true,
122
- leaveOnEmpty: true,
123
- leaveTimeout: 100000,
124
- volume: 100,
125
- quality: "high",
126
- extractorTimeout: 50000,
127
- selfDeaf: true,
128
- selfMute: false,
129
- ...options,
130
- tts: {
131
- createPlayer: false,
132
- interrupt: true,
133
- volume: 100,
134
- Max_Time_TTS: 60_000,
135
- ...(options?.tts || {}),
136
- },
137
- };
138
- this.filter = new FilterManager(this, this.manager);
139
- this.extensionManager = new ExtensionManager(this, this.manager);
140
- this.pluginManager = new PluginManager(this, this.manager, {
141
- extractorTimeout: this.options.extractorTimeout,
142
- });
143
-
144
- this.volume = this.options.volume || 100;
145
- this.userdata = this.options.userdata;
146
- this.setupEventListeners();
147
-
148
- // Initialize filters from options
149
- if (this.options.filters && this.options.filters.length > 0) {
150
- this.debug(`[Player] Initializing ${this.options.filters.length} filters from options`);
151
- // Use async version but don't await in constructor
152
- this.filter.applyFilters(this.options.filters).catch((error: any) => {
153
- this.debug(`[Player] Error initializing filters:`, error);
154
- });
155
- }
156
-
157
- // Optionally pre-create the TTS AudioPlayer
158
- if (this.options?.tts?.createPlayer) {
159
- this.ensureTTSPlayer();
160
- }
161
- }
162
-
163
- /**
164
- * Destroy current stream to prevent memory leaks
165
- * @private
166
- */
167
- private destroyCurrentStream(): void {
168
- if (!this.currentResource) return;
169
-
170
- const stream = (this.currentResource as any)?.metadata?.stream ?? (this.currentResource as any)?.stream;
171
-
172
- if (stream?.destroy) {
173
- stream.destroy();
174
- }
175
-
176
- this.currentResource = null;
177
- }
178
-
179
- //#region Search
180
-
181
- /**
182
- * Search for tracks using the player's extensions and plugins
183
- *
184
- * @param {string} query - The query to search for
185
- * @param {string} requestedBy - The user ID who requested the search
186
- * @returns {Promise<SearchResult>} The search result
187
- * @example
188
- * const result = await player.search("Never Gonna Give You Up", userId);
189
- * console.log(`Search result: ${result.tracks.length} tracks`);
190
- */
191
- async search(query: string, requestedBy: string): Promise<SearchResult> {
192
- this.debug(`[Player] Search called with query: ${query}, requestedBy: ${requestedBy}`);
193
-
194
- // Clear expired search cache periodically
195
- if (Math.random() < 0.1) {
196
- // 10% chance to clean cache
197
- this.clearExpiredSearchCache();
198
- }
199
-
200
- // Check cache first
201
- const cachedResult = this.getCachedSearchResult(query);
202
- if (cachedResult) {
203
- return cachedResult;
204
- }
205
-
206
- // Try extensions first
207
- const extensionResult = await this.extensionManager.provideSearch(query, requestedBy);
208
- if (extensionResult && Array.isArray(extensionResult.tracks) && extensionResult.tracks.length > 0) {
209
- this.debug(`[Player] Extension handled search for query: ${query}`);
210
- this.cacheSearchResult(query, extensionResult);
211
- return extensionResult;
212
- }
213
-
214
- // Get plugins and filter out TTS for regular searches
215
- const allPlugins = this.pluginManager.getAll();
216
- const plugins = allPlugins.filter((p) => {
217
- // Skip TTS plugin for regular searches (unless query starts with "tts:")
218
- if (p.name.toLowerCase() === "tts" && !query.toLowerCase().startsWith("tts:")) {
219
- this.debug(`[Player] Skipping TTS plugin for regular search: ${query}`);
220
- return false;
221
- }
222
- return true;
223
- });
224
-
225
- this.debug(`[Player] Using ${plugins.length} plugins for search (filtered from ${allPlugins.length})`);
226
-
227
- let lastError: any = null;
228
- let searchAttempts = 0;
229
-
230
- for (const p of plugins) {
231
- searchAttempts++;
232
- try {
233
- this.debug(`[Player] Trying plugin for search: ${p.name} (attempt ${searchAttempts}/${plugins.length})`);
234
- const startTime = Date.now();
235
- const res = await withTimeout(
236
- p.search(query, requestedBy),
237
- this.options.extractorTimeout ?? 15000,
238
- `Search operation timed out for ${p.name}`,
239
- );
240
- const duration = Date.now() - startTime;
241
-
242
- if (res && Array.isArray(res.tracks) && res.tracks.length > 0) {
243
- this.debug(`[Player] Plugin '${p.name}' returned ${res.tracks.length} tracks in ${duration}ms`);
244
- this.cacheSearchResult(query, res);
245
- return res;
246
- }
247
- this.debug(`[Player] Plugin '${p.name}' returned no tracks in ${duration}ms`);
248
- } catch (error) {
249
- const errorMessage = error instanceof Error ? error.message : String(error);
250
- this.debug(`[Player] Search via plugin '${p.name}' failed: ${errorMessage}`);
251
- lastError = error;
252
- // Continue to next plugin
253
- }
254
- }
255
-
256
- this.debug(`[Player] No plugins returned results for query: ${query} (tried ${searchAttempts} plugins)`);
257
- if (lastError) this.emit("playerError", lastError as Error);
258
- throw new Error(`No plugin found to handle: ${query}`);
259
- }
260
-
261
- /**
262
- * Get cached search result or null if not found/expired
263
- * @param query The search query
264
- * @returns Cached search result or null
265
- */
266
- private getCachedSearchResult(query: string): SearchResult | null {
267
- const cacheKey = query.toLowerCase().trim();
268
- const now = Date.now();
269
-
270
- const cachedTimestamp = this.searchCacheTimestamps.get(cacheKey);
271
- if (cachedTimestamp && now - cachedTimestamp < this.SEARCH_CACHE_TTL) {
272
- const cachedResult = this.searchCache.get(cacheKey);
273
- if (cachedResult) {
274
- this.debug(`[SearchCache] Using cached search result for: ${query}`);
275
- return cachedResult;
276
- }
277
- }
278
-
279
- return null;
280
- }
281
-
282
- /**
283
- * Cache search result
284
- * @param query The search query
285
- * @param result The search result to cache
286
- */
287
- private cacheSearchResult(query: string, result: SearchResult): void {
288
- const cacheKey = query.toLowerCase().trim();
289
- const now = Date.now();
290
-
291
- this.searchCache.set(cacheKey, result);
292
- this.searchCacheTimestamps.set(cacheKey, now);
293
- this.debug(`[SearchCache] Cached search result for: ${query} (${result.tracks.length} tracks)`);
294
- }
295
-
296
- /**
297
- * Clear expired search cache entries
298
- */
299
- private clearExpiredSearchCache(): void {
300
- const now = Date.now();
301
- for (const [key, timestamp] of this.searchCacheTimestamps.entries()) {
302
- if (now - timestamp >= this.SEARCH_CACHE_TTL) {
303
- this.searchCache.delete(key);
304
- this.searchCacheTimestamps.delete(key);
305
- this.debug(`[SearchCache] Cleared expired cache entry: ${key}`);
306
- }
307
- }
308
- }
309
-
310
- /**
311
- * Clear all search cache entries
312
- * @example
313
- * player.clearSearchCache();
314
- */
315
- public clearSearchCache(): void {
316
- const cacheSize = this.searchCache.size;
317
- this.searchCache.clear();
318
- this.searchCacheTimestamps.clear();
319
- this.debug(`[SearchCache] Cleared all ${cacheSize} search cache entries`);
320
- }
321
-
322
- /**
323
- * Debug method to check for duplicate search calls
324
- * @param query The search query to check
325
- * @returns Debug information about the query
326
- */
327
- public debugSearchQuery(query: string): {
328
- isCached: boolean;
329
- cacheAge?: number;
330
- pluginCount: number;
331
- ttsFiltered: boolean;
332
- } {
333
- const cacheKey = query.toLowerCase().trim();
334
- const now = Date.now();
335
- const cachedTimestamp = this.searchCacheTimestamps.get(cacheKey);
336
- const isCached = cachedTimestamp && now - cachedTimestamp < this.SEARCH_CACHE_TTL;
337
-
338
- const allPlugins = this.pluginManager.getAll();
339
- const plugins = allPlugins.filter((p) => {
340
- if (p.name.toLowerCase() === "tts" && !query.toLowerCase().startsWith("tts:")) {
341
- return false;
342
- }
343
- return true;
344
- });
345
-
346
- return {
347
- isCached: !!isCached,
348
- cacheAge: cachedTimestamp ? now - cachedTimestamp : undefined,
349
- pluginCount: plugins.length,
350
- ttsFiltered: allPlugins.length > plugins.length,
351
- };
352
- }
353
-
354
- private async generateWillNext(): Promise<void> {
355
- const lastTrack = this.queue.previousTracks[this.queue.previousTracks.length - 1] ?? this.queue.currentTrack;
356
- if (!lastTrack) return;
357
- const related = await this.pluginManager.getRelatedTracks(lastTrack);
358
- if (!related || related.length === 0) return;
359
- const randomchoice = Math.floor(Math.random() * related.length);
360
- const nextTrack = this.queue.nextTrack ? this.queue.nextTrack : related[randomchoice];
361
- this.queue.willNextTrack(nextTrack);
362
- this.queue.relatedTracks(related);
363
- this.debug(`[Player] Will next track if autoplay: ${nextTrack?.title}]`);
364
- this.emit("willPlay", nextTrack, related);
365
- }
366
- //#endregion
367
- //#region Play
368
-
369
- /**
370
- * Play a track, search query, search result, or play from queue
371
- *
372
- * @param {string | Track | SearchResult | null} query - Track URL, search query, Track object, SearchResult, or null for play
373
- * @param {string} requestedBy - User ID who requested the track
374
- * @returns {Promise<boolean>} True if playback started successfully
375
- * @example
376
- * await player.play("Never Gonna Give You Up", userId); // Search query
377
- * await player.play("https://youtube.com/watch?v=dQw4w9WgXcQ", userId); // Direct URL
378
- * await player.play("tts: Hello everyone!", userId); // Text-to-Speech
379
- * await player.play(trackObject, userId); // Track object
380
- * await player.play(searchResult, userId); // SearchResult object
381
- * await player.play(null); // play from queue
382
- */
383
- async play(query: string | Track | SearchResult | null, requestedBy?: string): Promise<boolean> {
384
- const debugInfo =
385
- query === null ? "null"
386
- : typeof query === "string" ? query
387
- : "tracks" in query ? `${query.tracks.length} tracks`
388
- : query.title || "unknown";
389
- this.debug(`[Player] Play called with query: ${debugInfo}`);
390
- this.clearLeaveTimeout();
391
- let tracksToAdd: Track[] = [];
392
- let isPlaylist = false;
393
- let effectiveRequest: ExtensionPlayRequest = { query: query as string | Track, requestedBy };
394
- let hookResponse: ExtensionPlayResponse = {};
395
-
396
- try {
397
- // Handle null query - play from queue
398
- if (query === null) {
399
- this.debug(`[Player] Play from queue requested`);
400
- if (this.queue.isEmpty) {
401
- this.debug(`[Player] Queue is empty, nothing to play`);
402
- return false;
403
- }
404
-
405
- if (!this.isPlaying) {
406
- return await this.playNext();
407
- }
408
- return true;
409
- }
410
-
411
- // Handle SearchResult
412
- if (query && typeof query === "object" && "tracks" in query && Array.isArray(query.tracks)) {
413
- this.debug(`[Player] Playing SearchResult with ${query.tracks.length} tracks`);
414
- tracksToAdd = query.tracks;
415
- isPlaylist = !!query.playlist || query.tracks.length > 1;
416
-
417
- if (query.playlist) {
418
- this.debug(`[Player] Added playlist: ${query.playlist.name} (${tracksToAdd.length} tracks)`);
419
- }
420
- } else {
421
- // Handle other types (string, Track)
422
- const hookOutcome = await this.extensionManager.BeforePlayHooks(effectiveRequest);
423
- effectiveRequest = hookOutcome.request;
424
- hookResponse = hookOutcome.response;
425
- if (effectiveRequest.requestedBy === undefined) {
426
- effectiveRequest.requestedBy = requestedBy;
427
- }
428
-
429
- const hookTracks = Array.isArray(hookResponse.tracks) ? hookResponse.tracks : undefined;
430
-
431
- if (hookResponse.handled && (!hookTracks || hookTracks.length === 0)) {
432
- const handledPayload: ExtensionAfterPlayPayload = {
433
- success: hookResponse.success ?? true,
434
- query: effectiveRequest.query,
435
- requestedBy: effectiveRequest.requestedBy,
436
- tracks: [],
437
- isPlaylist: hookResponse.isPlaylist ?? false,
438
- error: hookResponse.error,
439
- };
440
- await this.extensionManager.AfterPlayHooks(handledPayload);
441
- if (hookResponse.error) {
442
- this.emit("playerError", hookResponse.error);
443
- }
444
- return hookResponse.success ?? true;
445
- }
446
-
447
- if (hookTracks && hookTracks.length > 0) {
448
- tracksToAdd = hookTracks;
449
- isPlaylist = hookResponse.isPlaylist ?? hookTracks.length > 1;
450
- } else if (typeof effectiveRequest.query === "string") {
451
- const searchResult = await this.search(effectiveRequest.query, effectiveRequest.requestedBy || "Unknown");
452
- tracksToAdd = searchResult.tracks;
453
- if (searchResult.playlist) {
454
- isPlaylist = true;
455
- this.debug(`[Player] Added playlist: ${searchResult.playlist.name} (${tracksToAdd.length} tracks)`);
456
- }
457
- } else if (effectiveRequest.query) {
458
- tracksToAdd = [effectiveRequest.query as Track];
459
- }
460
- }
461
-
462
- if (tracksToAdd.length === 0) {
463
- this.debug(`[Player] No tracks found for play`);
464
- throw new Error("No tracks found");
465
- }
466
-
467
- const isTTS = (t: Track | undefined) => {
468
- if (!t) return false;
469
- try {
470
- return typeof t.source === "string" && t.source.toLowerCase().includes("tts");
471
- } catch {
472
- return false;
473
- }
474
- };
475
-
476
- const queryLooksTTS =
477
- typeof effectiveRequest.query === "string" && effectiveRequest.query.trim().toLowerCase().startsWith("tts");
478
-
479
- if (
480
- !isPlaylist &&
481
- tracksToAdd.length > 0 &&
482
- this.options?.tts?.interrupt !== false &&
483
- (isTTS(tracksToAdd[0]) || queryLooksTTS)
484
- ) {
485
- this.debug(`[Player] Interrupting with TTS: ${tracksToAdd[0].title}`);
486
- await this.interruptWithTTSTrack(tracksToAdd[0]);
487
- await this.extensionManager.AfterPlayHooks({
488
- success: true,
489
- query: effectiveRequest.query,
490
- requestedBy: effectiveRequest.requestedBy,
491
- tracks: tracksToAdd,
492
- isPlaylist,
493
- });
494
- return true;
495
- }
496
-
497
- if (isPlaylist) {
498
- this.queue.addMultiple(tracksToAdd);
499
- this.emit("queueAddList", tracksToAdd);
500
- } else {
501
- this.queue.add(tracksToAdd[0]);
502
- this.emit("queueAdd", tracksToAdd[0]);
503
- }
504
-
505
- const started = !this.isPlaying ? await this.playNext() : true;
506
-
507
- await this.extensionManager.AfterPlayHooks({
508
- success: started,
509
- query: effectiveRequest.query,
510
- requestedBy: effectiveRequest.requestedBy,
511
- tracks: tracksToAdd,
512
- isPlaylist,
513
- });
514
-
515
- return started;
516
- } catch (error) {
517
- await this.extensionManager.AfterPlayHooks({
518
- success: false,
519
- query: effectiveRequest.query,
520
- requestedBy: effectiveRequest.requestedBy,
521
- tracks: tracksToAdd,
522
- isPlaylist,
523
- error: error as Error,
524
- });
525
- this.debug(`[Player] Play error:`, error);
526
- this.emit("playerError", error as Error);
527
- return false;
528
- }
529
- }
530
-
531
- /**
532
- * Create AudioResource with filters and seek applied
533
- *
534
- * @param {StreamInfo} streamInfo - The stream information
535
- * @param {Track} track - The track being processed
536
- * @param {number} position - Position in milliseconds to seek to (0 = no seek)
537
- * @returns {Promise<AudioResource>} The AudioResource with filters and seek applied
538
- */
539
- private async createResource(streamInfo: StreamInfo, track: Track, position: number = 0): Promise<AudioResource> {
540
- const filterString = this.filter.getFilterString();
541
-
542
- this.debug(`[Player] Creating AudioResource with filters: ${filterString || "none"}, seek: ${position}ms`);
543
-
544
- try {
545
- let stream: Readable = streamInfo.stream;
546
- // Apply filters and seek if needed
547
- if (filterString || position > 0) {
548
- stream = await this.filter.applyFiltersAndSeek(streamInfo.stream, position);
549
- streamInfo.type = StreamType.Arbitrary;
550
- }
551
-
552
- // Create AudioResource with better error handling
553
- const resource = createAudioResource(stream, {
554
- metadata: track,
555
- inputType:
556
- streamInfo.type === "webm/opus" ? StreamType.WebmOpus
557
- : streamInfo.type === "ogg/opus" ? StreamType.OggOpus
558
- : StreamType.Arbitrary,
559
- inlineVolume: true,
560
- });
561
-
562
- return resource;
563
- } catch (error) {
564
- this.debug(`[Player] Error creating AudioResource with filters+seek:`, error);
565
- // Fallback to basic AudioResource
566
- try {
567
- const resource = createAudioResource(streamInfo.stream, {
568
- metadata: track,
569
- inputType:
570
- streamInfo.type === "webm/opus" ? StreamType.WebmOpus
571
- : streamInfo.type === "ogg/opus" ? StreamType.OggOpus
572
- : StreamType.Arbitrary,
573
- inlineVolume: true,
574
- });
575
- return resource;
576
- } catch (fallbackError) {
577
- this.debug(`[Player] Fallback AudioResource creation failed:`, fallbackError);
578
- throw fallbackError;
579
- }
580
- }
581
- }
582
- private async getStream(track: Track): Promise<StreamInfo | null> {
583
- let stream = await this.extensionManager.provideStream(track);
584
- if (stream?.stream) return stream;
585
- stream = await this.pluginManager.getStream(track);
586
- if (stream?.stream) return stream;
587
- throw new Error(`No stream available for track: ${track.title}`);
588
- }
589
-
590
- /**
591
- * Start playing a specific track immediately, replacing the current resource.
592
- */
593
- private async startTrack(track: Track): Promise<boolean> {
594
- try {
595
- let streamInfo: StreamInfo | null = await this.getStream(track);
596
- this.debug(`[Player] Using stream for track: ${track.title}`);
597
- // Kiểm tra nếu có stream thực sự để tạo AudioResource
598
- if (streamInfo && (streamInfo as any).stream) {
599
- try {
600
- // Destroy the old stream and resource before creating a new one
601
- this.destroyCurrentStream();
602
-
603
- this.currentResource = await this.createResource(streamInfo, track, 0);
604
- if (this.volumeInterval) {
605
- clearInterval(this.volumeInterval);
606
- this.volumeInterval = null;
607
- }
608
- this.currentResource.volume?.setVolume(this.volume / 100);
609
-
610
- this.debug(`[Player] Playing resource for track: ${track.title}`);
611
- this.audioPlayer.play(this.currentResource);
612
-
613
- await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 5_000);
614
- return true;
615
- } catch (resourceError) {
616
- this.debug(`[Player] Error creating/playing resource:`, resourceError);
617
- // Try fallback without filters
618
- try {
619
- this.debug(`[Player] Attempting fallback without filters`);
620
- const fallbackResource = createAudioResource(streamInfo.stream, {
621
- metadata: track,
622
- inputType:
623
- streamInfo.type === "webm/opus" ? StreamType.WebmOpus
624
- : streamInfo.type === "ogg/opus" ? StreamType.OggOpus
625
- : StreamType.Arbitrary,
626
- inlineVolume: true,
627
- });
628
-
629
- this.currentResource = fallbackResource;
630
- this.currentResource.volume?.setVolume(this.volume / 100);
631
- this.audioPlayer.play(this.currentResource);
632
- await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 5_000);
633
- return true;
634
- } catch (fallbackError) {
635
- this.debug(`[Player] Fallback also failed:`, fallbackError);
636
- throw fallbackError;
637
- }
638
- }
639
- } else if (streamInfo && !(streamInfo as any).stream) {
640
- // Extension đang xử lý phát nhạc (như Lavalink) - chỉ đánh dấu đang phát
641
- this.debug(`[Player] Extension is handling playback for track: ${track.title}`);
642
- this.isPlaying = true;
643
- this.isPaused = false;
644
- this.emit("trackStart", track);
645
- return true;
646
- } else {
647
- throw new Error(`No stream available for track: ${track.title}`);
648
- }
649
- } catch (error) {
650
- this.debug(`[Player] startTrack error:`, error);
651
- this.emit("playerError", error as Error, track);
652
- return false;
653
- }
654
- }
655
-
656
- private async playNext(): Promise<boolean> {
657
- this.debug(`[Player] playNext called by ${new Error().stack?.split("\n")[2]?.trim()}`);
658
- while (true) {
659
- const track = this.queue.next(this.skipLoop);
660
- this.skipLoop = false;
661
-
662
- if (!track) {
663
- if (this.queue.autoPlay()) {
664
- const willnext = this.queue.willNextTrack();
665
- if (willnext) {
666
- this.queue.addMultiple([willnext]);
667
- continue;
668
- }
669
- }
670
- this.debug(`[Player] No next track in queue`);
671
- this.isPlaying = false;
672
- this.emit("queueEnd");
673
-
674
- if (this.options.leaveOnEnd) {
675
- this.scheduleLeave();
676
- }
677
-
678
- return false;
679
- }
680
-
681
- this.generateWillNext();
682
- this.clearLeaveTimeout();
683
- this.debug(`[Player] playNext called for track: ${track.title}`);
684
-
685
- try {
686
- return await this.startTrack(track);
687
- } catch (err) {
688
- this.debug(`[Player] playNext error:`, err);
689
- this.emit("playerError", err as Error, track);
690
- continue;
691
- }
692
- }
693
- }
694
-
695
- //#endregion
696
- //#region TTS
697
-
698
- private ensureTTSPlayer(): DiscordAudioPlayer {
699
- if (this.ttsPlayer) return this.ttsPlayer;
700
- this.ttsPlayer = createAudioPlayer({
701
- behaviors: {
702
- noSubscriber: NoSubscriberBehavior.Pause,
703
- maxMissedFrames: 100,
704
- },
705
- });
706
- this.ttsPlayer.on("error", (e) => this.debug("[TTS] error:", e));
707
- return this.ttsPlayer;
708
- }
709
- /**
710
- * Interrupt current music with a TTS track. Pauses music, swaps the
711
- * subscription to a dedicated TTS player, plays TTS, then resumes.
712
- *
713
- * @param {Track} track - The track to interrupt with
714
- * @returns {Promise<void>}
715
- * @example
716
- * await player.interruptWithTTSTrack(track);
717
- */
718
- public async interruptWithTTSTrack(track: Track): Promise<void> {
719
- const wasPlaying =
720
- this.audioPlayer.state.status === AudioPlayerStatus.Playing ||
721
- this.audioPlayer.state.status === AudioPlayerStatus.Buffering;
722
-
723
- let ttsResource: AudioResource | null = null;
724
- let ttsStream: any = null;
725
-
726
- try {
727
- if (!this.connection) throw new Error("No voice connection for TTS");
728
- const ttsPlayer = this.ensureTTSPlayer();
729
-
730
- // Build resource from plugin stream
731
- const streamInfo = await this.pluginManager.getStream(track);
732
- if (!streamInfo) {
733
- throw new Error("No stream available for track: ${track.title}");
734
- }
735
- ttsStream = streamInfo.stream;
736
- const resource = await this.createResource(streamInfo as StreamInfo, track);
737
- if (!resource) {
738
- throw new Error("No resource available for track: ${track.title}");
739
- }
740
- ttsResource = resource;
741
- if (resource.volume) {
742
- resource.volume.setVolume((this.options?.tts?.volume ?? this?.volume ?? 100) / 100);
743
- }
744
-
745
- // Pause current music if any
746
- try {
747
- this.pause();
748
- } catch {}
749
-
750
- // Swap subscription and play TTS
751
- this.connection.subscribe(ttsPlayer);
752
- this.emit("ttsStart", { track });
753
- ttsPlayer.play(resource);
754
-
755
- // Wait until TTS starts then finishes
756
- await entersState(ttsPlayer, AudioPlayerStatus.Playing, 5_000).catch(() => null);
757
- // Derive timeoutMs from resource/track duration when available, with a sensible cap
758
- const md: any = (resource as any)?.metadata ?? {};
759
- const declared =
760
- typeof md.duration === "number" ? md.duration
761
- : typeof track?.duration === "number" ? track.duration
762
- : undefined;
763
- const declaredMs =
764
- declared ?
765
- declared > 1000 ?
766
- declared
767
- : declared * 1000
768
- : undefined;
769
- const cap = this.options?.tts?.Max_Time_TTS ?? 60_000;
770
- const idleTimeout = declaredMs ? Math.min(cap, Math.max(1_000, declaredMs + 1_500)) : cap;
771
- await entersState(ttsPlayer, AudioPlayerStatus.Idle, idleTimeout).catch(() => null);
772
-
773
- // Swap back and resume if needed
774
- this.connection.subscribe(this.audioPlayer);
775
- } catch (err) {
776
- this.debug("[TTS] error while playing:", err);
777
- this.emit("playerError", err as Error);
778
- } finally {
779
- // Clean up TTS stream and resource
780
- try {
781
- if (ttsStream && typeof ttsStream.destroy === "function") {
782
- ttsStream.destroy();
783
- }
784
- } catch (error) {
785
- this.debug("[TTS] Error destroying stream:", error);
786
- }
787
-
788
- if (wasPlaying) {
789
- try {
790
- this.resume();
791
- } catch {}
792
- }
793
- this.emit("ttsEnd");
794
- }
795
- }
796
-
797
- //#endregion
798
- //#region Player Function
799
-
800
- /**
801
- * Connect to a voice channel
802
- *
803
- * @param {VoiceChannel} channel - Discord voice channel
804
- * @returns {Promise<VoiceConnection>} The voice connection
805
- * @example
806
- * await player.connect(voiceChannel);
807
- */
808
- async connect(channel: VoiceChannel): Promise<VoiceConnection> {
809
- try {
810
- this.debug(`[Player] Connecting to voice channel: ${channel.id}`);
811
- const connection = joinVoiceChannel({
812
- channelId: channel.id,
813
- guildId: channel.guildId,
814
- adapterCreator: channel.guild.voiceAdapterCreator as any,
815
- selfDeaf: this.options.selfDeaf ?? true,
816
- selfMute: this.options.selfMute ?? false,
817
- });
818
-
819
- await entersState(connection, VoiceConnectionStatus.Ready, 50_000);
820
- this.connection = connection;
821
-
822
- connection.on(VoiceConnectionStatus.Disconnected, () => {
823
- this.debug(`[Player] VoiceConnectionStatus.Disconnected`);
824
- this.destroy();
825
- });
826
-
827
- connection.on("error", (error) => {
828
- this.debug(`[Player] Voice connection error:`, error);
829
- this.emit("connectionError", error);
830
- });
831
- connection.subscribe(this.audioPlayer);
832
-
833
- this.clearLeaveTimeout();
834
- return this.connection;
835
- } catch (error) {
836
- this.debug(`[Player] Connection error:`, error);
837
- this.emit("connectionError", error as Error);
838
- this.connection?.destroy();
839
- throw error;
840
- }
841
- }
842
-
843
- /**
844
- * Pause the current track
845
- *
846
- * @returns {boolean} True if paused successfully
847
- * @example
848
- * const paused = player.pause();
849
- * console.log(`Paused: ${paused}`);
850
- */
851
- pause(): boolean {
852
- this.debug(`[Player] pause called`);
853
- if (this.isPlaying && !this.isPaused) {
854
- return this.audioPlayer.pause();
855
- }
856
- return false;
857
- }
858
-
859
- /**
860
- * Resume the current track
861
- *
862
- * @returns {boolean} True if resumed successfully
863
- * @example
864
- * const resumed = player.resume();
865
- * console.log(`Resumed: ${resumed}`);
866
- */
867
- resume(): boolean {
868
- this.debug(`[Player] resume called`);
869
- if (this.isPaused) {
870
- const result = this.audioPlayer.unpause();
871
- if (result) {
872
- const track = this.queue.currentTrack;
873
- if (track) {
874
- this.debug(`[Player] Player resumed on track: ${track.title}`);
875
- this.emit("playerResume", track);
876
- }
877
- }
878
- return result;
879
- }
880
- return false;
881
- }
882
-
883
- /**
884
- * Stop the current track
885
- *
886
- * @returns {boolean} True if stopped successfully
887
- * @example
888
- * const stopped = player.stop();
889
- * console.log(`Stopped: ${stopped}`);
890
- */
891
- stop(): boolean {
892
- this.debug(`[Player] stop called`);
893
- this.queue.clear();
894
- const result = this.audioPlayer.stop();
895
- this.destroyCurrentStream();
896
- this.currentResource = null;
897
-
898
- this.isPlaying = false;
899
- this.isPaused = false;
900
- this.emit("playerStop");
901
- return result;
902
- }
903
-
904
- /**
905
- * Seek to a specific position in the current track
906
- *
907
- * @param {number} position - Position in milliseconds to seek to
908
- * @returns {Promise<boolean>} True if seek was successful
909
- * @example
910
- * // Seek to 30 seconds (30000ms)
911
- * const success = await player.seek(30000);
912
- * console.log(`Seek successful: ${success}`);
913
- *
914
- * // Seek to 1 minute 30 seconds (90000ms)
915
- * await player.seek(90000);
916
- */
917
- async seek(position: number): Promise<boolean> {
918
- this.debug(`[Player] seek called with position: ${position}ms`);
919
-
920
- const track = this.queue.currentTrack;
921
- if (!track) {
922
- this.debug(`[Player] No current track to seek`);
923
- return false;
924
- }
925
-
926
- const totalDuration = track.duration > 1000 ? track.duration : track.duration * 1000;
927
- if (position < 0 || position > totalDuration) {
928
- this.debug(`[Player] Invalid seek position: ${position}ms (track duration: ${totalDuration}ms)`);
929
- return false;
930
- }
931
-
932
- const streaminfo = await this.getStream(track);
933
- if (!streaminfo?.stream) {
934
- this.debug(`[Player] No stream to seek`);
935
- return false;
936
- }
937
-
938
- await this.refeshPlayerResource(true, position);
939
-
940
- return true;
941
- }
942
-
943
- /**
944
- * Skip to the next track or skip to a specific index
945
- *
946
- * @param {number} index - Optional index to skip to (0 = next track)
947
- * @returns {boolean} True if skipped successfully
948
- * @example
949
- * const skipped = player.skip(); // Skip to next track
950
- * const skippedToIndex = player.skip(2); // Skip to track at index 2
951
- * console.log(`Skipped: ${skipped}`);
952
- */
953
- skip(index?: number): boolean {
954
- this.debug(`[Player] skip called with index: ${index}`);
955
- try {
956
- if (typeof index === "number" && index >= 0) {
957
- // Skip to specific index
958
- const targetTrack = this.queue.getTrack(index);
959
- if (!targetTrack) {
960
- this.debug(`[Player] No track found at index ${index}`);
961
- return false;
962
- }
963
-
964
- // Remove tracks from 0 to index-1
965
- for (let i = 0; i < index; i++) {
966
- this.queue.remove(0);
967
- }
968
-
969
- this.debug(`[Player] Skipped to track at index ${index}: ${targetTrack.title}`);
970
- if (this.isPlaying || this.isPaused) {
971
- this.skipLoop = true;
972
- return this.audioPlayer.stop();
973
- }
974
- return true;
975
- }
976
-
977
- if (this.isPlaying || this.isPaused) {
978
- this.skipLoop = true;
979
- return this.audioPlayer.stop();
980
- }
981
-
982
- return true;
983
- } catch (error) {
984
- this.debug(`[Player] skip error:`, error);
985
- return false;
986
- }
987
- }
988
-
989
- /**
990
- * Go back to the previous track in history and play it.
991
- *
992
- * @returns {Promise<boolean>} True if previous track was played successfully
993
- * @example
994
- * const previous = await player.previous();
995
- * console.log(`Previous: ${previous}`);
996
- */
997
- async previous(): Promise<boolean> {
998
- this.debug(`[Player] previous called`);
999
- const track = this.queue.previous();
1000
- if (!track) return false;
1001
- if (this.queue.currentTrack) this.insert(this.queue.currentTrack, 0);
1002
- this.clearLeaveTimeout();
1003
- return this.startTrack(track);
1004
- }
1005
-
1006
- /**
1007
- * Save a track's stream to a file and return a Readable stream
1008
- *
1009
- * @param {Track} track - The track to save
1010
- * @param {SaveOptions | string} options - Save options or filename string (for backward compatibility)
1011
- * @returns {Promise<Readable>} A Readable stream containing the audio data
1012
- * @example
1013
- * // Save current track to file
1014
- * const track = player.currentTrack;
1015
- * if (track) {
1016
- * const stream = await player.save(track);
1017
- *
1018
- * // Use fs to write the stream to file
1019
- * const fs = require('fs');
1020
- * const writeStream = fs.createWriteStream('saved-song.mp3');
1021
- * stream.pipe(writeStream);
1022
- *
1023
- * writeStream.on('finish', () => {
1024
- * console.log('File saved successfully!');
1025
- * });
1026
- * }
1027
- *
1028
- * // Save any track by URL
1029
- * const searchResult = await player.search("Never Gonna Give You Up", userId);
1030
- * if (searchResult.tracks.length > 0) {
1031
- * const stream = await player.save(searchResult.tracks[0]);
1032
- * // Handle the stream...
1033
- * }
1034
- *
1035
- * // Backward compatibility - filename as string
1036
- * const stream = await player.save(track, "my-song.mp3");
1037
- */
1038
- async save(track: Track, options?: SaveOptions | string): Promise<Readable> {
1039
- this.debug(`[Player] save called for track: ${track.title}`);
1040
-
1041
- // Parse options - support both SaveOptions object and filename string (backward compatibility)
1042
- let saveOptions: SaveOptions = {};
1043
- if (typeof options === "string") {
1044
- saveOptions = { filename: options };
1045
- } else if (options) {
1046
- saveOptions = options;
1047
- }
1048
-
1049
- try {
1050
- // Try extensions first
1051
- let streamInfo: StreamInfo | null = await this.pluginManager.getStream(track);
1052
-
1053
- if (!streamInfo || !streamInfo.stream) {
1054
- throw new Error(`No save stream available for track: ${track.title}`);
1055
- }
1056
-
1057
- this.debug(`[Player] Save stream obtained for track: ${track.title}`);
1058
- if (saveOptions.filename) {
1059
- this.debug(`[Player] Save options - filename: ${saveOptions.filename}, quality: ${saveOptions.quality || "default"}`);
1060
- }
1061
-
1062
- // Apply filters if any are active
1063
- let finalStream = streamInfo.stream;
1064
-
1065
- if (saveOptions.filter || saveOptions.seek) {
1066
- try {
1067
- this.filter.clearAll();
1068
- this.filter.applyFilters(saveOptions.filter || []);
1069
- } catch (err) {
1070
- this.debug(`[Player] Error applying save filters:`, err);
1071
- }
1072
-
1073
- this.debug(`[Player] Applying filters to save stream: ${this.filter.getFilterString() || "none"}`);
1074
- finalStream = await this.filter.applyFiltersAndSeek(streamInfo.stream, saveOptions.seek || 0).catch((err) => {
1075
- this.debug(`[Player] Error applying filters to save stream:`, err);
1076
- return streamInfo!.stream; // Fallback to original stream
1077
- });
1078
- }
1079
-
1080
- // Return the stream directly - caller can pipe it to fs.createWriteStream()
1081
- return finalStream;
1082
- } catch (error) {
1083
- this.debug(`[Player] save error:`, error);
1084
- this.emit("playerError", error as Error, track);
1085
- throw error;
1086
- }
1087
- }
1088
-
1089
- /**
1090
- * Loop the current track or queue
1091
- *
1092
- * @param {LoopMode | number} mode - The loop mode to set ("off", "track", "queue") or number (0=off, 1=track, 2=queue)
1093
- * @returns {LoopMode} The loop mode
1094
- * @example
1095
- * const loopMode = player.loop("track"); // Loop current track
1096
- * const loopQueue = player.loop("queue"); // Loop entire queue
1097
- * const loopTrack = player.loop(1); // Loop current track (same as "track")
1098
- * const loopQueueNum = player.loop(2); // Loop entire queue (same as "queue")
1099
- * const noLoop = player.loop("off"); // No loop
1100
- * const noLoopNum = player.loop(0); // No loop (same as "off")
1101
- * console.log(`Loop mode: ${loopMode}`);
1102
- */
1103
- loop(mode?: LoopMode | number): LoopMode {
1104
- this.debug(`[Player] loop called with mode: ${mode}`);
1105
-
1106
- if (typeof mode === "number") {
1107
- // Number mode: convert to text mode
1108
- switch (mode) {
1109
- case 0:
1110
- return this.queue.loop("off");
1111
- case 1:
1112
- return this.queue.loop("track");
1113
- case 2:
1114
- return this.queue.loop("queue");
1115
- default:
1116
- this.debug(`[Player] Invalid loop number: ${mode}, using "off"`);
1117
- return this.queue.loop("off");
1118
- }
1119
- }
1120
-
1121
- return this.queue.loop(mode as LoopMode);
1122
- }
1123
-
1124
- /**
1125
- * Set the auto-play mode
1126
- *
1127
- * @param {boolean} mode - The auto-play mode to set
1128
- * @returns {boolean} The auto-play mode
1129
- * @example
1130
- * const autoPlayMode = player.autoPlay(true);
1131
- * console.log(`Auto-play mode: ${autoPlayMode}`);
1132
- */
1133
- autoPlay(mode?: boolean): boolean {
1134
- return this.queue.autoPlay(mode);
1135
- }
1136
-
1137
- /**
1138
- * Set the volume of the current track
1139
- *
1140
- * @param {number} volume - The volume to set
1141
- * @returns {boolean} True if volume was set successfully
1142
- * @example
1143
- * const volumeSet = player.setVolume(50);
1144
- * console.log(`Volume set: ${volumeSet}`);
1145
- */
1146
- setVolume(volume: number): boolean {
1147
- this.debug(`[Player] setVolume called: ${volume}`);
1148
- if (volume < 0 || volume > 200) return false;
1149
-
1150
- const oldVolume = this.volume;
1151
- this.volume = volume;
1152
- const resourceVolume = this.currentResource?.volume;
1153
-
1154
- if (resourceVolume) {
1155
- if (this.volumeInterval) clearInterval(this.volumeInterval);
1156
-
1157
- const start = resourceVolume.volume;
1158
- const target = this.volume / 100;
1159
- const steps = 10;
1160
- let currentStep = 0;
1161
-
1162
- this.volumeInterval = setInterval(() => {
1163
- currentStep++;
1164
- const value = start + ((target - start) * currentStep) / steps;
1165
- resourceVolume.setVolume(value);
1166
- if (currentStep >= steps) {
1167
- clearInterval(this.volumeInterval!);
1168
- this.volumeInterval = null;
1169
- }
1170
- }, 300);
1171
- }
1172
-
1173
- this.emit("volumeChange", oldVolume, volume);
1174
- return true;
1175
- }
1176
-
1177
- /**
1178
- * Shuffle the queue
1179
- *
1180
- * @returns {void}
1181
- * @example
1182
- * player.shuffle();
1183
- */
1184
- shuffle(): void {
1185
- this.debug(`[Player] shuffle called`);
1186
- this.queue.shuffle();
1187
- }
1188
-
1189
- /**
1190
- * Clear the queue
1191
- *
1192
- * @returns {void}
1193
- * @example
1194
- * player.clearQueue();
1195
- */
1196
- clearQueue(): void {
1197
- this.debug(`[Player] clearQueue called`);
1198
- this.queue.clear();
1199
- }
1200
-
1201
- /**
1202
- * Insert a track or list of tracks into the upcoming queue at a specific position (0 = play after current).
1203
- * - If `query` is a string, performs a search and inserts resulting tracks (playlist supported).
1204
- * - If a Track or Track[] is provided, inserts directly.
1205
- * Does not auto-start playback; it only modifies the queue.
1206
- *
1207
- * @param {string | Track | Track[]} query - The track or tracks to insert
1208
- * @param {number} index - The index to insert the tracks at
1209
- * @param {string} requestedBy - The user ID who requested the insert
1210
- * @returns {Promise<boolean>} True if the tracks were inserted successfully
1211
- * @example
1212
- * const inserted = await player.insert("Song Name", 0, userId);
1213
- * console.log(`Inserted: ${inserted}`);
1214
- */
1215
- async insert(query: string | Track | Track[], index: number, requestedBy?: string): Promise<boolean> {
1216
- try {
1217
- this.debug(`[Player] insert called at index ${index} with type: ${typeof query}`);
1218
- let tracksToAdd: Track[] = [];
1219
- let isPlaylist = false;
1220
-
1221
- if (typeof query === "string") {
1222
- const searchResult = await this.search(query, requestedBy || "Unknown");
1223
- tracksToAdd = searchResult.tracks || [];
1224
- isPlaylist = !!searchResult.playlist;
1225
- } else if (Array.isArray(query)) {
1226
- tracksToAdd = query;
1227
- isPlaylist = query.length > 1;
1228
- } else if (query) {
1229
- tracksToAdd = [query];
1230
- }
1231
-
1232
- if (!tracksToAdd || tracksToAdd.length === 0) {
1233
- this.debug(`[Player] insert: no tracks resolved`);
1234
- throw new Error("No tracks to insert");
1235
- }
1236
-
1237
- if (tracksToAdd.length === 1) {
1238
- this.queue.insert(tracksToAdd[0], index);
1239
- this.emit("queueAdd", tracksToAdd[0]);
1240
- this.debug(`[Player] Inserted track at index ${index}: ${tracksToAdd[0].title}`);
1241
- } else {
1242
- this.queue.insertMultiple(tracksToAdd, index);
1243
- this.emit("queueAddList", tracksToAdd);
1244
- this.debug(`[Player] Inserted ${tracksToAdd.length} ${isPlaylist ? "playlist " : ""}tracks at index ${index}`);
1245
- }
1246
-
1247
- return true;
1248
- } catch (error) {
1249
- this.debug(`[Player] insert error:`, error);
1250
- this.emit("playerError", error as Error);
1251
- return false;
1252
- }
1253
- }
1254
-
1255
- /**
1256
- * Remove a track from the queue
1257
- *
1258
- * @param {number} index - The index of the track to remove
1259
- * @returns {Track | null} The removed track or null
1260
- * @example
1261
- * const removed = player.remove(0);
1262
- * console.log(`Removed: ${removed?.title}`);
1263
- */
1264
- remove(index: number): Track | null {
1265
- this.debug(`[Player] remove called for index: ${index}`);
1266
- const track = this.queue.remove(index);
1267
- if (track) {
1268
- this.emit("queueRemove", track, index);
1269
- }
1270
- return track;
1271
- }
1272
-
1273
- /**
1274
- * Get the progress bar of the current track
1275
- *
1276
- * @param {ProgressBarOptions} options - The options for the progress bar
1277
- * @returns {string} The progress bar
1278
- * @example
1279
- * const progressBar = player.getProgressBar();
1280
- * console.log(`Progress bar: ${progressBar}`);
1281
- */
1282
- getProgressBar(options: ProgressBarOptions = {}): string {
1283
- const { size = 20, barChar = "▬", progressChar = "🔘" } = options;
1284
- const track = this.queue.currentTrack;
1285
- const resource = this.currentResource;
1286
- if (!track || !resource) return "";
1287
-
1288
- const total = track.duration > 1000 ? track.duration : track.duration * 1000;
1289
- if (!total) return this.formatTime(resource.playbackDuration);
1290
-
1291
- const current = resource.playbackDuration;
1292
- const ratio = Math.min(current / total, 1);
1293
- const progress = Math.round(ratio * size);
1294
- const bar = barChar.repeat(progress) + progressChar + barChar.repeat(size - progress);
1295
-
1296
- return `${this.formatTime(current)} | ${bar} | ${this.formatTime(total)}`;
1297
- }
1298
-
1299
- /**
1300
- * Get the time of the current track
1301
- *
1302
- * @returns {Object} The time of the current track
1303
- * @example
1304
- * const time = player.getTime();
1305
- * console.log(`Time: ${time.current}`);
1306
- */
1307
- getTime() {
1308
- const resource = this.currentResource;
1309
- const track = this.queue.currentTrack;
1310
- if (!track || !resource)
1311
- return {
1312
- current: 0,
1313
- total: 0,
1314
- format: "00:00",
1315
- };
1316
-
1317
- const total = track.duration > 1000 ? track.duration : track.duration * 1000;
1318
-
1319
- return {
1320
- current: resource?.playbackDuration,
1321
- total: total,
1322
- format: this.formatTime(resource.playbackDuration),
1323
- };
1324
- }
1325
-
1326
- /**
1327
- * Format the time in the format of HH:MM:SS
1328
- *
1329
- * @param {number} ms - The time in milliseconds
1330
- * @returns {string} The formatted time
1331
- * @example
1332
- * const formattedTime = player.formatTime(1000);
1333
- * console.log(`Formatted time: ${formattedTime}`);
1334
- */
1335
- formatTime(ms: number): string {
1336
- const totalSeconds = Math.floor(ms / 1000);
1337
- const hours = Math.floor(totalSeconds / 3600);
1338
- const minutes = Math.floor((totalSeconds % 3600) / 60);
1339
- const seconds = totalSeconds % 60;
1340
- const parts: string[] = [];
1341
- if (hours > 0) parts.push(String(hours).padStart(2, "0"));
1342
- parts.push(String(minutes).padStart(2, "0"));
1343
- parts.push(String(seconds).padStart(2, "0"));
1344
- return parts.join(":");
1345
- }
1346
-
1347
- /**
1348
- * Destroy the player
1349
- *
1350
- * @returns {void}
1351
- * @example
1352
- * player.destroy();
1353
- */
1354
- destroy(): void {
1355
- this.debug(`[Player] destroy called`);
1356
- if (this.leaveTimeout) {
1357
- clearTimeout(this.leaveTimeout);
1358
- this.leaveTimeout = null;
1359
- }
1360
-
1361
- // Destroy current stream before stopping audio
1362
- this.destroyCurrentStream();
1363
-
1364
- this.audioPlayer.removeAllListeners();
1365
- this.audioPlayer.stop(true);
1366
-
1367
- if (this.ttsPlayer) {
1368
- try {
1369
- this.ttsPlayer.stop(true);
1370
- } catch {}
1371
- this.ttsPlayer = null;
1372
- }
1373
-
1374
- if (this.connection) {
1375
- this.connection.destroy();
1376
- this.connection = null;
1377
- }
1378
-
1379
- this.queue.clear();
1380
- this.pluginManager.clear();
1381
- this.filter.destroy();
1382
- this.extensionManager.destroy();
1383
- this.isPlaying = false;
1384
- this.isPaused = false;
1385
-
1386
- // Clear any remaining intervals
1387
- if (this.volumeInterval) {
1388
- clearInterval(this.volumeInterval);
1389
- this.volumeInterval = null;
1390
- }
1391
-
1392
- this.emit("playerDestroy");
1393
- this.removeAllListeners();
1394
- }
1395
-
1396
- //#endregion
1397
- //#region utils
1398
- private scheduleLeave(): void {
1399
- this.debug(`[Player] scheduleLeave called`);
1400
- if (this.leaveTimeout) {
1401
- clearTimeout(this.leaveTimeout);
1402
- }
1403
-
1404
- if (this.options.leaveOnEmpty && this.options.leaveTimeout) {
1405
- this.leaveTimeout = setTimeout(() => {
1406
- this.debug(`[Player] Leaving voice channel after timeoutMs`);
1407
- this.destroy();
1408
- }, this.options.leaveTimeout);
1409
- }
1410
- }
1411
-
1412
- /**
1413
- * Refesh player resource (apply filter)
1414
- *
1415
- * @param {boolean} applyToCurrent - Apply filter for curent track
1416
- * @param {number} position - Position to seek to in milliseconds
1417
- * @returns {Promise<boolean>}
1418
- * @example
1419
- * const refreshed = await player.refeshPlayerResource(true, 1000);
1420
- * console.log(`Refreshed: ${refreshed}`);
1421
- */
1422
- public async refeshPlayerResource(applyToCurrent: boolean = true, position: number = -1): Promise<boolean> {
1423
- if (!applyToCurrent || !this.queue.currentTrack || !(this.isPlaying || this.isPaused)) {
1424
- return false;
1425
- }
1426
-
1427
- try {
1428
- const track = this.queue.currentTrack;
1429
- this.debug(`[Player] Refreshing player resource for track: ${track.title}`);
1430
-
1431
- // Get current position for seeking
1432
- const currentPosition = position > 0 ? position : this.currentResource?.playbackDuration || 0;
1433
-
1434
- const streaminfo = await this.getStream(track);
1435
- if (!streaminfo?.stream) {
1436
- this.debug(`[Player] No stream to refresh`);
1437
- return false;
1438
- }
1439
-
1440
- // Create AudioResource with filters and seek to current position
1441
- const resource = await this.createResource(streaminfo, track, currentPosition);
1442
-
1443
- // Stop current playback and destroy old resource/stream
1444
- const wasPlaying = this.isPlaying;
1445
- const wasPaused = this.isPaused;
1446
-
1447
- this.audioPlayer.stop();
1448
-
1449
- // Properly destroy the old resource and stream
1450
- try {
1451
- if (this.currentResource) {
1452
- const oldStream = (this.currentResource as any)._readableState?.stream || (this.currentResource as any).stream;
1453
- if (oldStream && typeof oldStream.destroy === "function") {
1454
- oldStream.destroy();
1455
- }
1456
- }
1457
- } catch (error) {
1458
- this.debug(`[Player] Error destroying old stream in refeshPlayerResource:`, error);
1459
- }
1460
-
1461
- this.currentResource = resource;
1462
-
1463
- // Subscribe to new resource
1464
- if (this.connection) {
1465
- this.connection.subscribe(this.audioPlayer);
1466
- this.audioPlayer.play(resource);
1467
- }
1468
-
1469
- // Restore playing state
1470
- if (wasPlaying && !wasPaused) {
1471
- this.isPlaying = true;
1472
- this.isPaused = false;
1473
- } else if (wasPaused) {
1474
- this.isPlaying = false;
1475
- this.isPaused = true;
1476
- this.audioPlayer.pause();
1477
- }
1478
-
1479
- this.debug(`[Player] Successfully applied filter to current track at position ${currentPosition}ms`);
1480
- return true;
1481
- } catch (error) {
1482
- this.debug(`[Player] Error applying filter to current track:`, error);
1483
- // Filter was still added to active filters, so return true
1484
- return true;
1485
- }
1486
- }
1487
-
1488
- /**
1489
- * Attach an extension to the player
1490
- *
1491
- * @param {BaseExtension} extension - The extension to attach
1492
- * @example
1493
- * player.attachExtension(new MyExtension());
1494
- */
1495
- public attachExtension(extension: BaseExtension): void {
1496
- this.extensionManager.register(extension);
1497
- }
1498
-
1499
- /**
1500
- * Detach an extension from the player
1501
- *
1502
- * @param {BaseExtension} extension - The extension to detach
1503
- * @example
1504
- * player.detachExtension(new MyExtension());
1505
- */
1506
- public detachExtension(extension: BaseExtension): void {
1507
- this.extensionManager.unregister(extension);
1508
- }
1509
-
1510
- /**
1511
- * Get all extensions attached to the player
1512
- *
1513
- * @returns {readonly BaseExtension[]} All attached extensions
1514
- * @example
1515
- * const extensions = player.getExtensions();
1516
- * console.log(`Extensions: ${extensions.length}`);
1517
- */
1518
- public getExtensions(): readonly BaseExtension[] {
1519
- return this.extensionManager.getAll();
1520
- }
1521
-
1522
- private clearLeaveTimeout(): void {
1523
- if (this.leaveTimeout) {
1524
- clearTimeout(this.leaveTimeout);
1525
- this.leaveTimeout = null;
1526
- this.debug(`[Player] Cleared leave timeoutMs`);
1527
- }
1528
- }
1529
-
1530
- private debug(message?: any, ...optionalParams: any[]): void {
1531
- if (this.listenerCount("debug") > 0) {
1532
- this.emit("debug", message, ...optionalParams);
1533
- }
1534
- }
1535
-
1536
- private setupEventListeners(): void {
1537
- this.audioPlayer.on("stateChange", (oldState, newState) => {
1538
- this.debug(`[Player] AudioPlayer stateChange from ${oldState.status} to ${newState.status}`);
1539
- if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) {
1540
- // Track ended
1541
- const track = this.queue.currentTrack;
1542
- if (track) {
1543
- this.debug(`[Player] Track ended: ${track.title}`);
1544
- this.emit("trackEnd", track);
1545
- }
1546
- this.playNext();
1547
- } else if (
1548
- newState.status === AudioPlayerStatus.Playing &&
1549
- (oldState.status === AudioPlayerStatus.Idle || oldState.status === AudioPlayerStatus.Buffering)
1550
- ) {
1551
- // Track started
1552
- this.clearLeaveTimeout();
1553
- this.isPlaying = true;
1554
- this.isPaused = false;
1555
- const track = this.queue.currentTrack;
1556
- if (track) {
1557
- this.debug(`[Player] Track started: ${track.title}`);
1558
- this.emit("trackStart", track);
1559
- }
1560
- } else if (newState.status === AudioPlayerStatus.Paused && oldState.status !== AudioPlayerStatus.Paused) {
1561
- // Track paused
1562
- this.isPaused = true;
1563
- const track = this.queue.currentTrack;
1564
- if (track) {
1565
- this.debug(`[Player] Player paused on track: ${track.title}`);
1566
- this.emit("playerPause", track);
1567
- }
1568
- } else if (newState.status !== AudioPlayerStatus.Paused && oldState.status === AudioPlayerStatus.Paused) {
1569
- // Track resumed
1570
- this.isPaused = false;
1571
- const track = this.queue.currentTrack;
1572
- if (track) {
1573
- this.debug(`[Player] Player resumed on track: ${track.title}`);
1574
- this.emit("playerResume", track);
1575
- }
1576
- } else if (newState.status === AudioPlayerStatus.AutoPaused) {
1577
- this.debug(`[Player] AudioPlayerStatus.AutoPaused`);
1578
- } else if (newState.status === AudioPlayerStatus.Buffering) {
1579
- this.debug(`[Player] AudioPlayerStatus.Buffering`);
1580
- }
1581
- });
1582
- this.audioPlayer.on("error", (error) => {
1583
- this.debug(`[Player] AudioPlayer error:`, error);
1584
- this.emit("playerError", error, this.queue.currentTrack || undefined);
1585
- this.playNext();
1586
- });
1587
-
1588
- this.audioPlayer.on("debug", (...args) => {
1589
- if (this.manager.debugEnabled) {
1590
- this.emit("debug", ...args);
1591
- }
1592
- });
1593
- }
1594
-
1595
- addPlugin(plugin: SourcePlugin): void {
1596
- this.debug(`[Player] Adding plugin: ${plugin.name}`);
1597
- this.pluginManager.register(plugin);
1598
- }
1599
-
1600
- removePlugin(name: string): boolean {
1601
- this.debug(`[Player] Removing plugin: ${name}`);
1602
- return this.pluginManager.unregister(name);
1603
- }
1604
-
1605
- //#endregion
1606
- //#region Getters
1607
-
1608
- /**
1609
- * Get the size of the queue
1610
- *
1611
- * @returns {number} The size of the queue
1612
- * @example
1613
- * const queueSize = player.queueSize;
1614
- * console.log(`Queue size: ${queueSize}`);
1615
- */
1616
- get queueSize(): number {
1617
- return this.queue.size;
1618
- }
1619
-
1620
- /**
1621
- * Get the current track
1622
- *
1623
- * @returns {Track | null} The current track or null
1624
- * @example
1625
- * const currentTrack = player.currentTrack;
1626
- * console.log(`Current track: ${currentTrack?.title}`);
1627
- */
1628
- get currentTrack(): Track | null {
1629
- return this.queue.currentTrack;
1630
- }
1631
-
1632
- /**
1633
- * Get the previous track
1634
- *
1635
- * @returns {Track | null} The previous track or null
1636
- * @example
1637
- * const previousTrack = player.previousTrack;
1638
- * console.log(`Previous track: ${previousTrack?.title}`);
1639
- */
1640
- get previousTrack(): Track | null {
1641
- return this.queue.previousTracks?.at(-1) ?? null;
1642
- }
1643
-
1644
- /**
1645
- * Get the upcoming tracks
1646
- *
1647
- * @returns {Track[]} The upcoming tracks
1648
- * @example
1649
- * const upcomingTracks = player.upcomingTracks;
1650
- * console.log(`Upcoming tracks: ${upcomingTracks.length}`);
1651
- */
1652
- get upcomingTracks(): Track[] {
1653
- return this.queue.getTracks();
1654
- }
1655
-
1656
- /**
1657
- * Get the previous tracks
1658
- *
1659
- * @returns {Track[]} The previous tracks
1660
- * @example
1661
- * const previousTracks = player.previousTracks;
1662
- * console.log(`Previous tracks: ${previousTracks.length}`);
1663
- */
1664
- get previousTracks(): Track[] {
1665
- return this.queue.previousTracks;
1666
- }
1667
-
1668
- /**
1669
- * Get the available plugins
1670
- *
1671
- * @returns {string[]} The available plugins
1672
- * @example
1673
- * const availablePlugins = player.availablePlugins;
1674
- * console.log(`Available plugins: ${availablePlugins.length}`);
1675
- */
1676
- get availablePlugins(): string[] {
1677
- return this.pluginManager.getAll().map((p) => p.name);
1678
- }
1679
-
1680
- /**
1681
- * Get the related tracks
1682
- *
1683
- * @returns {Track[] | null} The related tracks or null
1684
- * @example
1685
- * const relatedTracks = player.relatedTracks;
1686
- * console.log(`Related tracks: ${relatedTracks?.length}`);
1687
- */
1688
- get relatedTracks(): Track[] | null {
1689
- return this.queue.relatedTracks();
1690
- }
1691
-
1692
- //#endregion
1693
- }
1
+ import { EventEmitter } from "events";
2
+ import {
3
+ createAudioPlayer,
4
+ createAudioResource,
5
+ entersState,
6
+ AudioPlayerStatus,
7
+ VoiceConnection,
8
+ AudioPlayer as DiscordAudioPlayer,
9
+ VoiceConnectionStatus,
10
+ NoSubscriberBehavior,
11
+ joinVoiceChannel,
12
+ AudioResource,
13
+ StreamType,
14
+ } from "@discordjs/voice";
15
+
16
+ import { Readable } from "stream";
17
+ import { LRUCache } from "lru-cache";
18
+ import type { BaseExtension } from "../extensions";
19
+ import type {
20
+ Track,
21
+ PlayerOptions,
22
+ PlayerEvents,
23
+ SourcePlugin,
24
+ SearchResult,
25
+ ProgressBarOptions,
26
+ LoopMode,
27
+ StreamInfo,
28
+ SaveOptions,
29
+ VoiceChannel,
30
+ PlayerSession,
31
+ ExtensionPlayRequest,
32
+ ExtensionPlayResponse,
33
+ ExtensionAfterPlayPayload,
34
+ PreloadState,
35
+ StreamSlot,
36
+ } from "../types";
37
+ import type { PlayerManager } from "./PlayerManager";
38
+
39
+ import { Queue } from "./Queue";
40
+ import { PluginManager } from "../plugins";
41
+ import { ExtensionManager } from "../extensions";
42
+ import { withTimeout } from "../utils/timeout";
43
+ import { FilterManager } from "./FilterManager";
44
+ import { StreamManager } from "./StreamManager";
45
+
46
+ export declare interface Player {
47
+ on<K extends keyof PlayerEvents>(event: K, listener: (...args: PlayerEvents[K]) => void): this;
48
+ emit<K extends keyof PlayerEvents>(event: K, ...args: PlayerEvents[K]): boolean;
49
+ }
50
+
51
+ /**
52
+ * Represents a music player for a specific Discord guild.
53
+ *
54
+ * @example
55
+ * // Create and configure player
56
+ * const player = await manager.create(guildId, {
57
+ * tts: { interrupt: true, volume: 1 },
58
+ * leaveOnEnd: true,
59
+ * leaveTimeout: 30000
60
+ * });
61
+ *
62
+ * // Connect to voice channel
63
+ * await player.connect(voiceChannel);
64
+ *
65
+ * // Play different types of content
66
+ * await player.play("Never Gonna Give You Up", userId); // Search query
67
+ * await player.play("https://youtube.com/watch?v=dQw4w9WgXcQ", userId); // Direct URL
68
+ * await player.play("tts: Hello everyone!", userId); // Text-to-Speech
69
+ *
70
+ * // Player controls
71
+ * player.pause(); // Pause current track
72
+ * player.resume(); // Resume paused track
73
+ * player.skip(); // Skip to next track
74
+ * player.stop(); // Stop and clear queue
75
+ * player.setVolume(0.5); // Set volume to 50%
76
+ *
77
+ * // Event handling
78
+ * player.on("trackStart", (player, track) => {
79
+ * console.log(`Now playing: ${track.title}`);
80
+ * });
81
+ *
82
+ * player.on("queueEnd", (player) => {
83
+ * console.log("Queue finished");
84
+ * });
85
+ *
86
+ */
87
+ export class Player extends EventEmitter {
88
+ public readonly guildId: string;
89
+ public connection: VoiceConnection | null = null;
90
+ public audioPlayer: DiscordAudioPlayer;
91
+ public queue: Queue;
92
+ public volume: number = 100;
93
+ public isPlaying: boolean = false;
94
+ public isPaused: boolean = false;
95
+ public options: PlayerOptions;
96
+ public pluginManager: PluginManager;
97
+ public extensionManager: ExtensionManager;
98
+ public streamManager: StreamManager;
99
+
100
+ public userdata?: Record<string, any>;
101
+ public _lastActivity: number = Date.now();
102
+ private manager: PlayerManager;
103
+ private leaveTimeout: NodeJS.Timeout | null = null;
104
+ private currentResource: AudioResource | null = null;
105
+ private volumeInterval: NodeJS.Timeout | null = null;
106
+ private stuckTimer: NodeJS.Timeout | null = null;
107
+
108
+ private skipLoop = false;
109
+ private filter!: FilterManager;
110
+ private refreshLock = false;
111
+ //preloaded resource
112
+
113
+ private preloadState: PreloadState = {
114
+ resource: null,
115
+ track: null,
116
+ abortController: null,
117
+ timeoutId: null,
118
+ isValid: false,
119
+ isBeingUsed: false,
120
+ };
121
+ private isPreloading = false;
122
+ private currentSlot: StreamSlot = {
123
+ resource: null,
124
+ track: null,
125
+ streamId: null,
126
+ abortController: null,
127
+ isValid: false,
128
+ isLoading: false,
129
+ loadPromise: null,
130
+ };
131
+
132
+ private preloadSlot: StreamSlot = {
133
+ resource: null,
134
+ track: null,
135
+ streamId: null,
136
+ abortController: null,
137
+ isValid: false,
138
+ isLoading: false,
139
+ loadPromise: null,
140
+ };
141
+ private preloadLock = false;
142
+ private preloadEnabled = true;
143
+ private crossfadeEnabled = true;
144
+ private crossfadeDurationMs = 500;
145
+ private lowPerformanceMode = false;
146
+ private crossfadeTransitionLock = false;
147
+ private smartTransitionEnabled = true;
148
+ private smartTransitionGenreAware = true;
149
+ private smartTransitionBeatAlign = true;
150
+ private smartTransitionBaseMs = 800;
151
+ private smartTransitionMinMs = 120;
152
+ private smartTransitionMaxMs = 8000;
153
+ private smartTransitionGenreDurations: Record<string, number> = {
154
+ chill: 700,
155
+ ambient: 750,
156
+ lofi: 650,
157
+ pop: 450,
158
+ rock: 350,
159
+ edm: 220,
160
+ house: 250,
161
+ techno: 200,
162
+ };
163
+ private smartTransitionBeatAlignMaxWaitMs = 180;
164
+ private antiStuckEnabled = true;
165
+ private antiStuckMaxRetries = 2;
166
+ private antiStuckRetryDelayMs = 900;
167
+ private antiStuckReusePreloadFirst = true;
168
+ private antiStuckReduceQualityOnRetry = true;
169
+ private antiStuckControlledSkipThreshold = 3;
170
+ private antiStuckConsecutiveFailures = 0;
171
+ private loudnessNormalizationEnabled = false;
172
+ private loudnessTargetLUFS = -14;
173
+ private loudnessMaxBoostDb = 8;
174
+ private loudnessMaxCutDb = 10;
175
+ private loudnessLimiterCeiling = 0.95;
176
+
177
+ // Cache for search results to avoid duplicate calls
178
+ private searchCache: LRUCache<string, SearchResult>;
179
+ private readonly SEARCH_CACHE_TTL = 2 * 60 * 1000; // 2 minutes
180
+ private ttsPlayer: DiscordAudioPlayer | null = null;
181
+ private lastDuration: number = 0;
182
+
183
+ constructor(guildId: string, options: PlayerOptions = {}, manager: PlayerManager) {
184
+ super();
185
+ this.debug(`[Player] Constructor called for guildId: ${guildId}`);
186
+ this.guildId = guildId;
187
+ this.queue = new Queue();
188
+ this.manager = manager;
189
+ this.audioPlayer = createAudioPlayer({
190
+ behaviors: {
191
+ noSubscriber: NoSubscriberBehavior.Pause,
192
+ maxMissedFrames: 100,
193
+ },
194
+ });
195
+
196
+ this.options = {
197
+ leaveOnEnd: true,
198
+ leaveOnEmpty: true,
199
+ leaveTimeout: 100000,
200
+ volume: 100,
201
+ quality: "high",
202
+ extractorTimeout: 50000,
203
+ selfDeaf: true,
204
+ selfMute: false,
205
+ ...options,
206
+ tts: {
207
+ createPlayer: false,
208
+ interrupt: true,
209
+ volume: 100,
210
+ maxTimeTts: 60_000,
211
+ ...(options?.tts || {}),
212
+ },
213
+ };
214
+ this.lowPerformanceMode = this.options.lowPerformance ?? this.options.quality === "low";
215
+
216
+ const preloadOptions = this.options.preload || {};
217
+ const preloadAutoDisable = preloadOptions.autoDisableInLowPerformance ?? true;
218
+ this.preloadEnabled = preloadOptions.enabled ?? true;
219
+ if (this.lowPerformanceMode && preloadAutoDisable) {
220
+ this.preloadEnabled = false;
221
+ }
222
+
223
+ const crossfadeOptions = this.options.crossfade || {};
224
+ const crossfadeAutoEnable = crossfadeOptions.autoEnable ?? true;
225
+ const crossfadeAutoDisable = crossfadeOptions.autoDisableInLowPerformance ?? true;
226
+ this.crossfadeDurationMs = Math.max(0, crossfadeOptions.durationMs ?? 5000);
227
+
228
+ if (typeof crossfadeOptions.enabled === "boolean") {
229
+ this.crossfadeEnabled = crossfadeOptions.enabled;
230
+ } else {
231
+ this.crossfadeEnabled = crossfadeAutoEnable;
232
+ }
233
+
234
+ if (this.lowPerformanceMode && crossfadeAutoDisable) {
235
+ this.crossfadeEnabled = false;
236
+ }
237
+
238
+ const smartTransitionOptions = this.options.smartTransition || {};
239
+ this.smartTransitionEnabled = smartTransitionOptions.enabled ?? true;
240
+ this.smartTransitionGenreAware = smartTransitionOptions.genreAware ?? true;
241
+ this.smartTransitionBeatAlign = smartTransitionOptions.beatAlign ?? true;
242
+ this.smartTransitionBaseMs = Math.max(0, smartTransitionOptions.baseDurationMs ?? this.crossfadeDurationMs);
243
+ this.smartTransitionMinMs = Math.max(0, smartTransitionOptions.minDurationMs ?? 1200);
244
+ this.smartTransitionMaxMs = Math.max(this.smartTransitionMinMs, smartTransitionOptions.maxDurationMs ?? 8000);
245
+ this.smartTransitionBeatAlignMaxWaitMs = Math.max(0, smartTransitionOptions.beatAlignMaxWaitMs ?? 1200);
246
+ this.smartTransitionGenreDurations = {
247
+ ...this.smartTransitionGenreDurations,
248
+ ...(smartTransitionOptions.genreDurations || {}),
249
+ };
250
+
251
+ const antiStuckOptions = this.options.antiStuck || {};
252
+ this.antiStuckEnabled = antiStuckOptions.enabled ?? true;
253
+ this.antiStuckMaxRetries = Math.max(0, antiStuckOptions.maxRetries ?? 2);
254
+ this.antiStuckRetryDelayMs = Math.max(0, antiStuckOptions.retryDelayMs ?? 900);
255
+ this.antiStuckReusePreloadFirst = antiStuckOptions.reusePreloadFirst ?? true;
256
+ this.antiStuckReduceQualityOnRetry = antiStuckOptions.reduceQualityOnRetry ?? true;
257
+ this.antiStuckControlledSkipThreshold = Math.max(1, antiStuckOptions.controlledSkipThreshold ?? 3);
258
+
259
+ const loudnessOptions = this.options.loudnessNormalization || {};
260
+ this.loudnessNormalizationEnabled = loudnessOptions.enabled ?? false;
261
+ this.loudnessTargetLUFS = loudnessOptions.targetLUFS ?? -14;
262
+ this.loudnessMaxBoostDb = Math.max(0, loudnessOptions.maxBoostDb ?? 8);
263
+ this.loudnessMaxCutDb = Math.max(0, loudnessOptions.maxCutDb ?? 10);
264
+ this.loudnessLimiterCeiling = Math.min(1, Math.max(0.1, loudnessOptions.limiterCeiling ?? 0.95));
265
+
266
+ this.debug(
267
+ `[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}`,
268
+ );
269
+ this.filter = new FilterManager(this, this.manager);
270
+ this.extensionManager = new ExtensionManager(this, this.manager);
271
+ this.pluginManager = new PluginManager(this, this.manager, {
272
+ extractorTimeout: this.options.extractorTimeout,
273
+ });
274
+ this.streamManager = new StreamManager({
275
+ maxConcurrentStreams: 20,
276
+ streamTimeout: 5 * 60 * 1000,
277
+ maxListenersPerStream: 15,
278
+ enableMetrics: true,
279
+ autoDestroy: true,
280
+ });
281
+ this.volume = this.options.volume || 100;
282
+ this.userdata = this.options.userdata;
283
+ this.searchCache = new LRUCache<string, SearchResult>({
284
+ max: 200,
285
+ ttl: this.SEARCH_CACHE_TTL,
286
+ dispose: (value, key, reason) => {
287
+ if (this.listenerCount("debug") > 0) {
288
+ this.debug(`[SearchCache] Disposed cache entry: ${key}, reason: ${reason}`);
289
+ }
290
+ },
291
+ allowStale: false,
292
+ updateAgeOnGet: true,
293
+ });
294
+
295
+ this.setupEventListeners();
296
+
297
+ // Initialize filters from options
298
+ if (this.options.filters && this.options.filters.length > 0) {
299
+ this.debug(`[Player] Initializing ${this.options.filters.length} filters from options`);
300
+ // Use async version but don't await in constructor
301
+ this.filter.applyFilters(this.options.filters).catch((error: any) => {
302
+ this.debug(`[Player] Error initializing filters:`, error);
303
+ });
304
+ }
305
+
306
+ // Optionally pre-create the TTS AudioPlayer
307
+ if (this.options?.tts?.createPlayer) {
308
+ this.ensureTTSPlayer();
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Destroy current stream to prevent memory leaks
314
+ * @private
315
+ */
316
+ private destroyCurrentStream(): void {
317
+ this.audioPlayer.stop(true);
318
+ if (!this.currentResource) return;
319
+
320
+ const stream = (this.currentResource as any)?.metadata?.stream ?? (this.currentResource as any)?.stream;
321
+
322
+ if (stream && typeof stream.destroy === "function") {
323
+ stream.destroy().catch((e: any) => this.debug("Stream destroy error:", e));
324
+ }
325
+
326
+ this.currentResource = null;
327
+ }
328
+
329
+ //#region Search
330
+
331
+ /**
332
+ * Search for tracks using the player's extensions and plugins
333
+ *
334
+ * @param {string} query - The query to search for
335
+ * @param {string} requestedBy - The user ID who requested the search
336
+ * @returns {Promise<SearchResult>} The search result
337
+ * @example
338
+ * const result = await player.search("Never Gonna Give You Up", userId);
339
+ * console.log(`Search result: ${result.tracks.length} tracks`);
340
+ */
341
+ async search(query: string, requestedBy: string): Promise<SearchResult> {
342
+ this.debug(`[Player] Search called with query: ${query}, requestedBy: ${requestedBy}`);
343
+
344
+ // Check player cache first (LRU)
345
+ const cachedResult = this.getCachedSearchResult(query);
346
+ if (cachedResult) {
347
+ return cachedResult;
348
+ }
349
+
350
+ // Try extensions first
351
+ const extensionResult = await this.extensionManager.provideSearch(query, requestedBy);
352
+ if (extensionResult && Array.isArray(extensionResult.tracks) && extensionResult.tracks.length > 0) {
353
+ this.debug(`[Player] Extension handled search for query: ${query}`);
354
+ this.cacheSearchResult(query, extensionResult);
355
+ return extensionResult;
356
+ }
357
+
358
+ // Use PluginManager for search with deduplication and evaluation
359
+ const pluginResult = await this.pluginManager.search(query, requestedBy);
360
+
361
+ if (pluginResult && pluginResult.tracks.length > 0) {
362
+ this.debug(`[Player] Plugin search returned ${pluginResult.tracks.length} tracks (score: ${pluginResult.score?.score}%)`);
363
+
364
+ if (pluginResult.score) {
365
+ this.debug(`[Player] Search evaluation - ${pluginResult.score.reason}`);
366
+ }
367
+
368
+ this.cacheSearchResult(query, pluginResult);
369
+ return pluginResult;
370
+ }
371
+
372
+ this.debug(`[Player] No search results for query: ${query}`);
373
+ throw new Error(`No results found for: ${query}`);
374
+ }
375
+
376
+ /**
377
+ * Get cached search result or null if not found/expired
378
+ * @param query The search query
379
+ * @returns Cached search result or null
380
+ */
381
+ private getCachedSearchResult(query: string): SearchResult | null {
382
+ const cacheKey = query.toLowerCase().trim();
383
+ const cached = this.searchCache.get(cacheKey);
384
+ if (cached) {
385
+ this.debug(`[SearchCache] Using cached search result for: ${query}`);
386
+ return cached;
387
+ }
388
+
389
+ return null;
390
+ }
391
+
392
+ /**
393
+ * Cache search result
394
+ * @param query The search query
395
+ * @param result The search result to cache
396
+ */
397
+ private cacheSearchResult(query: string, result: SearchResult): void {
398
+ const cacheKey = query.toLowerCase().trim();
399
+ this.searchCache.set(cacheKey, result);
400
+ this.debug(`[SearchCache] Cached search result for: ${query} (${result.tracks.length} tracks)`);
401
+ }
402
+
403
+ /**
404
+ * Clear expired search cache entries
405
+ */
406
+ private clearExpiredSearchCache(): void {
407
+ this.searchCache.purgeStale();
408
+ this.debug(`[SearchCache] Purged stale search cache entries`);
409
+ }
410
+
411
+ /**
412
+ * Clear all search cache entries
413
+ * @example
414
+ * player.clearSearchCache();
415
+ */
416
+ public clearSearchCache(): void {
417
+ const cacheSize = this.searchCache.size;
418
+ this.searchCache.clear();
419
+ this.debug(`[SearchCache] Cleared all ${cacheSize} search cache entries`);
420
+ }
421
+
422
+ /**
423
+ * Debug method to check for duplicate search calls
424
+ * @param query The search query to check
425
+ * @returns Debug information about the query
426
+ */
427
+ public debugSearchQuery(query: string): {
428
+ isCached: boolean;
429
+ cacheAge?: number;
430
+ pluginCount: number;
431
+ ttsFiltered: boolean;
432
+ } {
433
+ const cacheKey = query.toLowerCase().trim();
434
+ const cached = this.searchCache.get(cacheKey);
435
+ const isCached = !!cached;
436
+
437
+ const allPlugins = this.pluginManager.getAll();
438
+ const plugins = allPlugins.filter((p) => {
439
+ if (p.name.toLowerCase() === "tts" && !query.toLowerCase().startsWith("tts:")) {
440
+ return false;
441
+ }
442
+ return true;
443
+ });
444
+
445
+ return {
446
+ isCached,
447
+ cacheAge: undefined,
448
+ pluginCount: plugins.length,
449
+ ttsFiltered: allPlugins.length > plugins.length,
450
+ };
451
+ }
452
+
453
+ private async generateWillNext(): Promise<void> {
454
+ const lastTrack = this.queue.previousTracks[this.queue.previousTracks.length - 1] ?? this.queue.currentTrack;
455
+ if (!lastTrack) return;
456
+ const related = await this.pluginManager.getRelatedTracks(lastTrack);
457
+ if (!related || related.length === 0) return;
458
+ const randomchoice = Math.floor(Math.random() * related.length);
459
+ const nextTrack = this.queue.nextTrack ? this.queue.nextTrack : related[randomchoice];
460
+ this.queue.willNextTrack(nextTrack);
461
+ this.queue.relatedTracks(related);
462
+ this.debug(`[Player] Will next track if autoplay: ${nextTrack?.title}]`);
463
+ this.emit("willPlay", nextTrack, related);
464
+ }
465
+ //#endregion
466
+ //#region Play
467
+
468
+ /**
469
+ * Play a track, search query, search result, or play from queue
470
+ *
471
+ * @param {string | Track | SearchResult | null} query - Track URL, search query, Track object, SearchResult, or null for play
472
+ * @param {string} requestedBy - User ID who requested the track
473
+ * @returns {Promise<boolean>} True if playback started successfully
474
+ * @example
475
+ * await player.play("Never Gonna Give You Up", userId); // Search query
476
+ * await player.play("https://youtube.com/watch?v=dQw4w9WgXcQ", userId); // Direct URL
477
+ * await player.play("tts: Hello everyone!", userId); // Text-to-Speech
478
+ * await player.play(trackObject, userId); // Track object
479
+ * await player.play(searchResult, userId); // SearchResult object
480
+ * await player.play(null); // play from queue
481
+ */
482
+ async play(query: string | Track | SearchResult | null, requestedBy?: string): Promise<boolean> {
483
+ const debugInfo =
484
+ query === null ? "null"
485
+ : typeof query === "string" ? query
486
+ : "tracks" in query ? `${query.tracks.length} tracks`
487
+ : query.title || "unknown";
488
+ this.debug(`[Player] Play called with query: ${debugInfo}`);
489
+ this.clearLeaveTimeout();
490
+ let tracksToAdd: Track[] = [];
491
+ let isPlaylist = false;
492
+ let effectiveRequest: ExtensionPlayRequest = { query: query as string | Track, requestedBy };
493
+ let hookResponse: ExtensionPlayResponse = {};
494
+
495
+ try {
496
+ // Handle null query - play from queue
497
+ if (query === null) {
498
+ this.debug(`[Player] Play from queue requested`);
499
+ if (this.queue.isEmpty) {
500
+ this.debug(`[Player] Queue is empty, nothing to play`);
501
+ return false;
502
+ }
503
+
504
+ if (!this.isPlaying) {
505
+ return await this.playNext();
506
+ }
507
+ return true;
508
+ }
509
+
510
+ // Handle SearchResult
511
+ if (query && typeof query === "object" && "tracks" in query && Array.isArray(query.tracks)) {
512
+ this.debug(`[Player] Playing SearchResult with ${query.tracks.length} tracks`);
513
+ tracksToAdd = query.tracks;
514
+ isPlaylist = !!query.playlist || query.tracks.length > 1;
515
+
516
+ if (query.playlist) {
517
+ this.debug(`[Player] Added playlist: ${query.playlist.name} (${tracksToAdd.length} tracks)`);
518
+ }
519
+ } else {
520
+ // Handle other types (string, Track)
521
+ const hookOutcome = await this.extensionManager.beforePlayHooks(effectiveRequest);
522
+ effectiveRequest = hookOutcome.request;
523
+ hookResponse = hookOutcome.response;
524
+ if (effectiveRequest.requestedBy === undefined) {
525
+ effectiveRequest.requestedBy = requestedBy;
526
+ }
527
+
528
+ const hookTracks = Array.isArray(hookResponse.tracks) ? hookResponse.tracks : undefined;
529
+
530
+ if (hookResponse.handled && (!hookTracks || hookTracks.length === 0)) {
531
+ const handledPayload: ExtensionAfterPlayPayload = {
532
+ success: hookResponse.success ?? true,
533
+ query: effectiveRequest.query,
534
+ requestedBy: effectiveRequest.requestedBy,
535
+ tracks: [],
536
+ isPlaylist: hookResponse.isPlaylist ?? false,
537
+ error: hookResponse.error,
538
+ };
539
+ await this.extensionManager.afterPlayHooks(handledPayload);
540
+ if (hookResponse.error) {
541
+ this.emit("playerError", hookResponse.error);
542
+ }
543
+ return hookResponse.success ?? true;
544
+ }
545
+
546
+ if (hookTracks && hookTracks.length > 0) {
547
+ tracksToAdd = hookTracks;
548
+ isPlaylist = hookResponse.isPlaylist ?? hookTracks.length > 1;
549
+ } else if (typeof effectiveRequest.query === "string") {
550
+ const searchResult = await this.search(effectiveRequest.query, effectiveRequest.requestedBy || "Unknown");
551
+ tracksToAdd = searchResult.tracks;
552
+ if (searchResult.playlist) {
553
+ isPlaylist = true;
554
+ this.debug(`[Player] Added playlist: ${searchResult.playlist.name} (${tracksToAdd.length} tracks)`);
555
+ }
556
+ } else if (effectiveRequest.query) {
557
+ tracksToAdd = [effectiveRequest.query as Track];
558
+ }
559
+ }
560
+
561
+ if (tracksToAdd.length === 0) {
562
+ this.debug(`[Player] No tracks found for play`);
563
+ throw new Error("No tracks found");
564
+ }
565
+
566
+ const isTTS = (t: Track | undefined) => {
567
+ if (!t) return false;
568
+ try {
569
+ return typeof t.source === "string" && t.source.toLowerCase().includes("tts");
570
+ } catch {
571
+ return false;
572
+ }
573
+ };
574
+
575
+ const queryLooksTTS =
576
+ typeof effectiveRequest.query === "string" && effectiveRequest.query.trim().toLowerCase().startsWith("tts");
577
+
578
+ if (
579
+ !isPlaylist &&
580
+ tracksToAdd.length > 0 &&
581
+ this.options?.tts?.interrupt !== false &&
582
+ (isTTS(tracksToAdd[0]) || queryLooksTTS)
583
+ ) {
584
+ this.debug(`[Player] Interrupting with TTS: ${tracksToAdd[0].title}`);
585
+ await this.interruptWithTTSTrack(tracksToAdd[0]);
586
+ await this.extensionManager.afterPlayHooks({
587
+ success: true,
588
+ query: effectiveRequest.query,
589
+ requestedBy: effectiveRequest.requestedBy,
590
+ tracks: tracksToAdd,
591
+ isPlaylist,
592
+ });
593
+ return true;
594
+ }
595
+
596
+ if (isPlaylist) {
597
+ this.queue.addMultiple(tracksToAdd);
598
+ this.emit("queueAddList", tracksToAdd);
599
+ } else {
600
+ this.queue.add(tracksToAdd[0]);
601
+ this.emit("queueAdd", tracksToAdd[0]);
602
+ }
603
+
604
+ const started = !this.isPlaying ? await this.playNext() : true;
605
+
606
+ await this.extensionManager.afterPlayHooks({
607
+ success: started,
608
+ query: effectiveRequest.query,
609
+ requestedBy: effectiveRequest.requestedBy,
610
+ tracks: tracksToAdd,
611
+ isPlaylist,
612
+ });
613
+
614
+ return started;
615
+ } catch (error) {
616
+ await this.extensionManager.afterPlayHooks({
617
+ success: false,
618
+ query: effectiveRequest.query,
619
+ requestedBy: effectiveRequest.requestedBy,
620
+ tracks: tracksToAdd,
621
+ isPlaylist,
622
+ error: error as Error,
623
+ });
624
+ this.debug(`[Player] Play error:`, error);
625
+ this.emit("playerError", error as Error);
626
+ return false;
627
+ }
628
+ }
629
+ //#endregion
630
+ //#region Preload
631
+ /**
632
+ * Main preload method - only one at a time
633
+ */
634
+ private async preloadNextTrack(): Promise<void> {
635
+ if (!this.preloadEnabled) {
636
+ this.debug(`[Preload] Disabled by options/runtime profile`);
637
+ return;
638
+ }
639
+
640
+ // Prevent concurrent preloads
641
+ if (this.preloadLock) {
642
+ this.debug(`[Preload] Already preloading, skipping`);
643
+ return;
644
+ }
645
+
646
+ const nextTrack = this.queue.nextTrack;
647
+ if (!nextTrack) {
648
+ this.debug(`[Preload] No next track to preload`);
649
+ return;
650
+ }
651
+
652
+ // Check if already preloaded correctly
653
+ if (this.preloadSlot.isValid && this.preloadSlot.track?.id === nextTrack.id && this.preloadSlot.resource) {
654
+ this.debug(`[Preload] Already have valid preload for: ${nextTrack.title}`);
655
+ return;
656
+ }
657
+
658
+ // Check if currently loading the same track
659
+ if (this.preloadSlot.isLoading && this.preloadSlot.track?.id === nextTrack.id) {
660
+ this.debug(`[Preload] Currently loading same track, waiting...`);
661
+ if (this.preloadSlot.loadPromise) {
662
+ await this.preloadSlot.loadPromise;
663
+ }
664
+ return;
665
+ }
666
+
667
+ // Cancel old preload if different track
668
+ if (this.preloadSlot.isValid && this.preloadSlot.track?.id !== nextTrack.id) {
669
+ this.debug(`[Preload] Cancelling old preload for different track: ${this.preloadSlot.track?.title}`);
670
+ await this.safeCancelPreload();
671
+ }
672
+
673
+ this.preloadLock = true;
674
+
675
+ // Create new abort controller
676
+ const abortController = new AbortController();
677
+
678
+ // Setup preload slot
679
+ this.preloadSlot.track = nextTrack;
680
+ this.preloadSlot.abortController = abortController;
681
+ this.preloadSlot.isLoading = true;
682
+
683
+ // Create load promise
684
+ const loadPromise = this.executePreload(nextTrack, abortController);
685
+ this.preloadSlot.loadPromise = loadPromise;
686
+
687
+ try {
688
+ await loadPromise;
689
+ } catch (err) {
690
+ if (err instanceof Error && err.message === "PRELOAD_CANCELLED") {
691
+ this.debug(`[Preload] Cancelled for ${nextTrack.title}`);
692
+ } else {
693
+ this.debug(`[Preload] Failed for ${nextTrack.title}:`, err);
694
+ }
695
+ this.clearSlot(this.preloadSlot);
696
+ } finally {
697
+ this.preloadLock = false;
698
+ this.preloadSlot.isLoading = false;
699
+ this.preloadSlot.loadPromise = null;
700
+ }
701
+ }
702
+
703
+ /**
704
+ * Execute actual preload
705
+ */
706
+ private async executePreload(track: Track, abortController: AbortController): Promise<void> {
707
+ this.debug(`[Preload] Starting preload for: ${track.title}`);
708
+
709
+ // Check for cancellation
710
+ if (abortController.signal.aborted) {
711
+ throw new Error("PRELOAD_CANCELLED");
712
+ }
713
+
714
+ // Check if track still relevant
715
+ if (this.queue.nextTrack?.id !== track.id) {
716
+ this.debug(`[Preload] Track changed, cancelling`);
717
+ throw new Error("PRELOAD_CANCELLED");
718
+ }
719
+
720
+ try {
721
+ // Get stream with abort support - NO TIMEOUT
722
+ const streamInfo = await this.getStreamWithCancel(track, abortController.signal);
723
+
724
+ // Check cancellation
725
+ if (abortController.signal.aborted) {
726
+ throw new Error("PRELOAD_CANCELLED");
727
+ }
728
+
729
+ // Check track relevance again
730
+ if (this.queue.nextTrack?.id !== track.id) {
731
+ this.debug(`[Preload] Track changed after stream fetch`);
732
+ throw new Error("PRELOAD_CANCELLED");
733
+ }
734
+
735
+ if (!streamInfo?.stream) {
736
+ throw new Error(`No stream available`);
737
+ }
738
+
739
+ // Register with StreamManager as preload
740
+ const streamId = this.streamManager.registerStream(streamInfo.stream, track, {
741
+ source: track.source || "preload",
742
+ isPreload: true,
743
+ priority: 5,
744
+ });
745
+
746
+ // Create resource
747
+ const resource = createAudioResource(streamInfo.stream, {
748
+ inlineVolume: true,
749
+ metadata: { ...track, preloaded: true },
750
+ });
751
+
752
+ // Verify resource is valid
753
+ if (!resource.playStream || resource.playStream.readable === false) {
754
+ throw new Error("Resource not readable");
755
+ }
756
+
757
+ // Update preload slot
758
+ this.preloadSlot.resource = resource;
759
+ this.preloadSlot.streamId = streamId;
760
+ this.preloadSlot.isValid = true;
761
+ this.preloadSlot.track = track;
762
+
763
+ this.debug(`[Preload] Successfully preloaded: ${track.title} (Stream ID: ${streamId})`);
764
+ } catch (err) {
765
+ if (err instanceof Error && err.message === "PRELOAD_CANCELLED") {
766
+ throw err;
767
+ }
768
+ this.debug(`[Preload] Error during preload:`, err);
769
+ throw err;
770
+ }
771
+ }
772
+
773
+ /**
774
+ * Safe cancel preload - doesn't throw
775
+ */
776
+ private async safeCancelPreload(): Promise<void> {
777
+ if (!this.preloadSlot.abortController && !this.preloadSlot.resource) {
778
+ return;
779
+ }
780
+
781
+ this.debug(`[Preload] Safely cancelling preload for: ${this.preloadSlot.track?.title || "unknown"}`);
782
+
783
+ // Abort the operation
784
+ if (this.preloadSlot.abortController) {
785
+ this.preloadSlot.abortController.abort();
786
+ this.preloadSlot.abortController = null;
787
+ }
788
+
789
+ // Clean up stream
790
+ if (this.preloadSlot.streamId && this.streamManager) {
791
+ this.streamManager.unregisterStream(this.preloadSlot.streamId, true);
792
+ }
793
+
794
+ // Clean up resource
795
+ if (this.preloadSlot.resource) {
796
+ try {
797
+ const stream = this.preloadSlot.resource.playStream;
798
+ if (stream && typeof stream.destroy === "function" && !stream.destroyed) {
799
+ stream.destroy();
800
+ }
801
+ } catch (err) {
802
+ // Ignore destroy errors
803
+ }
804
+ }
805
+
806
+ // Clear slot
807
+ this.clearSlot(this.preloadSlot);
808
+ }
809
+
810
+ /**
811
+ * Get stream with proper cancellation
812
+ */
813
+ private async getStreamWithCancel(track: Track, signal: AbortSignal): Promise<StreamInfo | null> {
814
+ // Create abort promise
815
+ const abortPromise = new Promise<never>((_, reject) => {
816
+ if (signal.aborted) {
817
+ reject(new Error("PRELOAD_CANCELLED"));
818
+ return;
819
+ }
820
+ const handler = () => {
821
+ signal.removeEventListener("abort", handler);
822
+ reject(new Error("PRELOAD_CANCELLED"));
823
+ };
824
+ signal.addEventListener("abort", handler);
825
+ });
826
+
827
+ try {
828
+ // Check if stream already exists and is valid
829
+ const existingStream = this.streamManager.getStreamByTrack(track.id || track.title);
830
+ if (existingStream && !existingStream.destroyed && existingStream.readable !== false) {
831
+ this.debug(`[Stream] Using existing stream for preload: ${track.title}`);
832
+ return { stream: existingStream, type: "arbitrary" };
833
+ }
834
+
835
+ // Race between stream fetch and abort
836
+ const streamPromise = this.getStream(track);
837
+ const result = await Promise.race([streamPromise, abortPromise]);
838
+ return result as StreamInfo | null;
839
+ } catch (err) {
840
+ if (err instanceof Error && err.message === "PRELOAD_CANCELLED") {
841
+ throw err;
842
+ }
843
+ throw err;
844
+ }
845
+ }
846
+ /**
847
+ * Preload next track with proper error handling and cleanup
848
+ */
849
+ async preloadNext(): Promise<void> {
850
+ if (!this.preloadEnabled) {
851
+ this.debug(`[Preload] Disabled by options/runtime profile`);
852
+ return;
853
+ }
854
+
855
+ this.cancelPreload();
856
+
857
+ const next = this.queue.nextTrack;
858
+ if (!next || this.isPreloading) {
859
+ this.debug(`[Preload] Skipped - ${!next ? "no next track" : "already preloading"}`);
860
+ return;
861
+ }
862
+
863
+ this.isPreloading = true;
864
+
865
+ // Create new AbortController
866
+ const abortController = new AbortController();
867
+ const timeoutId = setTimeout(() => {
868
+ // this.debug(`[Preload] Timeout for track: ${next.title}`);
869
+ // abortController.abort();
870
+ }, 30000);
871
+
872
+ this.preloadState.abortController = abortController;
873
+ this.preloadState.timeoutId = timeoutId;
874
+
875
+ try {
876
+ this.debug(`[Preload] Starting preload for: ${next.title}`);
877
+
878
+ // Check if already aborted
879
+ if (abortController.signal.aborted) {
880
+ throw new Error("Preload aborted before start");
881
+ }
882
+
883
+ // Check if this track is still the next one
884
+ if (this.queue.nextTrack?.id !== next.id) {
885
+ this.debug(`[Preload] Track changed, cancelling preload`);
886
+ return;
887
+ }
888
+
889
+ const streamInfo = await this.getStreamWithCancel(next, abortController.signal);
890
+
891
+ // Double check
892
+ if (abortController.signal.aborted) {
893
+ throw new Error("Preload aborted after stream fetch");
894
+ }
895
+
896
+ if (this.queue.nextTrack?.id !== next.id) {
897
+ this.debug(`[Preload] Track changed after stream fetch`);
898
+ return;
899
+ }
900
+
901
+ if (!streamInfo?.stream) {
902
+ throw new Error(`No stream available`);
903
+ }
904
+
905
+ // Register with StreamManager
906
+ const streamId = this.streamManager.registerStream(streamInfo.stream, next, {
907
+ source: next.source || "preload",
908
+ isPreload: true,
909
+ priority: 8,
910
+ });
911
+
912
+ // Create resource
913
+ const resource = createAudioResource(streamInfo.stream, {
914
+ inlineVolume: true,
915
+ metadata: { ...next, preloaded: true },
916
+ });
917
+
918
+ // Store preload state
919
+ this.preloadState = {
920
+ resource,
921
+ track: next,
922
+ abortController,
923
+ timeoutId,
924
+ isValid: true,
925
+ isBeingUsed: false,
926
+ streamId,
927
+ };
928
+
929
+ this.debug(`[Preload] Successfully preloaded: ${next.title} (Stream ID: ${streamId})`);
930
+ } catch (err) {
931
+ if (err instanceof Error && err.message.includes("aborted")) {
932
+ this.debug(`[Preload] Cancelled for ${next.title}`);
933
+ } else {
934
+ this.debug(`[Preload] Failed for ${next?.title}:`, err);
935
+ }
936
+ this.cancelPreload();
937
+ } finally {
938
+ this.isPreloading = false;
939
+ }
940
+ }
941
+
942
+ private async fadeResourceVolume(resource: AudioResource, from: number, to: number, durationMs: number): Promise<void> {
943
+ if (!resource.volume) return;
944
+
945
+ const safeDuration = Math.max(0, durationMs);
946
+ if (safeDuration === 0) {
947
+ resource.volume.setVolume(to);
948
+ return;
949
+ }
950
+
951
+ const steps = Math.max(1, Math.floor(safeDuration / 50));
952
+ const stepDuration = Math.max(20, Math.floor(safeDuration / steps));
953
+ const delta = (to - from) / steps;
954
+
955
+ resource.volume.setVolume(from);
956
+ for (let i = 1; i <= steps; i++) {
957
+ await new Promise((resolve) => setTimeout(resolve, stepDuration));
958
+ resource.volume.setVolume(from + delta * i);
959
+ }
960
+ }
961
+
962
+ private async applyCrossfadeIn(resource: AudioResource, track: Track): Promise<void> {
963
+ if (!this.crossfadeEnabled || !resource.volume) return;
964
+ const targetVolume = this.getTrackTargetVolume(track);
965
+ const transitionMs = this.resolveSmartTransitionDuration(track);
966
+ await this.fadeResourceVolume(resource, 0, targetVolume, transitionMs);
967
+ }
968
+
969
+ private async applyCrossfadeOutCurrent(): Promise<void> {
970
+ if (!this.crossfadeEnabled) return;
971
+ const current = this.currentSlot.resource || this.currentResource;
972
+ if (!current?.volume) return;
973
+ const currentVolume = current.volume.volume ?? this.volume / 100;
974
+ const currentTrack = this.queue.currentTrack;
975
+ const transitionMs =
976
+ currentTrack ? this.resolveSmartTransitionDuration(currentTrack) : this.resolveSmartTransitionDuration({} as Track);
977
+ await this.fadeResourceVolume(current, currentVolume, 0, transitionMs);
978
+ }
979
+
980
+ private async crossfadeSkipAndStop(): Promise<void> {
981
+ if (!this.crossfadeEnabled) {
982
+ this.audioPlayer.stop();
983
+ return;
984
+ }
985
+ if (this.crossfadeTransitionLock) {
986
+ return;
987
+ }
988
+ this.crossfadeTransitionLock = true;
989
+ try {
990
+ await this.applyCrossfadeOutCurrent();
991
+ this.audioPlayer.stop();
992
+ } finally {
993
+ this.crossfadeTransitionLock = false;
994
+ }
995
+ }
996
+
997
+ private getTrackMetadataValue(track: Track, key: string): any {
998
+ const md = track?.metadata as Record<string, any> | undefined;
999
+ if (!md) return undefined;
1000
+ return md[key];
1001
+ }
1002
+
1003
+ private resolveSmartTransitionDuration(track: Track): number {
1004
+ if (!this.smartTransitionEnabled) {
1005
+ return this.crossfadeDurationMs;
1006
+ }
1007
+
1008
+ let duration = this.smartTransitionBaseMs;
1009
+ if (this.smartTransitionGenreAware) {
1010
+ const rawGenre = this.getTrackMetadataValue(track, "genre");
1011
+ const genre = typeof rawGenre === "string" ? rawGenre.toLowerCase().trim() : "";
1012
+ if (genre && this.smartTransitionGenreDurations[genre] !== undefined) {
1013
+ duration = this.smartTransitionGenreDurations[genre];
1014
+ }
1015
+ }
1016
+
1017
+ return Math.min(this.smartTransitionMaxMs, Math.max(this.smartTransitionMinMs, duration));
1018
+ }
1019
+
1020
+ private async maybeAlignToBeatBoundary(): Promise<void> {
1021
+ if (!this.smartTransitionEnabled || !this.smartTransitionBeatAlign) return;
1022
+ const currentTrack = this.queue.currentTrack;
1023
+ if (!currentTrack || !this.currentResource) return;
1024
+
1025
+ const bpmRaw = this.getTrackMetadataValue(currentTrack, "bpm");
1026
+ const bpm = typeof bpmRaw === "number" ? bpmRaw : Number(bpmRaw);
1027
+ if (!Number.isFinite(bpm) || bpm <= 0) return;
1028
+
1029
+ const beatMs = 60000 / bpm;
1030
+ const positionMs = this.currentResource.playbackDuration;
1031
+ const remainder = positionMs % beatMs;
1032
+ const waitMs = beatMs - remainder;
1033
+ if (waitMs > 0 && waitMs <= this.smartTransitionBeatAlignMaxWaitMs) {
1034
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
1035
+ }
1036
+ }
1037
+
1038
+ private getTrackTargetVolume(track: Track): number {
1039
+ const base = this.volume / 100;
1040
+ if (!this.loudnessNormalizationEnabled) {
1041
+ return base;
1042
+ }
1043
+
1044
+ const lufsRaw = this.getTrackMetadataValue(track, "lufs");
1045
+ const trackLufs = typeof lufsRaw === "number" ? lufsRaw : Number(lufsRaw);
1046
+ if (!Number.isFinite(trackLufs)) {
1047
+ return Math.min(base, this.loudnessLimiterCeiling);
1048
+ }
1049
+
1050
+ const deltaDbRaw = this.loudnessTargetLUFS - trackLufs;
1051
+ const maxBoost = this.loudnessMaxBoostDb;
1052
+ const maxCut = this.loudnessMaxCutDb;
1053
+ const deltaDb = Math.max(-maxCut, Math.min(maxBoost, deltaDbRaw));
1054
+ const multiplier = Math.pow(10, deltaDb / 20);
1055
+ const adjusted = base * multiplier;
1056
+ return Math.min(this.loudnessLimiterCeiling, Math.max(0, adjusted));
1057
+ }
1058
+
1059
+ private async attemptTrackRecovery(track: Track, reason: unknown): Promise<boolean> {
1060
+ if (!this.antiStuckEnabled) return false;
1061
+ this.debug(`[AntiStuck] Recovery started for: ${track.title}`, reason);
1062
+
1063
+ const originalQuality = this.options.quality;
1064
+ let attempted = 0;
1065
+
1066
+ while (attempted < this.antiStuckMaxRetries) {
1067
+ attempted++;
1068
+ if (this.antiStuckReduceQualityOnRetry) {
1069
+ this.options.quality = "low";
1070
+ }
1071
+
1072
+ if (this.antiStuckRetryDelayMs > 0) {
1073
+ await new Promise((resolve) => setTimeout(resolve, this.antiStuckRetryDelayMs));
1074
+ }
1075
+
1076
+ try {
1077
+ if (this.antiStuckReusePreloadFirst && this.preloadSlot.isValid && this.preloadSlot.track?.id === track.id) {
1078
+ const startedFromPreload = await this.startTrack(track);
1079
+ if (startedFromPreload) {
1080
+ this.antiStuckConsecutiveFailures = 0;
1081
+ this.options.quality = originalQuality;
1082
+ return true;
1083
+ }
1084
+ }
1085
+
1086
+ const started = await this.loadFreshStream(track);
1087
+ if (started) {
1088
+ this.antiStuckConsecutiveFailures = 0;
1089
+ this.options.quality = originalQuality;
1090
+ return true;
1091
+ }
1092
+ } catch (error) {
1093
+ this.debug(`[AntiStuck] Retry ${attempted} failed for ${track.title}:`, error);
1094
+ }
1095
+ }
1096
+
1097
+ this.options.quality = originalQuality;
1098
+ this.antiStuckConsecutiveFailures++;
1099
+ if (this.antiStuckConsecutiveFailures >= this.antiStuckControlledSkipThreshold) {
1100
+ this.debug(`[AntiStuck] Controlled skip threshold reached for ${track.title}`);
1101
+ return false;
1102
+ }
1103
+
1104
+ // Avoid hard skip storm by leaving track for next natural retry window.
1105
+ this.debug(`[AntiStuck] Keeping track for controlled retry window: ${track.title}`);
1106
+ return false;
1107
+ }
1108
+
1109
+ /**
1110
+ * Clear preloaded resource with proper cleanup
1111
+ */
1112
+ private clearPreload(): void {
1113
+ // Abort ongoing preload
1114
+ if (this.preloadState.abortController) {
1115
+ this.preloadState.abortController.abort();
1116
+ this.preloadState.abortController = null;
1117
+ }
1118
+
1119
+ // Clean up stream
1120
+ const stream = (this.preloadState as any).stream;
1121
+ if (stream && typeof stream.destroy === "function") {
1122
+ try {
1123
+ stream.destroy();
1124
+ } catch (err) {
1125
+ this.debug(`[Preload] Error destroying stream:`, err);
1126
+ }
1127
+ }
1128
+
1129
+ // Clean up resource
1130
+ if (this.preloadState.resource) {
1131
+ try {
1132
+ const playStream = this.preloadState.resource.playStream;
1133
+ if (playStream && typeof playStream.destroy === "function") {
1134
+ playStream.destroy();
1135
+ }
1136
+ } catch (err) {
1137
+ this.debug(`[Preload] Error destroying resource:`, err);
1138
+ }
1139
+ }
1140
+
1141
+ this.preloadState = {
1142
+ resource: null,
1143
+ track: null,
1144
+ abortController: null,
1145
+ timeoutId: null,
1146
+ isValid: false,
1147
+ isBeingUsed: false,
1148
+ streamId: undefined,
1149
+ };
1150
+ }
1151
+
1152
+ /**
1153
+ * Cancel preload (when skipping or stopping)
1154
+ */
1155
+ private cancelPreload(): void {
1156
+ if (this.preloadSlot.abortController) {
1157
+ this.debug(`[Preload] Cancelling preload for: ${this.preloadSlot.track?.title}`);
1158
+ this.preloadSlot.abortController.abort();
1159
+ }
1160
+
1161
+ if (this.preloadSlot.streamId && this.streamManager) {
1162
+ this.streamManager.unregisterStream(this.preloadSlot.streamId, true);
1163
+ }
1164
+
1165
+ this.clearSlot(this.preloadSlot);
1166
+ }
1167
+
1168
+ /**
1169
+ * Clear a stream slot
1170
+ */
1171
+ private clearSlot(slot: StreamSlot): void {
1172
+ if (slot.resource) {
1173
+ try {
1174
+ const stream = slot.resource.playStream;
1175
+ if (stream && typeof stream.destroy === "function" && !stream.destroyed) {
1176
+ stream.destroy();
1177
+ }
1178
+ } catch (err) {
1179
+ // Ignore
1180
+ }
1181
+ }
1182
+
1183
+ if (slot.streamId && this.streamManager) {
1184
+ // Don't wait for unregister
1185
+ this.streamManager.unregisterStream(slot.streamId, true);
1186
+ }
1187
+
1188
+ slot.resource = null;
1189
+ slot.track = null;
1190
+ slot.streamId = null;
1191
+ slot.abortController = null;
1192
+ slot.isValid = false;
1193
+ slot.isLoading = false;
1194
+ slot.loadPromise = null;
1195
+ }
1196
+
1197
+ /**
1198
+ * Promote preload slot to current slot without destroying promoted stream.
1199
+ */
1200
+ private promotePreloadToCurrent(track: Track): void {
1201
+ const promotedResource = this.preloadSlot.resource;
1202
+ const promotedStreamId = this.preloadSlot.streamId;
1203
+
1204
+ // Move ownership to current slot.
1205
+ this.currentSlot.resource = promotedResource;
1206
+ this.currentSlot.track = track;
1207
+ this.currentSlot.streamId = promotedStreamId;
1208
+ this.currentSlot.abortController = null;
1209
+ this.currentSlot.isValid = !!promotedResource;
1210
+ this.currentSlot.isLoading = false;
1211
+ this.currentSlot.loadPromise = null;
1212
+ this.currentResource = promotedResource;
1213
+
1214
+ // Reset preload slot only (do not destroy promoted resource/stream).
1215
+ this.preloadSlot.resource = null;
1216
+ this.preloadSlot.track = null;
1217
+ this.preloadSlot.streamId = null;
1218
+ this.preloadSlot.abortController = null;
1219
+ this.preloadSlot.isValid = false;
1220
+ this.preloadSlot.isLoading = false;
1221
+ this.preloadSlot.loadPromise = null;
1222
+ }
1223
+
1224
+ /**
1225
+ * Create AudioResource with filters and seek applied
1226
+ *
1227
+ * @param {StreamInfo} streamInfo - The stream information
1228
+ * @param {Track} track - The track being processed
1229
+ * @param {number} position - Position in milliseconds to seek to (0 = no seek)
1230
+ * @returns {Promise<AudioResource>} The AudioResource with filters and seek applied
1231
+ */
1232
+ private async createResource(streamInfo: StreamInfo, track: Track, position: number = 0): Promise<AudioResource> {
1233
+ const filterString = this.filter.getFilterString();
1234
+
1235
+ this.debug(`[Player] Creating AudioResource with filters: ${filterString || "none"}, seek: ${position}ms`);
1236
+
1237
+ try {
1238
+ let stream: Readable = streamInfo.stream;
1239
+ // Apply filters and seek if needed
1240
+ if (filterString || position > 0) {
1241
+ stream = await this.filter.applyFiltersAndSeek(streamInfo.stream, position);
1242
+ streamInfo.type = StreamType.Arbitrary;
1243
+ }
1244
+
1245
+ // Create AudioResource with better error handling
1246
+ const resource = createAudioResource(stream, {
1247
+ metadata: track,
1248
+ inputType:
1249
+ streamInfo.type === "webm/opus" ? StreamType.WebmOpus
1250
+ : streamInfo.type === "ogg/opus" ? StreamType.OggOpus
1251
+ : StreamType.Arbitrary,
1252
+ inlineVolume: true,
1253
+ });
1254
+
1255
+ return resource;
1256
+ } catch (error) {
1257
+ this.debug(`[Player] Error creating AudioResource with filters+seek:`, error);
1258
+ // Fallback to basic AudioResource
1259
+ try {
1260
+ const resource = createAudioResource(streamInfo.stream, {
1261
+ metadata: track,
1262
+ inputType:
1263
+ streamInfo.type === "webm/opus" ? StreamType.WebmOpus
1264
+ : streamInfo.type === "ogg/opus" ? StreamType.OggOpus
1265
+ : StreamType.Arbitrary,
1266
+ inlineVolume: true,
1267
+ });
1268
+ return resource;
1269
+ } catch (fallbackError) {
1270
+ this.debug(`[Player] Fallback AudioResource creation failed:`, fallbackError);
1271
+ throw fallbackError;
1272
+ }
1273
+ }
1274
+ }
1275
+
1276
+ private async getStream(track: Track): Promise<StreamInfo | null> {
1277
+ const trackId = track.id || track.url || track.title;
1278
+ const existingStream = this.streamManager.getStreamByTrack(trackId);
1279
+
1280
+ if (existingStream && !existingStream.destroyed) {
1281
+ this.debug(`[Stream] Using existing stream from manager for: ${track.title}`);
1282
+ return { stream: existingStream, type: "arbitrary" };
1283
+ }
1284
+
1285
+ let stream = await this.extensionManager.provideStream(track);
1286
+ if (stream?.stream) {
1287
+ // Register with StreamManager
1288
+ const streamId = this.streamManager.registerStream(stream.stream, track, {
1289
+ source: "extension",
1290
+ isPreload: false,
1291
+ priority: 10,
1292
+ });
1293
+ this.debug(`[Stream] Extension stream registered with ID: ${streamId}`);
1294
+ return stream;
1295
+ }
1296
+
1297
+ stream = await this.pluginManager.getStream(track);
1298
+ if (stream?.stream) {
1299
+ const existingAgain = this.streamManager.getStreamByTrack(trackId);
1300
+ if (existingAgain && !existingAgain.destroyed) {
1301
+ if (stream.stream.destroy) stream.stream.destroy();
1302
+ return { stream: existingAgain, type: "arbitrary" };
1303
+ }
1304
+ // Register with StreamManager
1305
+ const streamId = this.streamManager.registerStream(stream.stream, track, {
1306
+ source: track.source || "plugin",
1307
+ isPreload: false,
1308
+ priority: 5,
1309
+ });
1310
+ this.debug(`[Stream] Plugin stream registered with ID: ${streamId}`);
1311
+ return stream;
1312
+ }
1313
+
1314
+ throw new Error(`No stream available for track: ${track.title}`);
1315
+ }
1316
+
1317
+ /**
1318
+ * Start playing a specific track immediately, replacing the current resource.
1319
+ */
1320
+ private async startTrack(track: Track): Promise<boolean> {
1321
+ try {
1322
+ // Try to use preloaded resource
1323
+ if (
1324
+ this.preloadSlot.isValid &&
1325
+ this.preloadSlot.track?.id === track.id &&
1326
+ this.preloadSlot.resource &&
1327
+ this.preloadSlot.resource.playStream?.readable !== false
1328
+ ) {
1329
+ this.debug(`[Player] Using preloaded stream for: ${track.title}`);
1330
+
1331
+ // Stop current playback
1332
+ this.audioPlayer.stop(true);
1333
+
1334
+ // Clean up old current stream (but delay to be safe)
1335
+ const oldStreamId = this.currentSlot.streamId;
1336
+ if (oldStreamId && this.streamManager) {
1337
+ setTimeout(() => {
1338
+ if (this.currentSlot.streamId === oldStreamId) {
1339
+ this.streamManager.unregisterStream(oldStreamId, true);
1340
+ }
1341
+ }, 3000);
1342
+ }
1343
+
1344
+ // Set current slot from preload
1345
+ this.promotePreloadToCurrent(track);
1346
+ const currentResource = this.currentSlot.resource;
1347
+ if (!currentResource) {
1348
+ return false;
1349
+ }
1350
+ const targetVolume = this.getTrackTargetVolume(track);
1351
+
1352
+ // Apply volume
1353
+ if (currentResource.volume) {
1354
+ currentResource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
1355
+ }
1356
+
1357
+ // Play
1358
+ await this.maybeAlignToBeatBoundary();
1359
+ this.audioPlayer.play(currentResource);
1360
+ await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 10_000);
1361
+ await this.applyCrossfadeIn(currentResource, track);
1362
+
1363
+ // Start preloading next track (async, don't await)
1364
+ this.preloadNextTrack().catch((err) => {
1365
+ this.debug(`[Player] Preload error:`, err);
1366
+ });
1367
+
1368
+ return true;
1369
+ }
1370
+
1371
+ // No valid preload, load fresh
1372
+ this.debug(`[Player] No preload available, loading fresh: ${track.title}`);
1373
+ return await this.loadFreshStream(track);
1374
+ } catch (error) {
1375
+ this.debug(`[Player] startTrack error:`, error);
1376
+ this.emit("playerError", error as Error, track);
1377
+ return false;
1378
+ }
1379
+ }
1380
+
1381
+ /**
1382
+ * Swap preload slot to current slot
1383
+ */
1384
+ private async swapToCurrent(track: Track): Promise<boolean> {
1385
+ // Store preload resource
1386
+ const newResource = this.preloadSlot.resource;
1387
+ const oldStreamId = this.currentSlot.streamId;
1388
+
1389
+ if (!newResource) {
1390
+ return false;
1391
+ }
1392
+
1393
+ // Stop current playback
1394
+ this.audioPlayer.stop(true);
1395
+
1396
+ // Clean up old current stream (but keep it for a moment)
1397
+ if (oldStreamId && this.streamManager) {
1398
+ // Delay cleanup to avoid destroying if still needed
1399
+ setTimeout(() => {
1400
+ if (this.currentSlot.streamId === oldStreamId) {
1401
+ this.streamManager.unregisterStream(oldStreamId, true);
1402
+ }
1403
+ }, 5000);
1404
+ }
1405
+
1406
+ // Set new current
1407
+ this.promotePreloadToCurrent(track);
1408
+ const currentResource = this.currentSlot.resource;
1409
+ if (!currentResource) {
1410
+ return false;
1411
+ }
1412
+ const targetVolume = this.getTrackTargetVolume(track);
1413
+
1414
+ // Apply volume
1415
+ if (currentResource.volume) {
1416
+ currentResource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
1417
+ }
1418
+
1419
+ // Play
1420
+ await this.maybeAlignToBeatBoundary();
1421
+ this.audioPlayer.play(currentResource);
1422
+
1423
+ try {
1424
+ await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 10_000);
1425
+ await this.applyCrossfadeIn(currentResource, track);
1426
+
1427
+ // Start preloading next track
1428
+ this.preloadNextTrack().catch((err) => {
1429
+ this.debug(`[Player] Preload error:`, err);
1430
+ });
1431
+
1432
+ return true;
1433
+ } catch (err) {
1434
+ this.debug(`[Player] Failed to play swapped track:`, err);
1435
+ return false;
1436
+ }
1437
+ }
1438
+
1439
+ /**
1440
+ * Load fresh stream when no preload available
1441
+ */
1442
+ private async loadFreshStream(track: Track): Promise<boolean> {
1443
+ // Cancel preload to free resources
1444
+ await this.safeCancelPreload();
1445
+
1446
+ try {
1447
+ const streamInfo = await this.getStream(track);
1448
+
1449
+ if (!streamInfo?.stream) {
1450
+ throw new Error(`No stream available`);
1451
+ }
1452
+
1453
+ // Register with StreamManager
1454
+ const streamId = this.streamManager.registerStream(streamInfo.stream, track, {
1455
+ source: track.source || "stream",
1456
+ isPreload: false,
1457
+ priority: 10,
1458
+ });
1459
+
1460
+ // Create resource
1461
+ const resource = await this.createResource(streamInfo, track, 0);
1462
+
1463
+ // Clean up old current
1464
+ if (this.currentSlot.streamId && this.currentSlot.streamId !== streamId) {
1465
+ this.streamManager.unregisterStream(this.currentSlot.streamId, true);
1466
+ }
1467
+
1468
+ // Set current slot
1469
+ this.currentSlot.resource = resource;
1470
+ this.currentSlot.track = track;
1471
+ this.currentSlot.streamId = streamId;
1472
+ this.currentSlot.isValid = true;
1473
+ this.currentResource = resource;
1474
+
1475
+ // Apply volume
1476
+ const targetVolume = this.getTrackTargetVolume(track);
1477
+ if (resource.volume) {
1478
+ resource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
1479
+ }
1480
+
1481
+ // Play
1482
+ await this.maybeAlignToBeatBoundary();
1483
+ this.audioPlayer.stop(true);
1484
+ this.audioPlayer.play(resource);
1485
+ await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 10_000);
1486
+ await this.applyCrossfadeIn(resource, track);
1487
+
1488
+ // Preload next (async)
1489
+ this.preloadNextTrack().catch((err) => {
1490
+ this.debug(`[Player] Preload error:`, err);
1491
+ });
1492
+
1493
+ return true;
1494
+ } catch (error) {
1495
+ this.debug(`[Player] loadFreshStream error:`, error);
1496
+ throw error;
1497
+ }
1498
+ }
1499
+
1500
+ /**
1501
+ * Play the next track in the queue, handling errors and edge cases gracefully
1502
+ */
1503
+ private async playNext(): Promise<boolean> {
1504
+ this.debug("[Player] playNext called");
1505
+
1506
+ // Don't cancel preload here unless absolutely necessary
1507
+ // Let startTrack handle it
1508
+
1509
+ while (true) {
1510
+ const track = this.queue.next(this.skipLoop);
1511
+ this.skipLoop = false;
1512
+
1513
+ if (!track) {
1514
+ if (this.queue.autoPlay()) {
1515
+ const willnext = this.queue.willNextTrack();
1516
+ if (willnext) {
1517
+ this.queue.addMultiple([willnext]);
1518
+ continue;
1519
+ }
1520
+ }
1521
+
1522
+ this.debug(`[Player] No next track in queue`);
1523
+ this.isPlaying = false;
1524
+ this.emit("queueEnd");
1525
+
1526
+ // Clean up both slots when queue is empty
1527
+ this.clearSlot(this.currentSlot);
1528
+ await this.safeCancelPreload();
1529
+
1530
+ if (this.options.leaveOnEnd) {
1531
+ this.scheduleLeave();
1532
+ }
1533
+
1534
+ return false;
1535
+ }
1536
+
1537
+ this.generateWillNext().catch((err) => this.debug("[Player] generateWillNext error:", err));
1538
+ this.clearLeaveTimeout();
1539
+ this.debug(`[Player] playNext called for track: ${track.title}`);
1540
+
1541
+ try {
1542
+ const started = await this.startTrack(track);
1543
+ if (started) {
1544
+ this.antiStuckConsecutiveFailures = 0;
1545
+ return true;
1546
+ }
1547
+ const recovered = await this.attemptTrackRecovery(track, new Error("TRACK_START_RETURNED_FALSE"));
1548
+ if (recovered) {
1549
+ return true;
1550
+ }
1551
+ if (this.antiStuckEnabled && this.antiStuckConsecutiveFailures < this.antiStuckControlledSkipThreshold) {
1552
+ this.queue.insert(track, 0);
1553
+ if (this.antiStuckRetryDelayMs > 0) {
1554
+ await new Promise((resolve) => setTimeout(resolve, this.antiStuckRetryDelayMs));
1555
+ }
1556
+ }
1557
+ } catch (err) {
1558
+ this.debug(`[Player] playNext error:`, err);
1559
+ this.emit("playerError", err as Error, track);
1560
+ const recovered = await this.attemptTrackRecovery(track, err);
1561
+ if (recovered) {
1562
+ return true;
1563
+ }
1564
+ if (this.antiStuckEnabled && this.antiStuckConsecutiveFailures < this.antiStuckControlledSkipThreshold) {
1565
+ this.queue.insert(track, 0);
1566
+ if (this.antiStuckRetryDelayMs > 0) {
1567
+ await new Promise((resolve) => setTimeout(resolve, this.antiStuckRetryDelayMs));
1568
+ }
1569
+ }
1570
+ continue;
1571
+ }
1572
+ }
1573
+ }
1574
+
1575
+ //#endregion
1576
+ //#region TTS
1577
+
1578
+ private ensureTTSPlayer(): DiscordAudioPlayer {
1579
+ if (this.ttsPlayer) return this.ttsPlayer;
1580
+ this.ttsPlayer = createAudioPlayer({
1581
+ behaviors: {
1582
+ noSubscriber: NoSubscriberBehavior.Pause,
1583
+ maxMissedFrames: 100,
1584
+ },
1585
+ });
1586
+ this.ttsPlayer.on("error", (e) => this.debug("[TTS] error:", e));
1587
+ return this.ttsPlayer;
1588
+ }
1589
+ /**
1590
+ * Interrupt current music with a TTS track. Pauses music, swaps the
1591
+ * subscription to a dedicated TTS player, plays TTS, then resumes.
1592
+ *
1593
+ * @param {Track} track - The track to interrupt with
1594
+ * @returns {Promise<void>}
1595
+ * @example
1596
+ * await player.interruptWithTTSTrack(track);
1597
+ */
1598
+ public async interruptWithTTSTrack(track: Track): Promise<void> {
1599
+ const wasPlaying =
1600
+ this.audioPlayer.state.status === AudioPlayerStatus.Playing ||
1601
+ this.audioPlayer.state.status === AudioPlayerStatus.Buffering;
1602
+
1603
+ let ttsResource: AudioResource | null = null;
1604
+ let ttsStream: any = null;
1605
+
1606
+ try {
1607
+ if (!this.connection) throw new Error("No voice connection for TTS");
1608
+ const ttsPlayer = this.ensureTTSPlayer();
1609
+
1610
+ // Build resource from plugin stream
1611
+ const streamInfo = await this.pluginManager.getStream(track);
1612
+ if (!streamInfo) {
1613
+ throw new Error(`No stream available for track: ${track.title}`);
1614
+ }
1615
+ ttsStream = streamInfo.stream;
1616
+ const resource = await this.createResource(streamInfo as StreamInfo, track);
1617
+ if (!resource) {
1618
+ throw new Error(`No resource available for track: ${track.title}`);
1619
+ }
1620
+ ttsResource = resource;
1621
+ if (resource.volume) {
1622
+ resource.volume.setVolume((this.options?.tts?.volume ?? this?.volume ?? 100) / 100);
1623
+ }
1624
+
1625
+ // Pause current music if any
1626
+ try {
1627
+ this.pause();
1628
+ } catch {}
1629
+
1630
+ // Swap subscription and play TTS
1631
+ this.connection.subscribe(ttsPlayer);
1632
+ this.emit("ttsStart", { track });
1633
+ ttsPlayer.play(resource);
1634
+
1635
+ // Wait until TTS starts then finishes
1636
+ await entersState(ttsPlayer, AudioPlayerStatus.Playing, 5_000).catch(() => null);
1637
+ // Derive timeoutMs from resource/track duration when available, with a sensible cap
1638
+ const md: any = (resource as any)?.metadata ?? {};
1639
+ const declared =
1640
+ typeof md.duration === "number" ? md.duration
1641
+ : typeof track?.duration === "number" ? track.duration
1642
+ : undefined;
1643
+ const declaredMs =
1644
+ declared ?
1645
+ declared > 1000 ?
1646
+ declared
1647
+ : declared * 1000
1648
+ : undefined;
1649
+ const cap = this.options?.tts?.maxTimeTts ?? 60_000;
1650
+ const idleTimeout = declaredMs ? Math.min(cap, Math.max(1_000, declaredMs + 1_500)) : cap;
1651
+ await entersState(ttsPlayer, AudioPlayerStatus.Idle, idleTimeout).catch(() => null);
1652
+
1653
+ // Swap back and resume if needed
1654
+ this.connection.subscribe(this.audioPlayer);
1655
+ } catch (err) {
1656
+ this.debug("[TTS] error while playing:", err);
1657
+ this.emit("playerError", err as Error);
1658
+ } finally {
1659
+ // Clean up TTS stream and resource
1660
+ try {
1661
+ if (ttsStream && typeof ttsStream.destroy === "function") {
1662
+ ttsStream.destroy();
1663
+ }
1664
+ } catch (error) {
1665
+ this.debug("[TTS] Error destroying stream:", error);
1666
+ }
1667
+
1668
+ if (wasPlaying) {
1669
+ try {
1670
+ this.resume();
1671
+ } catch {}
1672
+ }
1673
+ this.emit("ttsEnd");
1674
+ }
1675
+ }
1676
+
1677
+ //#endregion
1678
+ //#region Player Function
1679
+
1680
+ /**
1681
+ * Connect to a voice channel
1682
+ *
1683
+ * @param {VoiceChannel} channel - Discord voice channel
1684
+ * @returns {Promise<VoiceConnection>} The voice connection
1685
+ * @example
1686
+ * await player.connect(voiceChannel);
1687
+ */
1688
+ async connect(channel: VoiceChannel): Promise<VoiceConnection> {
1689
+ try {
1690
+ this.debug(`[Player] Connecting to voice channel: ${channel.id}`);
1691
+ const connection = joinVoiceChannel({
1692
+ channelId: channel.id,
1693
+ guildId: channel.guildId,
1694
+ adapterCreator: channel.guild.voiceAdapterCreator as any,
1695
+ selfDeaf: this.options.selfDeaf ?? true,
1696
+ selfMute: this.options.selfMute ?? false,
1697
+ });
1698
+
1699
+ await entersState(connection, VoiceConnectionStatus.Ready, 50_000);
1700
+ this.connection = connection;
1701
+
1702
+ connection.on(VoiceConnectionStatus.Disconnected, async () => {
1703
+ try {
1704
+ // move channel
1705
+ await Promise.race([
1706
+ entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
1707
+ entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
1708
+ ]);
1709
+ // Signalling/Connecting → reconnect
1710
+ this.debug(`[Player] Reconnecting after channel move...`);
1711
+ } catch {
1712
+ // no reconnect in 5 giây → disconnect
1713
+ this.debug(`[Player] Truly disconnected, destroying player`);
1714
+ this.destroy();
1715
+ }
1716
+ });
1717
+
1718
+ connection.on("error", (error) => {
1719
+ this.debug(`[Player] Voice connection error:`, error);
1720
+ this.emit("connectionError", error);
1721
+ });
1722
+ connection.subscribe(this.audioPlayer);
1723
+
1724
+ this.clearLeaveTimeout();
1725
+ return this.connection;
1726
+ } catch (error) {
1727
+ this.debug(`[Player] Connection error:`, error);
1728
+ this.emit("connectionError", error as Error);
1729
+ this.connection?.destroy();
1730
+ throw error;
1731
+ }
1732
+ }
1733
+
1734
+ /**
1735
+ * Pause the current track
1736
+ *
1737
+ * @returns {boolean} True if paused successfully
1738
+ * @example
1739
+ * const paused = player.pause();
1740
+ * console.log(`Paused: ${paused}`);
1741
+ */
1742
+ pause(): boolean {
1743
+ this.debug(`[Player] pause called`);
1744
+ if (this.isPlaying && !this.isPaused) {
1745
+ return this.audioPlayer.pause();
1746
+ }
1747
+ return false;
1748
+ }
1749
+
1750
+ /**
1751
+ * Resume the current track
1752
+ *
1753
+ * @returns {boolean} True if resumed successfully
1754
+ * @example
1755
+ * const resumed = player.resume();
1756
+ * console.log(`Resumed: ${resumed}`);
1757
+ */
1758
+ resume(): boolean {
1759
+ this.debug(`[Player] resume called`);
1760
+ if (this.isPaused) {
1761
+ const result = this.audioPlayer.unpause();
1762
+ if (result) {
1763
+ const track = this.queue.currentTrack;
1764
+ if (track) {
1765
+ this.debug(`[Player] Player resumed on track: ${track.title}`);
1766
+ // this.emit("playerResume", track); //đã có trong stateChange
1767
+ }
1768
+ }
1769
+ return result;
1770
+ }
1771
+ return false;
1772
+ }
1773
+
1774
+ /**
1775
+ * Stop the current track
1776
+ *
1777
+ * @returns {boolean} True if stopped successfully
1778
+ * @example
1779
+ * const stopped = player.stop();
1780
+ * console.log(`Stopped: ${stopped}`);
1781
+ */
1782
+ stop(): boolean {
1783
+ this.debug(`[Player] stop called`);
1784
+
1785
+ // Cancel preload when stopping
1786
+ this.cancelPreload();
1787
+
1788
+ this.queue.clear();
1789
+ const result = this.audioPlayer.stop();
1790
+ this.destroyCurrentStream();
1791
+ this.currentResource = null;
1792
+
1793
+ this.isPlaying = false;
1794
+ this.isPaused = false;
1795
+ this.emit("playerStop");
1796
+ return result;
1797
+ }
1798
+
1799
+ /**
1800
+ * Seek to a specific position in the current track
1801
+ *
1802
+ * @param {number} position - Position in milliseconds to seek to
1803
+ * @returns {Promise<boolean>} True if seek was successful
1804
+ * @example
1805
+ * // Seek to 30 seconds (30000ms)
1806
+ * const success = await player.seek(30000);
1807
+ * console.log(`Seek successful: ${success}`);
1808
+ *
1809
+ * // Seek to 1 minute 30 seconds (90000ms)
1810
+ * await player.seek(90000);
1811
+ */
1812
+ async seek(position: number): Promise<boolean> {
1813
+ this.debug(`[Player] seek called with position: ${position}ms`);
1814
+
1815
+ const track = this.queue.currentTrack;
1816
+ if (!track) {
1817
+ this.debug(`[Player] No current track to seek`);
1818
+ return false;
1819
+ }
1820
+
1821
+ const totalDuration = track.duration > 1000 ? track.duration : track.duration * 1000;
1822
+ if (position < 0 || position > totalDuration) {
1823
+ this.debug(`[Player] Invalid seek position: ${position}ms (track duration: ${totalDuration}ms)`);
1824
+ return false;
1825
+ }
1826
+
1827
+ await this.refreshPlayerResource(true, position);
1828
+
1829
+ return true;
1830
+ }
1831
+
1832
+ /**
1833
+ * Skip to the next track or skip to a specific index
1834
+ *
1835
+ * @param {number} index - Optional index to skip to (0 = next track)
1836
+ * @returns {boolean} True if skipped successfully
1837
+ * @example
1838
+ * const skipped = player.skip(); // Skip to next track
1839
+ * const skippedToIndex = player.skip(2); // Skip to track at index 2
1840
+ * console.log(`Skipped: ${skipped}`);
1841
+ */
1842
+ skip(index?: number): boolean {
1843
+ this.debug(`[Player] skip called with index: ${index}`);
1844
+
1845
+ try {
1846
+ if (typeof index === "number" && index >= 0) {
1847
+ const targetTrack = this.queue.getTrack(index);
1848
+ if (!targetTrack) {
1849
+ this.debug(`[Player] No track found at index ${index}`);
1850
+ return false;
1851
+ }
1852
+
1853
+ for (let i = 0; i < index; i++) {
1854
+ this.queue.remove(0);
1855
+ }
1856
+
1857
+ this.debug(`[Player] Skipped to track at index ${index}: ${targetTrack.title}`);
1858
+ }
1859
+
1860
+ if (this.isPlaying || this.isPaused) {
1861
+ this.skipLoop = true;
1862
+ void this.crossfadeSkipAndStop().catch((error) => {
1863
+ this.debug(`[Player] crossfade skip error:`, error);
1864
+ this.audioPlayer.stop();
1865
+ });
1866
+ return true;
1867
+ }
1868
+
1869
+ return true;
1870
+ } catch (error) {
1871
+ this.debug(`[Player] skip error:`, error);
1872
+ return false;
1873
+ }
1874
+ }
1875
+
1876
+ /**
1877
+ * Go back to the previous track in history and play it.
1878
+ *
1879
+ * @returns {Promise<boolean>} True if previous track was played successfully
1880
+ * @example
1881
+ * const previous = await player.previous();
1882
+ * console.log(`Previous: ${previous}`);
1883
+ */
1884
+ async previous(): Promise<boolean> {
1885
+ this.debug(`[Player] previous called`);
1886
+ const track = this.queue.previous();
1887
+ if (!track) return false;
1888
+ if (this.queue.currentTrack) this.insert(this.queue.currentTrack, 0);
1889
+ this.clearLeaveTimeout();
1890
+ return this.startTrack(track);
1891
+ }
1892
+
1893
+ /**
1894
+ * Save a track's stream to a file and return a Readable stream
1895
+ *
1896
+ * @param {Track} track - The track to save
1897
+ * @param {SaveOptions | string} options - Save options or filename string (for backward compatibility)
1898
+ * @returns {Promise<Readable>} A Readable stream containing the audio data
1899
+ * @example
1900
+ * // Save current track to file
1901
+ * const track = player.currentTrack;
1902
+ * if (track) {
1903
+ * const stream = await player.save(track);
1904
+ *
1905
+ * // Use fs to write the stream to file
1906
+ * const fs = require('fs');
1907
+ * const writeStream = fs.createWriteStream('saved-song.mp3');
1908
+ * stream.pipe(writeStream);
1909
+ *
1910
+ * writeStream.on('finish', () => {
1911
+ * console.log('File saved successfully!');
1912
+ * });
1913
+ * }
1914
+ *
1915
+ * // Save any track by URL
1916
+ * const searchResult = await player.search("Never Gonna Give You Up", userId);
1917
+ * if (searchResult.tracks.length > 0) {
1918
+ * const stream = await player.save(searchResult.tracks[0]);
1919
+ * // Handle the stream...
1920
+ * }
1921
+ *
1922
+ * // Backward compatibility - filename as string
1923
+ * const stream = await player.save(track, "my-song.mp3");
1924
+ */
1925
+ async save(track: Track, options?: SaveOptions | string): Promise<Readable> {
1926
+ this.debug(`[Player] save called for track: ${track.title}`);
1927
+
1928
+ // Parse options - support both SaveOptions object and filename string (backward compatibility)
1929
+ let saveOptions: SaveOptions = {};
1930
+ if (typeof options === "string") {
1931
+ saveOptions = { filename: options };
1932
+ } else if (options) {
1933
+ saveOptions = options;
1934
+ }
1935
+
1936
+ try {
1937
+ // Skip extension manager for saving - we want the raw stream without filters/seek applied, and extensions may not support this
1938
+ let streamInfo: StreamInfo | null = await this.pluginManager.getStream(track);
1939
+
1940
+ if (!streamInfo || !streamInfo.stream) {
1941
+ throw new Error(`No save stream available for track: ${track.title}`);
1942
+ }
1943
+
1944
+ this.debug(`[Player] Save stream obtained for track: ${track.title}`);
1945
+ if (saveOptions.filename) {
1946
+ this.debug(`[Player] Save options - filename: ${saveOptions.filename}, quality: ${saveOptions.quality || "default"}`);
1947
+ }
1948
+
1949
+ // Apply filters if any are active
1950
+ let finalStream = streamInfo.stream;
1951
+
1952
+ if (saveOptions.filter || saveOptions.seek) {
1953
+ try {
1954
+ this.filter.clearAll();
1955
+ this.filter.applyFilters(saveOptions.filter || []);
1956
+ } catch (err) {
1957
+ this.debug(`[Player] Error applying save filters:`, err);
1958
+ }
1959
+
1960
+ this.debug(`[Player] Applying filters to save stream: ${this.filter.getFilterString() || "none"}`);
1961
+ finalStream = await this.filter.applyFiltersAndSeek(streamInfo.stream, saveOptions.seek || 0).catch((err) => {
1962
+ this.debug(`[Player] Error applying filters to save stream:`, err);
1963
+ return streamInfo!.stream; // Fallback to original stream
1964
+ });
1965
+ }
1966
+
1967
+ // Return the stream directly - caller can pipe it to fs.createWriteStream()
1968
+ return finalStream;
1969
+ } catch (error) {
1970
+ this.debug(`[Player] save error:`, error);
1971
+ this.emit("playerError", error as Error, track);
1972
+ throw error;
1973
+ }
1974
+ }
1975
+
1976
+ /**
1977
+ * Loop the current track or queue
1978
+ *
1979
+ * @param {LoopMode | number} mode - The loop mode to set ("off", "track", "queue") or number (0=off, 1=track, 2=queue)
1980
+ * @returns {LoopMode} The loop mode
1981
+ * @example
1982
+ * const loopMode = player.loop("track"); // Loop current track
1983
+ * const loopQueue = player.loop("queue"); // Loop entire queue
1984
+ * const loopTrack = player.loop(1); // Loop current track (same as "track")
1985
+ * const loopQueueNum = player.loop(2); // Loop entire queue (same as "queue")
1986
+ * const noLoop = player.loop("off"); // No loop
1987
+ * const noLoopNum = player.loop(0); // No loop (same as "off")
1988
+ * console.log(`Loop mode: ${loopMode}`);
1989
+ */
1990
+ loop(mode?: LoopMode | number): LoopMode {
1991
+ this.debug(`[Player] loop called with mode: ${mode}`);
1992
+
1993
+ if (typeof mode === "number") {
1994
+ // Number mode: convert to text mode
1995
+ switch (mode) {
1996
+ case 0:
1997
+ return this.queue.loop("off");
1998
+ case 1:
1999
+ return this.queue.loop("track");
2000
+ case 2:
2001
+ return this.queue.loop("queue");
2002
+ default:
2003
+ this.debug(`[Player] Invalid loop number: ${mode}, using "off"`);
2004
+ return this.queue.loop("off");
2005
+ }
2006
+ }
2007
+
2008
+ return this.queue.loop(mode as LoopMode);
2009
+ }
2010
+
2011
+ /**
2012
+ * Set the auto-play mode
2013
+ *
2014
+ * @param {boolean} mode - The auto-play mode to set
2015
+ * @returns {boolean} The auto-play mode
2016
+ * @example
2017
+ * const autoPlayMode = player.autoPlay(true);
2018
+ * console.log(`Auto-play mode: ${autoPlayMode}`);
2019
+ */
2020
+ autoPlay(mode?: boolean): boolean {
2021
+ return this.queue.autoPlay(mode);
2022
+ }
2023
+
2024
+ /**
2025
+ * Set the volume of the current track
2026
+ *
2027
+ * @param {number} volume - The volume to set
2028
+ * @returns {boolean} True if volume was set successfully
2029
+ * @example
2030
+ * const volumeSet = player.setVolume(50);
2031
+ * console.log(`Volume set: ${volumeSet}`);
2032
+ */
2033
+ setVolume(volume: number): boolean {
2034
+ this.debug(`[Player] setVolume called: ${volume}`);
2035
+ if (volume < 0 || volume > 200) return false;
2036
+
2037
+ const oldVolume = this.volume;
2038
+ this.volume = volume;
2039
+ const resourceVolume = this.currentResource?.volume;
2040
+
2041
+ if (resourceVolume) {
2042
+ if (this.volumeInterval) clearInterval(this.volumeInterval);
2043
+
2044
+ const start = resourceVolume.volume;
2045
+ const target = this.volume / 100;
2046
+ const steps = 10;
2047
+ let currentStep = 0;
2048
+
2049
+ this.volumeInterval = setInterval(() => {
2050
+ currentStep++;
2051
+ const value = start + ((target - start) * currentStep) / steps;
2052
+ resourceVolume.setVolume(value);
2053
+ if (currentStep >= steps) {
2054
+ clearInterval(this.volumeInterval!);
2055
+ this.volumeInterval = null;
2056
+ }
2057
+ }, 300);
2058
+ }
2059
+
2060
+ this.emit("volumeChange", oldVolume, volume);
2061
+ return true;
2062
+ }
2063
+
2064
+ /**
2065
+ * Shuffle the queue
2066
+ *
2067
+ * @returns {void}
2068
+ * @example
2069
+ * player.shuffle();
2070
+ */
2071
+ shuffle(): void {
2072
+ this.debug(`[Player] shuffle called`);
2073
+ this.queue.shuffle();
2074
+ }
2075
+
2076
+ /**
2077
+ * Clear the queue
2078
+ *
2079
+ * @returns {void}
2080
+ * @example
2081
+ * player.clearQueue();
2082
+ */
2083
+ clearQueue(): void {
2084
+ this.debug(`[Player] clearQueue called`);
2085
+ this.queue.clear();
2086
+ }
2087
+
2088
+ /**
2089
+ * Insert a track or list of tracks into the upcoming queue at a specific position (0 = play after current).
2090
+ * - If `query` is a string, performs a search and inserts resulting tracks (playlist supported).
2091
+ * - If a Track or Track[] is provided, inserts directly.
2092
+ * Does not auto-start playback; it only modifies the queue.
2093
+ *
2094
+ * @param {string | Track | Track[]} query - The track or tracks to insert
2095
+ * @param {number} index - The index to insert the tracks at
2096
+ * @param {string} requestedBy - The user ID who requested the insert
2097
+ * @returns {Promise<boolean>} True if the tracks were inserted successfully
2098
+ * @example
2099
+ * const inserted = await player.insert("Song Name", 0, userId);
2100
+ * console.log(`Inserted: ${inserted}`);
2101
+ */
2102
+ async insert(query: string | Track | Track[], index: number, requestedBy?: string): Promise<boolean> {
2103
+ try {
2104
+ this.debug(`[Player] insert called at index ${index} with type: ${typeof query}`);
2105
+ let tracksToAdd: Track[] = [];
2106
+ let isPlaylist = false;
2107
+
2108
+ if (typeof query === "string") {
2109
+ const searchResult = await this.search(query, requestedBy || "Unknown");
2110
+ tracksToAdd = searchResult.tracks || [];
2111
+ isPlaylist = !!searchResult.playlist;
2112
+ } else if (Array.isArray(query)) {
2113
+ tracksToAdd = query;
2114
+ isPlaylist = query.length > 1;
2115
+ } else if (query) {
2116
+ tracksToAdd = [query];
2117
+ }
2118
+
2119
+ if (!tracksToAdd || tracksToAdd.length === 0) {
2120
+ this.debug(`[Player] insert: no tracks resolved`);
2121
+ throw new Error("No tracks to insert");
2122
+ }
2123
+
2124
+ if (tracksToAdd.length === 1) {
2125
+ this.queue.insert(tracksToAdd[0], index);
2126
+ this.emit("queueAdd", tracksToAdd[0]);
2127
+ this.debug(`[Player] Inserted track at index ${index}: ${tracksToAdd[0].title}`);
2128
+ } else {
2129
+ this.queue.insertMultiple(tracksToAdd, index);
2130
+ this.emit("queueAddList", tracksToAdd);
2131
+ this.debug(`[Player] Inserted ${tracksToAdd.length} ${isPlaylist ? "playlist " : ""}tracks at index ${index}`);
2132
+ }
2133
+
2134
+ return true;
2135
+ } catch (error) {
2136
+ this.debug(`[Player] insert error:`, error);
2137
+ this.emit("playerError", error as Error);
2138
+ return false;
2139
+ }
2140
+ }
2141
+
2142
+ /**
2143
+ * Remove a track from the queue
2144
+ *
2145
+ * @param {number} index - The index of the track to remove
2146
+ * @returns {Track | null} The removed track or null
2147
+ * @example
2148
+ * const removed = player.remove(0);
2149
+ * console.log(`Removed: ${removed?.title}`);
2150
+ */
2151
+ remove(index: number): Track | null {
2152
+ this.debug(`[Player] remove called for index: ${index}`);
2153
+ const track = this.queue.remove(index);
2154
+ if (track) {
2155
+ this.emit("queueRemove", track, index);
2156
+ }
2157
+ return track;
2158
+ }
2159
+ /**
2160
+ * Get the progress bar of the current track
2161
+ *
2162
+ * @param {ProgressBarOptions} options - The options for the progress bar
2163
+ * @returns {string} The progress bar
2164
+ * @example
2165
+ * const progressBar = player.getProgressBar();
2166
+ * console.log(`Progress bar: ${progressBar}`);
2167
+ *
2168
+ * // Custom options
2169
+ * const customBar = player.getProgressBar({
2170
+ * size: 30,
2171
+ * barChar: "─",
2172
+ * progressChar: "●",
2173
+ * timeFormat: "compact" // "compact" = 1:22:12, "full" = 01:22:12
2174
+ * });
2175
+ */
2176
+ getProgressBar(options: ProgressBarOptions = {}): string {
2177
+ const {
2178
+ size = 20,
2179
+ barChar = "▬",
2180
+ progressChar = "🔘",
2181
+ timeFormat = "compact", // "compact" or "full"
2182
+ showPercentage = false,
2183
+ showTime = true,
2184
+ } = options;
2185
+
2186
+ const track = this.queue.currentTrack;
2187
+ const resource = this.currentResource;
2188
+
2189
+ // Handle live stream
2190
+ if (this.isLive || !track || !resource) {
2191
+ if (this.isLive) return "🔴 LIVE";
2192
+ return "";
2193
+ }
2194
+
2195
+ const total = track.duration > 1000 ? track.duration : track.duration * 1000;
2196
+ if (!total) return this.formatTimeCompact(resource.playbackDuration);
2197
+
2198
+ const current = resource.playbackDuration;
2199
+ const ratio = Math.min(Math.max(current / total, 0), 1);
2200
+ const progress = Math.round(ratio * size);
2201
+
2202
+ // Build progress bar
2203
+ let bar = "";
2204
+ if (progressChar === "none" || options.hideProgressChar) {
2205
+ // Continuous bar without separator
2206
+ const filled = barChar.repeat(progress);
2207
+ const empty = barChar.repeat(size - progress);
2208
+ bar = filled + empty;
2209
+ } else {
2210
+ // Bar with progress character
2211
+ const filled = barChar.repeat(progress);
2212
+ const empty = barChar.repeat(Math.max(0, size - progress));
2213
+ bar = filled + progressChar + empty;
2214
+ }
2215
+
2216
+ // Format time based on option
2217
+ const formatTimeFn = timeFormat === "compact" ? this.formatTimeCompact.bind(this) : this.formatTime.bind(this);
2218
+ const currentTimeStr = formatTimeFn(current);
2219
+ const totalTimeStr = formatTimeFn(total);
2220
+
2221
+ // Build result
2222
+ let result = "";
2223
+ if (showTime) {
2224
+ result = `${currentTimeStr} ${bar} ${totalTimeStr}`;
2225
+ } else {
2226
+ result = bar;
2227
+ }
2228
+
2229
+ // Add percentage if requested
2230
+ if (showPercentage) {
2231
+ const percent = Math.round(ratio * 100);
2232
+ result += ` (${percent}%)`;
2233
+ }
2234
+
2235
+ return result;
2236
+ }
2237
+
2238
+ /**
2239
+ * Format time with leading zeros (00:00 or 00:00:00)
2240
+ * @param ms - Time in milliseconds
2241
+ * @returns Formatted time string with leading zeros
2242
+ */
2243
+ formatTime(ms: number): string {
2244
+ const totalSeconds = Math.floor(ms / 1000);
2245
+ const hours = Math.floor(totalSeconds / 3600);
2246
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
2247
+ const seconds = totalSeconds % 60;
2248
+ const parts: string[] = [];
2249
+ if (hours > 0) parts.push(String(hours).padStart(2, "0"));
2250
+ parts.push(String(minutes).padStart(2, "0"));
2251
+ parts.push(String(seconds).padStart(2, "0"));
2252
+ return parts.join(":");
2253
+ }
2254
+
2255
+ /**
2256
+ * Format time without leading zeros for hours (1:22:12 or 3:45)
2257
+ * @param ms - Time in milliseconds
2258
+ * @returns Compact formatted time string
2259
+ */
2260
+ formatTimeCompact(ms: number): string {
2261
+ const totalSeconds = Math.floor(ms / 1000);
2262
+ const hours = Math.floor(totalSeconds / 3600);
2263
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
2264
+ const seconds = totalSeconds % 60;
2265
+
2266
+ if (hours > 0) {
2267
+ return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
2268
+ }
2269
+ return `${minutes}:${String(seconds).padStart(2, "0")}`;
2270
+ }
2271
+
2272
+ /**
2273
+ * Get the time of the current track
2274
+ *
2275
+ * @returns {Object} The time of the current track
2276
+ * @example
2277
+ * const time = player.getTime();
2278
+ * console.log(`Time: ${time.current}`);
2279
+ * console.log(`Formatted: ${time.formatted.current}`); // "1:22:12" or "3:45"
2280
+ */
2281
+ getTime() {
2282
+ if (this.isLive)
2283
+ return {
2284
+ current: 0,
2285
+ total: 0,
2286
+ format: "LIVE",
2287
+ formatted: {
2288
+ current: "LIVE",
2289
+ total: "LIVE",
2290
+ },
2291
+ };
2292
+
2293
+ const resource = this.currentResource;
2294
+ const track = this.queue.currentTrack;
2295
+ if (!track || !resource) {
2296
+ return {
2297
+ current: 0,
2298
+ total: 0,
2299
+ format: "00:00",
2300
+ formatted: {
2301
+ current: "00:00",
2302
+ total: "00:00",
2303
+ },
2304
+ };
2305
+ }
2306
+
2307
+ const total = track.duration > 1000 ? track.duration : track.duration * 1000;
2308
+ const current = resource.playbackDuration;
2309
+
2310
+ return {
2311
+ current: current,
2312
+ total: total,
2313
+ format: this.formatTime(current),
2314
+ formatted: {
2315
+ current: this.formatTimeCompact(current),
2316
+ total: this.formatTimeCompact(total),
2317
+ },
2318
+ };
2319
+ }
2320
+
2321
+ /**
2322
+ * Destroy the player
2323
+ *
2324
+ * @returns {void}
2325
+ * @example
2326
+ * player.destroy();
2327
+ */
2328
+ destroy(): void {
2329
+ this.debug(`[Player] destroy called`);
2330
+
2331
+ if (this.leaveTimeout) {
2332
+ clearTimeout(this.leaveTimeout);
2333
+ this.leaveTimeout = null;
2334
+ }
2335
+ this.streamManager.destroyAll(true);
2336
+ // Destroy current stream before stopping audio
2337
+ this.destroyCurrentStream();
2338
+
2339
+ this.clearSlot(this.currentSlot);
2340
+ this.clearSlot(this.preloadSlot);
2341
+
2342
+ this.audioPlayer.removeAllListeners();
2343
+ this.audioPlayer.stop(true);
2344
+ this.clearPreload();
2345
+
2346
+ if (this.ttsPlayer) {
2347
+ try {
2348
+ this.ttsPlayer.stop(true);
2349
+ } catch {}
2350
+ this.ttsPlayer = null;
2351
+ }
2352
+
2353
+ if (this.connection) {
2354
+ this.connection.destroy();
2355
+ this.connection = null;
2356
+ }
2357
+
2358
+ this.queue.clear();
2359
+ this.pluginManager.clear();
2360
+ this.filter.destroy();
2361
+ this.extensionManager.destroy();
2362
+ this.isPlaying = false;
2363
+ this.isPaused = false;
2364
+
2365
+ // Clear any remaining intervals
2366
+ if (this.volumeInterval) {
2367
+ clearInterval(this.volumeInterval);
2368
+ this.volumeInterval = null;
2369
+ }
2370
+
2371
+ this.emit("playerDestroy");
2372
+ this.removeAllListeners();
2373
+ }
2374
+
2375
+ //#endregion
2376
+ //#region utils
2377
+ private scheduleLeave(): void {
2378
+ this.debug(`[Player] scheduleLeave called`);
2379
+ if (this.leaveTimeout) {
2380
+ clearTimeout(this.leaveTimeout);
2381
+ }
2382
+
2383
+ if (this.options.leaveOnEnd && this.options.leaveTimeout) {
2384
+ this.leaveTimeout = setTimeout(() => {
2385
+ this.debug(`[Player] Leaving voice channel after timeoutMs`);
2386
+ this.destroy();
2387
+ }, this.options.leaveTimeout);
2388
+ }
2389
+ }
2390
+
2391
+ /**
2392
+ * Refesh player resource (apply filter)
2393
+ *
2394
+ * @param {boolean} applyToCurrent - Apply filter for curent track
2395
+ * @param {number} position - Position to seek to in milliseconds
2396
+ * @returns {Promise<boolean>}
2397
+ * @example
2398
+ * const refreshed = await player.refreshPlayerResource(true, 1000);
2399
+ * console.log(`Refreshed: ${refreshed}`);
2400
+ */
2401
+ public async refreshPlayerResource(applyToCurrent: boolean = true, position: number = -1): Promise<boolean> {
2402
+ if (!applyToCurrent || !this.queue.currentTrack || !(this.isPlaying || this.isPaused)) {
2403
+ return false;
2404
+ }
2405
+ if (this.refreshLock) return false;
2406
+ this.refreshLock = true;
2407
+ try {
2408
+ const track = this.queue.currentTrack;
2409
+ this.debug(`[Player] Refreshing player resource for track: ${track.title}`);
2410
+
2411
+ // Get current position for seeking
2412
+ const currentPosition = position > 0 ? position : this.currentResource?.playbackDuration || 0;
2413
+
2414
+ const streaminfo = await this.getStream(track);
2415
+ if (!streaminfo?.stream) {
2416
+ this.debug(`[Player] No stream to refresh`);
2417
+ return false;
2418
+ }
2419
+
2420
+ // Create AudioResource with filters and seek to current position
2421
+ const resource = await this.createResource(streaminfo, track, currentPosition);
2422
+
2423
+ // Stop current playback and destroy old resource/stream
2424
+ const wasPlaying = this.isPlaying;
2425
+ const wasPaused = this.isPaused;
2426
+
2427
+ this.audioPlayer.stop();
2428
+
2429
+ // Properly destroy the old resource and stream
2430
+ try {
2431
+ if (this.currentResource) {
2432
+ const oldStream = (this.currentResource as any)._readableState?.stream || (this.currentResource as any).stream;
2433
+ if (oldStream && typeof oldStream.destroy === "function") {
2434
+ oldStream.destroy();
2435
+ }
2436
+ }
2437
+ } catch (error) {
2438
+ this.debug(`[Player] Error destroying old stream in refreshPlayerResource:`, error);
2439
+ } finally {
2440
+ this.refreshLock = false;
2441
+ }
2442
+
2443
+ this.currentResource = resource;
2444
+
2445
+ // Subscribe to new resource
2446
+ if (this.connection) {
2447
+ this.connection.subscribe(this.audioPlayer);
2448
+ this.audioPlayer.play(resource);
2449
+ }
2450
+
2451
+ // Restore playing state
2452
+ if (wasPlaying && !wasPaused) {
2453
+ this.isPlaying = true;
2454
+ this.isPaused = false;
2455
+ } else if (wasPaused) {
2456
+ this.isPlaying = false;
2457
+ this.isPaused = true;
2458
+ this.audioPlayer.pause();
2459
+ }
2460
+
2461
+ this.debug(`[Player] Successfully applied filter to current track at position ${currentPosition}ms`);
2462
+ return true;
2463
+ } catch (error) {
2464
+ this.debug(`[Player] Error applying filter to current track:`, error);
2465
+ // Filter was still added to active filters, so return true
2466
+ return true;
2467
+ }
2468
+ }
2469
+
2470
+ /**
2471
+ * Attach an extension to the player
2472
+ *
2473
+ * @param {BaseExtension} extension - The extension to attach
2474
+ * @example
2475
+ * player.attachExtension(new MyExtension());
2476
+ */
2477
+ public attachExtension(extension: BaseExtension): void {
2478
+ this.extensionManager.register(extension);
2479
+ }
2480
+
2481
+ /**
2482
+ * Detach an extension from the player
2483
+ *
2484
+ * @param {BaseExtension} extension - The extension to detach
2485
+ * @example
2486
+ * player.detachExtension(new MyExtension());
2487
+ */
2488
+ public detachExtension(extension: BaseExtension): void {
2489
+ this.extensionManager.unregister(extension);
2490
+ }
2491
+
2492
+ /**
2493
+ * Get all extensions attached to the player
2494
+ *
2495
+ * @returns {readonly BaseExtension[]} All attached extensions
2496
+ * @example
2497
+ * const extensions = player.getExtensions();
2498
+ * console.log(`Extensions: ${extensions.length}`);
2499
+ */
2500
+ public getExtensions(): readonly BaseExtension[] {
2501
+ return this.extensionManager.getAll();
2502
+ }
2503
+
2504
+ private clearLeaveTimeout(): void {
2505
+ if (this.leaveTimeout) {
2506
+ clearTimeout(this.leaveTimeout);
2507
+ this.leaveTimeout = null;
2508
+ this.debug(`[Player] Cleared leave timeoutMs`);
2509
+ }
2510
+ }
2511
+
2512
+ private debug(message?: any, ...optionalParams: any[]): void {
2513
+ if (this.listenerCount("debug") > 0) {
2514
+ this.emit("debug", message, ...optionalParams);
2515
+ }
2516
+ }
2517
+
2518
+ private setupEventListeners(): void {
2519
+ this.audioPlayer.on("stateChange", (oldState, newState) => {
2520
+ this.debug(`[Player] AudioPlayer stateChange from ${oldState.status} to ${newState.status}`);
2521
+ if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) {
2522
+ // Track ended
2523
+ const track = this.queue.currentTrack;
2524
+ if (track) {
2525
+ this.debug(`[Player] Track ended: ${track.title}`);
2526
+ this.emit("trackEnd", track);
2527
+ }
2528
+ this.playNext();
2529
+ } else if (
2530
+ newState.status === AudioPlayerStatus.Playing &&
2531
+ (oldState.status === AudioPlayerStatus.Idle || oldState.status === AudioPlayerStatus.Buffering)
2532
+ ) {
2533
+ // Track started
2534
+ this.clearLeaveTimeout();
2535
+ this.isPlaying = true;
2536
+ this.isPaused = false;
2537
+ const track = this.queue.currentTrack;
2538
+ if (track) {
2539
+ this.debug(`[Player] Track started: ${track.title}`);
2540
+ this.emit("trackStart", track);
2541
+ }
2542
+ } else if (newState.status === AudioPlayerStatus.Paused && oldState.status !== AudioPlayerStatus.Paused) {
2543
+ // Track paused
2544
+ this.isPaused = true;
2545
+ const track = this.queue.currentTrack;
2546
+ if (track) {
2547
+ this.debug(`[Player] Player paused on track: ${track.title}`);
2548
+ this.emit("playerPause", track);
2549
+ }
2550
+ } else if (newState.status !== AudioPlayerStatus.Paused && oldState.status === AudioPlayerStatus.Paused) {
2551
+ // Track resumed
2552
+ this.isPaused = false;
2553
+ const track = this.queue.currentTrack;
2554
+ if (track) {
2555
+ this.debug(`[Player] Player resumed on track: ${track.title}`);
2556
+ this.emit("playerResume", track);
2557
+ }
2558
+ } else if (newState.status === AudioPlayerStatus.AutoPaused) {
2559
+ this.debug(`[Player] AudioPlayerStatus.AutoPaused`);
2560
+ } else if (newState.status === AudioPlayerStatus.Buffering) {
2561
+ this.debug(`[Player] AudioPlayerStatus.Buffering`);
2562
+ this.lastDuration = this.currentResource?.playbackDuration || 0;
2563
+ this.stuckTimer = setTimeout(() => {
2564
+ if (this.currentResource?.playbackDuration === this.lastDuration) {
2565
+ this.emit("trackStuck", this.currentTrack);
2566
+ const stuckTrack = this.currentTrack;
2567
+ if (stuckTrack && this.antiStuckEnabled) {
2568
+ void this.attemptTrackRecovery(stuckTrack, new Error("TRACK_STUCK")).then((recovered) => {
2569
+ if (!recovered) {
2570
+ this.skip();
2571
+ }
2572
+ });
2573
+ return;
2574
+ }
2575
+ this.skip();
2576
+ }
2577
+ }, 10000);
2578
+ } else {
2579
+ if (this.stuckTimer) {
2580
+ clearTimeout(this.stuckTimer);
2581
+ this.stuckTimer = null;
2582
+ }
2583
+ }
2584
+ });
2585
+ this.audioPlayer.on("error", (error) => {
2586
+ this.debug(`[Player] AudioPlayer error:`, error);
2587
+ this.emit("playerError", error, this.queue.currentTrack || undefined);
2588
+ const track = this.queue.currentTrack;
2589
+ if (track && this.antiStuckEnabled) {
2590
+ void this.attemptTrackRecovery(track, error).then((recovered) => {
2591
+ if (!recovered) {
2592
+ this.playNext();
2593
+ }
2594
+ });
2595
+ return;
2596
+ }
2597
+ this.playNext();
2598
+ });
2599
+
2600
+ this.audioPlayer.on("debug", (...args) => {
2601
+ if (this.manager.debugEnabled) {
2602
+ this.emit("debug", ...args);
2603
+ }
2604
+ });
2605
+ //stream Manager events
2606
+
2607
+ this.streamManager.on("streamError", ({ streamId, error }) => {
2608
+ this.debug(`[StreamManager] Error for stream ${streamId}:`, error);
2609
+ this.emit("streamError", error, this.queue.currentTrack || null);
2610
+ });
2611
+
2612
+ this.streamManager.on("streamRegistered", ({ streamId, track, metadata }) => {
2613
+ this.debug(`[StreamManager] Stream registered: ${track.title} (preload: ${metadata.isPreload})`);
2614
+ });
2615
+
2616
+ this.streamManager.on("streamUnregistered", ({ streamId, track, reason }) => {
2617
+ this.debug(`[StreamManager] Stream unregistered: ${track.title} (reason: ${reason})`);
2618
+ });
2619
+
2620
+ this.streamManager.on("debug", (...args) => {
2621
+ this.debug(...args);
2622
+ });
2623
+ }
2624
+
2625
+ addPlugin(plugin: SourcePlugin): void {
2626
+ this.debug(`[Player] Adding plugin: ${plugin.name}`);
2627
+ this.pluginManager.register(plugin);
2628
+ }
2629
+
2630
+ removePlugin(name: string): boolean {
2631
+ this.debug(`[Player] Removing plugin: ${name}`);
2632
+ return this.pluginManager.unregister(name);
2633
+ }
2634
+ /**
2635
+ * Save the current session of the player, including queue, current track, position, volume, loop mode, auto-play mode, and active extensions/plugins.
2636
+ *
2637
+ * @returns {PlayerSession} The saved session data
2638
+ */
2639
+ saveSession(): PlayerSession {
2640
+ return {
2641
+ guildId: this.guildId,
2642
+ currentTrack: this.currentTrack,
2643
+ position: this.currentResource?.playbackDuration || null,
2644
+ volume: this.volume,
2645
+ queue: this.queue.getTracks(),
2646
+ loopMode: this.queue.loop(),
2647
+ autoPlay: this.queue.autoPlay(),
2648
+ extensions: this.extensionManager.getAll().map((ext) => ext.name),
2649
+ plugins: this.pluginManager.getAll().map((plugin) => plugin.name),
2650
+ };
2651
+ }
2652
+
2653
+ /**
2654
+ * Get serializable state (for manual persistence)
2655
+ */
2656
+ getSerializableState(): object {
2657
+ return {
2658
+ guildId: this.guildId,
2659
+ queue: this.queue.getTracks(),
2660
+ currentTrack: this.currentTrack,
2661
+ volume: this.volume,
2662
+ isPlaying: this.isPlaying,
2663
+ isPaused: this.isPaused,
2664
+ loopMode: this.queue.loop(),
2665
+ autoPlay: this.queue.autoPlay(),
2666
+ filters: this.filter.getFilterString(),
2667
+ timestamp: Date.now(),
2668
+ };
2669
+ }
2670
+
2671
+ /**
2672
+ * Restore from saved state
2673
+ */
2674
+ async restoreState(state: any): Promise<boolean> {
2675
+ try {
2676
+ if (state.volume) this.setVolume(state.volume);
2677
+ if (state.loopMode) this.queue.loop(state.loopMode);
2678
+ if (typeof state.autoPlay === "boolean") this.queue.autoPlay(state.autoPlay);
2679
+ if (state.filters) await this.filter.applyFilters(state.filters.split(","));
2680
+
2681
+ // Restore queue
2682
+ if (state.queue && Array.isArray(state.queue)) {
2683
+ this.queue.clear();
2684
+ this.queue.addMultiple(state.queue);
2685
+ }
2686
+
2687
+ this.debug("[Player] State restored");
2688
+ return true;
2689
+ } catch (error) {
2690
+ this.debug("[Player] Failed to restore state:", error);
2691
+ return false;
2692
+ }
2693
+ }
2694
+
2695
+ /**
2696
+ * Get stream manager stats
2697
+ */
2698
+ getStreamManagerStats() {
2699
+ return {
2700
+ metrics: this.streamManager.getMetrics(),
2701
+ stats: this.streamManager.getStats(),
2702
+ totalStreams: this.streamManager.getStreamCount(),
2703
+ };
2704
+ }
2705
+ //#endregion
2706
+ //#region Getters
2707
+
2708
+ /**
2709
+ * Get the size of the queue
2710
+ *
2711
+ * @returns {number} The size of the queue
2712
+ * @example
2713
+ * const queueSize = player.queueSize;
2714
+ * console.log(`Queue size: ${queueSize}`);
2715
+ */
2716
+ get queueSize(): number {
2717
+ return this.queue.size;
2718
+ }
2719
+
2720
+ /**
2721
+ * Get the current track
2722
+ *
2723
+ * @returns {Track | null} The current track or null
2724
+ * @example
2725
+ * const currentTrack = player.currentTrack;
2726
+ * console.log(`Current track: ${currentTrack?.title}`);
2727
+ */
2728
+ get currentTrack(): Track | null {
2729
+ return this.queue.currentTrack;
2730
+ }
2731
+
2732
+ /**
2733
+ * Get the previous track
2734
+ *
2735
+ * @returns {Track | null} The previous track or null
2736
+ * @example
2737
+ * const previousTrack = player.previousTrack;
2738
+ * console.log(`Previous track: ${previousTrack?.title}`);
2739
+ */
2740
+ get previousTrack(): Track | null {
2741
+ return this.queue.previousTracks?.at(-1) ?? null;
2742
+ }
2743
+
2744
+ /**
2745
+ * Get the upcoming tracks
2746
+ *
2747
+ * @returns {Track[]} The upcoming tracks
2748
+ * @example
2749
+ * const upcomingTracks = player.upcomingTracks;
2750
+ * console.log(`Upcoming tracks: ${upcomingTracks.length}`);
2751
+ */
2752
+ get upcomingTracks(): Track[] {
2753
+ return this.queue.getTracks();
2754
+ }
2755
+
2756
+ /**
2757
+ * Get the previous tracks
2758
+ *
2759
+ * @returns {Track[]} The previous tracks
2760
+ * @example
2761
+ * const previousTracks = player.previousTracks;
2762
+ * console.log(`Previous tracks: ${previousTracks.length}`);
2763
+ */
2764
+ get previousTracks(): Track[] {
2765
+ return this.queue.previousTracks;
2766
+ }
2767
+
2768
+ /**
2769
+ * Get the available plugins
2770
+ *
2771
+ * @returns {string[]} The available plugins
2772
+ * @example
2773
+ * const availablePlugins = player.availablePlugins;
2774
+ * console.log(`Available plugins: ${availablePlugins.length}`);
2775
+ */
2776
+ get availablePlugins(): string[] {
2777
+ return this.pluginManager.getAll().map((p) => p.name);
2778
+ }
2779
+
2780
+ /**
2781
+ * Get the related tracks
2782
+ *
2783
+ * @returns {Track[] | null} The related tracks or null
2784
+ * @example
2785
+ * const relatedTracks = player.relatedTracks;
2786
+ * console.log(`Related tracks: ${relatedTracks?.length}`);
2787
+ */
2788
+ get relatedTracks(): Track[] | null {
2789
+ return this.queue.relatedTracks();
2790
+ }
2791
+
2792
+ get isLive(): boolean {
2793
+ return this.currentTrack?.isLive === true;
2794
+ }
2795
+
2796
+ //#endregion
2797
+ }