ziplayer 0.2.6 → 0.2.7-dev.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.
@@ -1,1689 +1,1704 @@
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.isPlaying = false;
896
- this.isPaused = false;
897
- this.emit("playerStop");
898
- return result;
899
- }
900
-
901
- /**
902
- * Seek to a specific position in the current track
903
- *
904
- * @param {number} position - Position in milliseconds to seek to
905
- * @returns {Promise<boolean>} True if seek was successful
906
- * @example
907
- * // Seek to 30 seconds (30000ms)
908
- * const success = await player.seek(30000);
909
- * console.log(`Seek successful: ${success}`);
910
- *
911
- * // Seek to 1 minute 30 seconds (90000ms)
912
- * await player.seek(90000);
913
- */
914
- async seek(position: number): Promise<boolean> {
915
- this.debug(`[Player] seek called with position: ${position}ms`);
916
-
917
- const track = this.queue.currentTrack;
918
- if (!track) {
919
- this.debug(`[Player] No current track to seek`);
920
- return false;
921
- }
922
-
923
- const totalDuration = track.duration > 1000 ? track.duration : track.duration * 1000;
924
- if (position < 0 || position > totalDuration) {
925
- this.debug(`[Player] Invalid seek position: ${position}ms (track duration: ${totalDuration}ms)`);
926
- return false;
927
- }
928
-
929
- const streaminfo = await this.getStream(track);
930
- if (!streaminfo?.stream) {
931
- this.debug(`[Player] No stream to seek`);
932
- return false;
933
- }
934
-
935
- await this.refeshPlayerResource(true, position);
936
-
937
- return true;
938
- }
939
-
940
- /**
941
- * Skip to the next track or skip to a specific index
942
- *
943
- * @param {number} index - Optional index to skip to (0 = next track)
944
- * @returns {boolean} True if skipped successfully
945
- * @example
946
- * const skipped = player.skip(); // Skip to next track
947
- * const skippedToIndex = player.skip(2); // Skip to track at index 2
948
- * console.log(`Skipped: ${skipped}`);
949
- */
950
- skip(index?: number): boolean {
951
- this.debug(`[Player] skip called with index: ${index}`);
952
- try {
953
- if (typeof index === "number" && index >= 0) {
954
- // Skip to specific index
955
- const targetTrack = this.queue.getTrack(index);
956
- if (!targetTrack) {
957
- this.debug(`[Player] No track found at index ${index}`);
958
- return false;
959
- }
960
-
961
- // Remove tracks from 0 to index-1
962
- for (let i = 0; i < index; i++) {
963
- this.queue.remove(0);
964
- }
965
-
966
- this.debug(`[Player] Skipped to track at index ${index}: ${targetTrack.title}`);
967
- if (this.isPlaying || this.isPaused) {
968
- this.skipLoop = true;
969
- return this.audioPlayer.stop();
970
- }
971
- return true;
972
- }
973
-
974
- if (this.isPlaying || this.isPaused) {
975
- this.skipLoop = true;
976
- return this.audioPlayer.stop();
977
- }
978
-
979
- return true;
980
- } catch (error) {
981
- this.debug(`[Player] skip error:`, error);
982
- return false;
983
- }
984
- }
985
-
986
- /**
987
- * Go back to the previous track in history and play it.
988
- *
989
- * @returns {Promise<boolean>} True if previous track was played successfully
990
- * @example
991
- * const previous = await player.previous();
992
- * console.log(`Previous: ${previous}`);
993
- */
994
- async previous(): Promise<boolean> {
995
- this.debug(`[Player] previous called`);
996
- const track = this.queue.previous();
997
- if (!track) return false;
998
- if (this.queue.currentTrack) this.insert(this.queue.currentTrack, 0);
999
- this.clearLeaveTimeout();
1000
- return this.startTrack(track);
1001
- }
1002
-
1003
- /**
1004
- * Save a track's stream to a file and return a Readable stream
1005
- *
1006
- * @param {Track} track - The track to save
1007
- * @param {SaveOptions | string} options - Save options or filename string (for backward compatibility)
1008
- * @returns {Promise<Readable>} A Readable stream containing the audio data
1009
- * @example
1010
- * // Save current track to file
1011
- * const track = player.currentTrack;
1012
- * if (track) {
1013
- * const stream = await player.save(track);
1014
- *
1015
- * // Use fs to write the stream to file
1016
- * const fs = require('fs');
1017
- * const writeStream = fs.createWriteStream('saved-song.mp3');
1018
- * stream.pipe(writeStream);
1019
- *
1020
- * writeStream.on('finish', () => {
1021
- * console.log('File saved successfully!');
1022
- * });
1023
- * }
1024
- *
1025
- * // Save any track by URL
1026
- * const searchResult = await player.search("Never Gonna Give You Up", userId);
1027
- * if (searchResult.tracks.length > 0) {
1028
- * const stream = await player.save(searchResult.tracks[0]);
1029
- * // Handle the stream...
1030
- * }
1031
- *
1032
- * // Backward compatibility - filename as string
1033
- * const stream = await player.save(track, "my-song.mp3");
1034
- */
1035
- async save(track: Track, options?: SaveOptions | string): Promise<Readable> {
1036
- this.debug(`[Player] save called for track: ${track.title}`);
1037
-
1038
- // Parse options - support both SaveOptions object and filename string (backward compatibility)
1039
- let saveOptions: SaveOptions = {};
1040
- if (typeof options === "string") {
1041
- saveOptions = { filename: options };
1042
- } else if (options) {
1043
- saveOptions = options;
1044
- }
1045
-
1046
- try {
1047
- // Try extensions first
1048
- let streamInfo: StreamInfo | null = await this.pluginManager.getStream(track);
1049
-
1050
- if (!streamInfo || !streamInfo.stream) {
1051
- throw new Error(`No save stream available for track: ${track.title}`);
1052
- }
1053
-
1054
- this.debug(`[Player] Save stream obtained for track: ${track.title}`);
1055
- if (saveOptions.filename) {
1056
- this.debug(`[Player] Save options - filename: ${saveOptions.filename}, quality: ${saveOptions.quality || "default"}`);
1057
- }
1058
-
1059
- // Apply filters if any are active
1060
- let finalStream = streamInfo.stream;
1061
-
1062
- if (saveOptions.filter || saveOptions.seek) {
1063
- try {
1064
- this.filter.clearAll();
1065
- this.filter.applyFilters(saveOptions.filter || []);
1066
- } catch (err) {
1067
- this.debug(`[Player] Error applying save filters:`, err);
1068
- }
1069
-
1070
- this.debug(`[Player] Applying filters to save stream: ${this.filter.getFilterString() || "none"}`);
1071
- finalStream = await this.filter.applyFiltersAndSeek(streamInfo.stream, saveOptions.seek || 0).catch((err) => {
1072
- this.debug(`[Player] Error applying filters to save stream:`, err);
1073
- return streamInfo!.stream; // Fallback to original stream
1074
- });
1075
- }
1076
-
1077
- // Return the stream directly - caller can pipe it to fs.createWriteStream()
1078
- return finalStream;
1079
- } catch (error) {
1080
- this.debug(`[Player] save error:`, error);
1081
- this.emit("playerError", error as Error, track);
1082
- throw error;
1083
- }
1084
- }
1085
-
1086
- /**
1087
- * Loop the current track or queue
1088
- *
1089
- * @param {LoopMode | number} mode - The loop mode to set ("off", "track", "queue") or number (0=off, 1=track, 2=queue)
1090
- * @returns {LoopMode} The loop mode
1091
- * @example
1092
- * const loopMode = player.loop("track"); // Loop current track
1093
- * const loopQueue = player.loop("queue"); // Loop entire queue
1094
- * const loopTrack = player.loop(1); // Loop current track (same as "track")
1095
- * const loopQueueNum = player.loop(2); // Loop entire queue (same as "queue")
1096
- * const noLoop = player.loop("off"); // No loop
1097
- * const noLoopNum = player.loop(0); // No loop (same as "off")
1098
- * console.log(`Loop mode: ${loopMode}`);
1099
- */
1100
- loop(mode?: LoopMode | number): LoopMode {
1101
- this.debug(`[Player] loop called with mode: ${mode}`);
1102
-
1103
- if (typeof mode === "number") {
1104
- // Number mode: convert to text mode
1105
- switch (mode) {
1106
- case 0:
1107
- return this.queue.loop("off");
1108
- case 1:
1109
- return this.queue.loop("track");
1110
- case 2:
1111
- return this.queue.loop("queue");
1112
- default:
1113
- this.debug(`[Player] Invalid loop number: ${mode}, using "off"`);
1114
- return this.queue.loop("off");
1115
- }
1116
- }
1117
-
1118
- return this.queue.loop(mode as LoopMode);
1119
- }
1120
-
1121
- /**
1122
- * Set the auto-play mode
1123
- *
1124
- * @param {boolean} mode - The auto-play mode to set
1125
- * @returns {boolean} The auto-play mode
1126
- * @example
1127
- * const autoPlayMode = player.autoPlay(true);
1128
- * console.log(`Auto-play mode: ${autoPlayMode}`);
1129
- */
1130
- autoPlay(mode?: boolean): boolean {
1131
- return this.queue.autoPlay(mode);
1132
- }
1133
-
1134
- /**
1135
- * Set the volume of the current track
1136
- *
1137
- * @param {number} volume - The volume to set
1138
- * @returns {boolean} True if volume was set successfully
1139
- * @example
1140
- * const volumeSet = player.setVolume(50);
1141
- * console.log(`Volume set: ${volumeSet}`);
1142
- */
1143
- setVolume(volume: number): boolean {
1144
- this.debug(`[Player] setVolume called: ${volume}`);
1145
- if (volume < 0 || volume > 200) return false;
1146
-
1147
- const oldVolume = this.volume;
1148
- this.volume = volume;
1149
- const resourceVolume = this.currentResource?.volume;
1150
-
1151
- if (resourceVolume) {
1152
- if (this.volumeInterval) clearInterval(this.volumeInterval);
1153
-
1154
- const start = resourceVolume.volume;
1155
- const target = this.volume / 100;
1156
- const steps = 10;
1157
- let currentStep = 0;
1158
-
1159
- this.volumeInterval = setInterval(() => {
1160
- currentStep++;
1161
- const value = start + ((target - start) * currentStep) / steps;
1162
- resourceVolume.setVolume(value);
1163
- if (currentStep >= steps) {
1164
- clearInterval(this.volumeInterval!);
1165
- this.volumeInterval = null;
1166
- }
1167
- }, 300);
1168
- }
1169
-
1170
- this.emit("volumeChange", oldVolume, volume);
1171
- return true;
1172
- }
1173
-
1174
- /**
1175
- * Shuffle the queue
1176
- *
1177
- * @returns {void}
1178
- * @example
1179
- * player.shuffle();
1180
- */
1181
- shuffle(): void {
1182
- this.debug(`[Player] shuffle called`);
1183
- this.queue.shuffle();
1184
- }
1185
-
1186
- /**
1187
- * Clear the queue
1188
- *
1189
- * @returns {void}
1190
- * @example
1191
- * player.clearQueue();
1192
- */
1193
- clearQueue(): void {
1194
- this.debug(`[Player] clearQueue called`);
1195
- this.queue.clear();
1196
- }
1197
-
1198
- /**
1199
- * Insert a track or list of tracks into the upcoming queue at a specific position (0 = play after current).
1200
- * - If `query` is a string, performs a search and inserts resulting tracks (playlist supported).
1201
- * - If a Track or Track[] is provided, inserts directly.
1202
- * Does not auto-start playback; it only modifies the queue.
1203
- *
1204
- * @param {string | Track | Track[]} query - The track or tracks to insert
1205
- * @param {number} index - The index to insert the tracks at
1206
- * @param {string} requestedBy - The user ID who requested the insert
1207
- * @returns {Promise<boolean>} True if the tracks were inserted successfully
1208
- * @example
1209
- * const inserted = await player.insert("Song Name", 0, userId);
1210
- * console.log(`Inserted: ${inserted}`);
1211
- */
1212
- async insert(query: string | Track | Track[], index: number, requestedBy?: string): Promise<boolean> {
1213
- try {
1214
- this.debug(`[Player] insert called at index ${index} with type: ${typeof query}`);
1215
- let tracksToAdd: Track[] = [];
1216
- let isPlaylist = false;
1217
-
1218
- if (typeof query === "string") {
1219
- const searchResult = await this.search(query, requestedBy || "Unknown");
1220
- tracksToAdd = searchResult.tracks || [];
1221
- isPlaylist = !!searchResult.playlist;
1222
- } else if (Array.isArray(query)) {
1223
- tracksToAdd = query;
1224
- isPlaylist = query.length > 1;
1225
- } else if (query) {
1226
- tracksToAdd = [query];
1227
- }
1228
-
1229
- if (!tracksToAdd || tracksToAdd.length === 0) {
1230
- this.debug(`[Player] insert: no tracks resolved`);
1231
- throw new Error("No tracks to insert");
1232
- }
1233
-
1234
- if (tracksToAdd.length === 1) {
1235
- this.queue.insert(tracksToAdd[0], index);
1236
- this.emit("queueAdd", tracksToAdd[0]);
1237
- this.debug(`[Player] Inserted track at index ${index}: ${tracksToAdd[0].title}`);
1238
- } else {
1239
- this.queue.insertMultiple(tracksToAdd, index);
1240
- this.emit("queueAddList", tracksToAdd);
1241
- this.debug(`[Player] Inserted ${tracksToAdd.length} ${isPlaylist ? "playlist " : ""}tracks at index ${index}`);
1242
- }
1243
-
1244
- return true;
1245
- } catch (error) {
1246
- this.debug(`[Player] insert error:`, error);
1247
- this.emit("playerError", error as Error);
1248
- return false;
1249
- }
1250
- }
1251
-
1252
- /**
1253
- * Remove a track from the queue
1254
- *
1255
- * @param {number} index - The index of the track to remove
1256
- * @returns {Track | null} The removed track or null
1257
- * @example
1258
- * const removed = player.remove(0);
1259
- * console.log(`Removed: ${removed?.title}`);
1260
- */
1261
- remove(index: number): Track | null {
1262
- this.debug(`[Player] remove called for index: ${index}`);
1263
- const track = this.queue.remove(index);
1264
- if (track) {
1265
- this.emit("queueRemove", track, index);
1266
- }
1267
- return track;
1268
- }
1269
-
1270
- /**
1271
- * Get the progress bar of the current track
1272
- *
1273
- * @param {ProgressBarOptions} options - The options for the progress bar
1274
- * @returns {string} The progress bar
1275
- * @example
1276
- * const progressBar = player.getProgressBar();
1277
- * console.log(`Progress bar: ${progressBar}`);
1278
- */
1279
- getProgressBar(options: ProgressBarOptions = {}): string {
1280
- const { size = 20, barChar = "▬", progressChar = "🔘" } = options;
1281
- const track = this.queue.currentTrack;
1282
- const resource = this.currentResource;
1283
- if (!track || !resource) return "";
1284
-
1285
- const total = track.duration > 1000 ? track.duration : track.duration * 1000;
1286
- if (!total) return this.formatTime(resource.playbackDuration);
1287
-
1288
- const current = resource.playbackDuration;
1289
- const ratio = Math.min(current / total, 1);
1290
- const progress = Math.round(ratio * size);
1291
- const bar = barChar.repeat(progress) + progressChar + barChar.repeat(size - progress);
1292
-
1293
- return `${this.formatTime(current)} | ${bar} | ${this.formatTime(total)}`;
1294
- }
1295
-
1296
- /**
1297
- * Get the time of the current track
1298
- *
1299
- * @returns {Object} The time of the current track
1300
- * @example
1301
- * const time = player.getTime();
1302
- * console.log(`Time: ${time.current}`);
1303
- */
1304
- getTime() {
1305
- const resource = this.currentResource;
1306
- const track = this.queue.currentTrack;
1307
- if (!track || !resource)
1308
- return {
1309
- current: 0,
1310
- total: 0,
1311
- format: "00:00",
1312
- };
1313
-
1314
- const total = track.duration > 1000 ? track.duration : track.duration * 1000;
1315
-
1316
- return {
1317
- current: resource?.playbackDuration,
1318
- total: total,
1319
- format: this.formatTime(resource.playbackDuration),
1320
- };
1321
- }
1322
-
1323
- /**
1324
- * Format the time in the format of HH:MM:SS
1325
- *
1326
- * @param {number} ms - The time in milliseconds
1327
- * @returns {string} The formatted time
1328
- * @example
1329
- * const formattedTime = player.formatTime(1000);
1330
- * console.log(`Formatted time: ${formattedTime}`);
1331
- */
1332
- formatTime(ms: number): string {
1333
- const totalSeconds = Math.floor(ms / 1000);
1334
- const hours = Math.floor(totalSeconds / 3600);
1335
- const minutes = Math.floor((totalSeconds % 3600) / 60);
1336
- const seconds = totalSeconds % 60;
1337
- const parts: string[] = [];
1338
- if (hours > 0) parts.push(String(hours).padStart(2, "0"));
1339
- parts.push(String(minutes).padStart(2, "0"));
1340
- parts.push(String(seconds).padStart(2, "0"));
1341
- return parts.join(":");
1342
- }
1343
-
1344
- /**
1345
- * Destroy the player
1346
- *
1347
- * @returns {void}
1348
- * @example
1349
- * player.destroy();
1350
- */
1351
- destroy(): void {
1352
- this.debug(`[Player] destroy called`);
1353
- if (this.leaveTimeout) {
1354
- clearTimeout(this.leaveTimeout);
1355
- this.leaveTimeout = null;
1356
- }
1357
-
1358
- // Destroy current stream before stopping audio
1359
- this.destroyCurrentStream();
1360
-
1361
- this.audioPlayer.stop(true);
1362
-
1363
- if (this.ttsPlayer) {
1364
- try {
1365
- this.ttsPlayer.stop(true);
1366
- } catch {}
1367
- this.ttsPlayer = null;
1368
- }
1369
-
1370
- if (this.connection) {
1371
- this.connection.destroy();
1372
- this.connection = null;
1373
- }
1374
-
1375
- this.queue.clear();
1376
- this.pluginManager.clear();
1377
- this.filter.destroy();
1378
- this.extensionManager.destroy();
1379
- this.isPlaying = false;
1380
- this.isPaused = false;
1381
-
1382
- // Clear any remaining intervals
1383
- if (this.volumeInterval) {
1384
- clearInterval(this.volumeInterval);
1385
- this.volumeInterval = null;
1386
- }
1387
-
1388
- this.emit("playerDestroy");
1389
- this.removeAllListeners();
1390
- }
1391
-
1392
- //#endregion
1393
- //#region utils
1394
- private scheduleLeave(): void {
1395
- this.debug(`[Player] scheduleLeave called`);
1396
- if (this.leaveTimeout) {
1397
- clearTimeout(this.leaveTimeout);
1398
- }
1399
-
1400
- if (this.options.leaveOnEmpty && this.options.leaveTimeout) {
1401
- this.leaveTimeout = setTimeout(() => {
1402
- this.debug(`[Player] Leaving voice channel after timeoutMs`);
1403
- this.destroy();
1404
- }, this.options.leaveTimeout);
1405
- }
1406
- }
1407
-
1408
- /**
1409
- * Refesh player resource (apply filter)
1410
- *
1411
- * @param {boolean} applyToCurrent - Apply filter for curent track
1412
- * @param {number} position - Position to seek to in milliseconds
1413
- * @returns {Promise<boolean>}
1414
- * @example
1415
- * const refreshed = await player.refeshPlayerResource(true, 1000);
1416
- * console.log(`Refreshed: ${refreshed}`);
1417
- */
1418
- public async refeshPlayerResource(applyToCurrent: boolean = true, position: number = -1): Promise<boolean> {
1419
- if (!applyToCurrent || !this.queue.currentTrack || !(this.isPlaying || this.isPaused)) {
1420
- return false;
1421
- }
1422
-
1423
- try {
1424
- const track = this.queue.currentTrack;
1425
- this.debug(`[Player] Refreshing player resource for track: ${track.title}`);
1426
-
1427
- // Get current position for seeking
1428
- const currentPosition = position > 0 ? position : this.currentResource?.playbackDuration || 0;
1429
-
1430
- const streaminfo = await this.getStream(track);
1431
- if (!streaminfo?.stream) {
1432
- this.debug(`[Player] No stream to refresh`);
1433
- return false;
1434
- }
1435
-
1436
- // Create AudioResource with filters and seek to current position
1437
- const resource = await this.createResource(streaminfo, track, currentPosition);
1438
-
1439
- // Stop current playback and destroy old resource/stream
1440
- const wasPlaying = this.isPlaying;
1441
- const wasPaused = this.isPaused;
1442
-
1443
- this.audioPlayer.stop();
1444
-
1445
- // Properly destroy the old resource and stream
1446
- try {
1447
- if (this.currentResource) {
1448
- const oldStream = (this.currentResource as any)._readableState?.stream || (this.currentResource as any).stream;
1449
- if (oldStream && typeof oldStream.destroy === "function") {
1450
- oldStream.destroy();
1451
- }
1452
- }
1453
- } catch (error) {
1454
- this.debug(`[Player] Error destroying old stream in refeshPlayerResource:`, error);
1455
- }
1456
-
1457
- this.currentResource = resource;
1458
-
1459
- // Subscribe to new resource
1460
- if (this.connection) {
1461
- this.connection.subscribe(this.audioPlayer);
1462
- this.audioPlayer.play(resource);
1463
- }
1464
-
1465
- // Restore playing state
1466
- if (wasPlaying && !wasPaused) {
1467
- this.isPlaying = true;
1468
- this.isPaused = false;
1469
- } else if (wasPaused) {
1470
- this.isPlaying = false;
1471
- this.isPaused = true;
1472
- this.audioPlayer.pause();
1473
- }
1474
-
1475
- this.debug(`[Player] Successfully applied filter to current track at position ${currentPosition}ms`);
1476
- return true;
1477
- } catch (error) {
1478
- this.debug(`[Player] Error applying filter to current track:`, error);
1479
- // Filter was still added to active filters, so return true
1480
- return true;
1481
- }
1482
- }
1483
-
1484
- /**
1485
- * Attach an extension to the player
1486
- *
1487
- * @param {BaseExtension} extension - The extension to attach
1488
- * @example
1489
- * player.attachExtension(new MyExtension());
1490
- */
1491
- public attachExtension(extension: BaseExtension): void {
1492
- this.extensionManager.register(extension);
1493
- }
1494
-
1495
- /**
1496
- * Detach an extension from the player
1497
- *
1498
- * @param {BaseExtension} extension - The extension to detach
1499
- * @example
1500
- * player.detachExtension(new MyExtension());
1501
- */
1502
- public detachExtension(extension: BaseExtension): void {
1503
- this.extensionManager.unregister(extension);
1504
- }
1505
-
1506
- /**
1507
- * Get all extensions attached to the player
1508
- *
1509
- * @returns {readonly BaseExtension[]} All attached extensions
1510
- * @example
1511
- * const extensions = player.getExtensions();
1512
- * console.log(`Extensions: ${extensions.length}`);
1513
- */
1514
- public getExtensions(): readonly BaseExtension[] {
1515
- return this.extensionManager.getAll();
1516
- }
1517
-
1518
- private clearLeaveTimeout(): void {
1519
- if (this.leaveTimeout) {
1520
- clearTimeout(this.leaveTimeout);
1521
- this.leaveTimeout = null;
1522
- this.debug(`[Player] Cleared leave timeoutMs`);
1523
- }
1524
- }
1525
-
1526
- private debug(message?: any, ...optionalParams: any[]): void {
1527
- if (this.listenerCount("debug") > 0) {
1528
- this.emit("debug", message, ...optionalParams);
1529
- }
1530
- }
1531
-
1532
- private setupEventListeners(): void {
1533
- this.audioPlayer.on("stateChange", (oldState, newState) => {
1534
- this.debug(`[Player] AudioPlayer stateChange from ${oldState.status} to ${newState.status}`);
1535
- if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) {
1536
- // Track ended
1537
- const track = this.queue.currentTrack;
1538
- if (track) {
1539
- this.debug(`[Player] Track ended: ${track.title}`);
1540
- this.emit("trackEnd", track);
1541
- }
1542
- this.playNext();
1543
- } else if (
1544
- newState.status === AudioPlayerStatus.Playing &&
1545
- (oldState.status === AudioPlayerStatus.Idle || oldState.status === AudioPlayerStatus.Buffering)
1546
- ) {
1547
- // Track started
1548
- this.clearLeaveTimeout();
1549
- this.isPlaying = true;
1550
- this.isPaused = false;
1551
- const track = this.queue.currentTrack;
1552
- if (track) {
1553
- this.debug(`[Player] Track started: ${track.title}`);
1554
- this.emit("trackStart", track);
1555
- }
1556
- } else if (newState.status === AudioPlayerStatus.Paused && oldState.status !== AudioPlayerStatus.Paused) {
1557
- // Track paused
1558
- this.isPaused = true;
1559
- const track = this.queue.currentTrack;
1560
- if (track) {
1561
- this.debug(`[Player] Player paused on track: ${track.title}`);
1562
- this.emit("playerPause", track);
1563
- }
1564
- } else if (newState.status !== AudioPlayerStatus.Paused && oldState.status === AudioPlayerStatus.Paused) {
1565
- // Track resumed
1566
- this.isPaused = false;
1567
- const track = this.queue.currentTrack;
1568
- if (track) {
1569
- this.debug(`[Player] Player resumed on track: ${track.title}`);
1570
- this.emit("playerResume", track);
1571
- }
1572
- } else if (newState.status === AudioPlayerStatus.AutoPaused) {
1573
- this.debug(`[Player] AudioPlayerStatus.AutoPaused`);
1574
- } else if (newState.status === AudioPlayerStatus.Buffering) {
1575
- this.debug(`[Player] AudioPlayerStatus.Buffering`);
1576
- }
1577
- });
1578
- this.audioPlayer.on("error", (error) => {
1579
- this.debug(`[Player] AudioPlayer error:`, error);
1580
- this.emit("playerError", error, this.queue.currentTrack || undefined);
1581
- this.playNext();
1582
- });
1583
-
1584
- this.audioPlayer.on("debug", (...args) => {
1585
- if (this.manager.debugEnabled) {
1586
- this.emit("debug", ...args);
1587
- }
1588
- });
1589
- }
1590
-
1591
- addPlugin(plugin: SourcePlugin): void {
1592
- this.debug(`[Player] Adding plugin: ${plugin.name}`);
1593
- this.pluginManager.register(plugin);
1594
- }
1595
-
1596
- removePlugin(name: string): boolean {
1597
- this.debug(`[Player] Removing plugin: ${name}`);
1598
- return this.pluginManager.unregister(name);
1599
- }
1600
-
1601
- //#endregion
1602
- //#region Getters
1603
-
1604
- /**
1605
- * Get the size of the queue
1606
- *
1607
- * @returns {number} The size of the queue
1608
- * @example
1609
- * const queueSize = player.queueSize;
1610
- * console.log(`Queue size: ${queueSize}`);
1611
- */
1612
- get queueSize(): number {
1613
- return this.queue.size;
1614
- }
1615
-
1616
- /**
1617
- * Get the current track
1618
- *
1619
- * @returns {Track | null} The current track or null
1620
- * @example
1621
- * const currentTrack = player.currentTrack;
1622
- * console.log(`Current track: ${currentTrack?.title}`);
1623
- */
1624
- get currentTrack(): Track | null {
1625
- return this.queue.currentTrack;
1626
- }
1627
-
1628
- /**
1629
- * Get the previous track
1630
- *
1631
- * @returns {Track | null} The previous track or null
1632
- * @example
1633
- * const previousTrack = player.previousTrack;
1634
- * console.log(`Previous track: ${previousTrack?.title}`);
1635
- */
1636
- get previousTrack(): Track | null {
1637
- return this.queue.previousTracks?.at(-1) ?? null;
1638
- }
1639
-
1640
- /**
1641
- * Get the upcoming tracks
1642
- *
1643
- * @returns {Track[]} The upcoming tracks
1644
- * @example
1645
- * const upcomingTracks = player.upcomingTracks;
1646
- * console.log(`Upcoming tracks: ${upcomingTracks.length}`);
1647
- */
1648
- get upcomingTracks(): Track[] {
1649
- return this.queue.getTracks();
1650
- }
1651
-
1652
- /**
1653
- * Get the previous tracks
1654
- *
1655
- * @returns {Track[]} The previous tracks
1656
- * @example
1657
- * const previousTracks = player.previousTracks;
1658
- * console.log(`Previous tracks: ${previousTracks.length}`);
1659
- */
1660
- get previousTracks(): Track[] {
1661
- return this.queue.previousTracks;
1662
- }
1663
-
1664
- /**
1665
- * Get the available plugins
1666
- *
1667
- * @returns {string[]} The available plugins
1668
- * @example
1669
- * const availablePlugins = player.availablePlugins;
1670
- * console.log(`Available plugins: ${availablePlugins.length}`);
1671
- */
1672
- get availablePlugins(): string[] {
1673
- return this.pluginManager.getAll().map((p) => p.name);
1674
- }
1675
-
1676
- /**
1677
- * Get the related tracks
1678
- *
1679
- * @returns {Track[] | null} The related tracks or null
1680
- * @example
1681
- * const relatedTracks = player.relatedTracks;
1682
- * console.log(`Related tracks: ${relatedTracks?.length}`);
1683
- */
1684
- get relatedTracks(): Track[] | null {
1685
- return this.queue.relatedTracks();
1686
- }
1687
-
1688
- //#endregion
1689
- }
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, async () => {
823
+ try {
824
+ // move channel
825
+ await Promise.race([
826
+ entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
827
+ entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
828
+ ]);
829
+ // Signalling/Connecting → reconnect
830
+ this.debug(`[Player] Reconnecting after channel move...`);
831
+ } catch {
832
+ // no reconnect in 5 giây → disconnect
833
+ this.debug(`[Player] Truly disconnected, destroying player`);
834
+ this.destroy();
835
+ }
836
+ });
837
+
838
+ connection.on("error", (error) => {
839
+ this.debug(`[Player] Voice connection error:`, error);
840
+ this.emit("connectionError", error);
841
+ });
842
+ connection.subscribe(this.audioPlayer);
843
+
844
+ this.clearLeaveTimeout();
845
+ return this.connection;
846
+ } catch (error) {
847
+ this.debug(`[Player] Connection error:`, error);
848
+ this.emit("connectionError", error as Error);
849
+ this.connection?.destroy();
850
+ throw error;
851
+ }
852
+ }
853
+
854
+ /**
855
+ * Pause the current track
856
+ *
857
+ * @returns {boolean} True if paused successfully
858
+ * @example
859
+ * const paused = player.pause();
860
+ * console.log(`Paused: ${paused}`);
861
+ */
862
+ pause(): boolean {
863
+ this.debug(`[Player] pause called`);
864
+ if (this.isPlaying && !this.isPaused) {
865
+ return this.audioPlayer.pause();
866
+ }
867
+ return false;
868
+ }
869
+
870
+ /**
871
+ * Resume the current track
872
+ *
873
+ * @returns {boolean} True if resumed successfully
874
+ * @example
875
+ * const resumed = player.resume();
876
+ * console.log(`Resumed: ${resumed}`);
877
+ */
878
+ resume(): boolean {
879
+ this.debug(`[Player] resume called`);
880
+ if (this.isPaused) {
881
+ const result = this.audioPlayer.unpause();
882
+ if (result) {
883
+ const track = this.queue.currentTrack;
884
+ if (track) {
885
+ this.debug(`[Player] Player resumed on track: ${track.title}`);
886
+ this.emit("playerResume", track);
887
+ }
888
+ }
889
+ return result;
890
+ }
891
+ return false;
892
+ }
893
+
894
+ /**
895
+ * Stop the current track
896
+ *
897
+ * @returns {boolean} True if stopped successfully
898
+ * @example
899
+ * const stopped = player.stop();
900
+ * console.log(`Stopped: ${stopped}`);
901
+ */
902
+ stop(): boolean {
903
+ this.debug(`[Player] stop called`);
904
+ this.queue.clear();
905
+ const result = this.audioPlayer.stop();
906
+ this.destroyCurrentStream();
907
+ this.currentResource = null;
908
+
909
+ this.isPlaying = false;
910
+ this.isPaused = false;
911
+ this.emit("playerStop");
912
+ return result;
913
+ }
914
+
915
+ /**
916
+ * Seek to a specific position in the current track
917
+ *
918
+ * @param {number} position - Position in milliseconds to seek to
919
+ * @returns {Promise<boolean>} True if seek was successful
920
+ * @example
921
+ * // Seek to 30 seconds (30000ms)
922
+ * const success = await player.seek(30000);
923
+ * console.log(`Seek successful: ${success}`);
924
+ *
925
+ * // Seek to 1 minute 30 seconds (90000ms)
926
+ * await player.seek(90000);
927
+ */
928
+ async seek(position: number): Promise<boolean> {
929
+ this.debug(`[Player] seek called with position: ${position}ms`);
930
+
931
+ const track = this.queue.currentTrack;
932
+ if (!track) {
933
+ this.debug(`[Player] No current track to seek`);
934
+ return false;
935
+ }
936
+
937
+ const totalDuration = track.duration > 1000 ? track.duration : track.duration * 1000;
938
+ if (position < 0 || position > totalDuration) {
939
+ this.debug(`[Player] Invalid seek position: ${position}ms (track duration: ${totalDuration}ms)`);
940
+ return false;
941
+ }
942
+
943
+ const streaminfo = await this.getStream(track);
944
+ if (!streaminfo?.stream) {
945
+ this.debug(`[Player] No stream to seek`);
946
+ return false;
947
+ }
948
+
949
+ await this.refeshPlayerResource(true, position);
950
+
951
+ return true;
952
+ }
953
+
954
+ /**
955
+ * Skip to the next track or skip to a specific index
956
+ *
957
+ * @param {number} index - Optional index to skip to (0 = next track)
958
+ * @returns {boolean} True if skipped successfully
959
+ * @example
960
+ * const skipped = player.skip(); // Skip to next track
961
+ * const skippedToIndex = player.skip(2); // Skip to track at index 2
962
+ * console.log(`Skipped: ${skipped}`);
963
+ */
964
+ skip(index?: number): boolean {
965
+ this.debug(`[Player] skip called with index: ${index}`);
966
+ try {
967
+ if (typeof index === "number" && index >= 0) {
968
+ // Skip to specific index
969
+ const targetTrack = this.queue.getTrack(index);
970
+ if (!targetTrack) {
971
+ this.debug(`[Player] No track found at index ${index}`);
972
+ return false;
973
+ }
974
+
975
+ // Remove tracks from 0 to index-1
976
+ for (let i = 0; i < index; i++) {
977
+ this.queue.remove(0);
978
+ }
979
+
980
+ this.debug(`[Player] Skipped to track at index ${index}: ${targetTrack.title}`);
981
+ if (this.isPlaying || this.isPaused) {
982
+ this.skipLoop = true;
983
+ return this.audioPlayer.stop();
984
+ }
985
+ return true;
986
+ }
987
+
988
+ if (this.isPlaying || this.isPaused) {
989
+ this.skipLoop = true;
990
+ return this.audioPlayer.stop();
991
+ }
992
+
993
+ return true;
994
+ } catch (error) {
995
+ this.debug(`[Player] skip error:`, error);
996
+ return false;
997
+ }
998
+ }
999
+
1000
+ /**
1001
+ * Go back to the previous track in history and play it.
1002
+ *
1003
+ * @returns {Promise<boolean>} True if previous track was played successfully
1004
+ * @example
1005
+ * const previous = await player.previous();
1006
+ * console.log(`Previous: ${previous}`);
1007
+ */
1008
+ async previous(): Promise<boolean> {
1009
+ this.debug(`[Player] previous called`);
1010
+ const track = this.queue.previous();
1011
+ if (!track) return false;
1012
+ if (this.queue.currentTrack) this.insert(this.queue.currentTrack, 0);
1013
+ this.clearLeaveTimeout();
1014
+ return this.startTrack(track);
1015
+ }
1016
+
1017
+ /**
1018
+ * Save a track's stream to a file and return a Readable stream
1019
+ *
1020
+ * @param {Track} track - The track to save
1021
+ * @param {SaveOptions | string} options - Save options or filename string (for backward compatibility)
1022
+ * @returns {Promise<Readable>} A Readable stream containing the audio data
1023
+ * @example
1024
+ * // Save current track to file
1025
+ * const track = player.currentTrack;
1026
+ * if (track) {
1027
+ * const stream = await player.save(track);
1028
+ *
1029
+ * // Use fs to write the stream to file
1030
+ * const fs = require('fs');
1031
+ * const writeStream = fs.createWriteStream('saved-song.mp3');
1032
+ * stream.pipe(writeStream);
1033
+ *
1034
+ * writeStream.on('finish', () => {
1035
+ * console.log('File saved successfully!');
1036
+ * });
1037
+ * }
1038
+ *
1039
+ * // Save any track by URL
1040
+ * const searchResult = await player.search("Never Gonna Give You Up", userId);
1041
+ * if (searchResult.tracks.length > 0) {
1042
+ * const stream = await player.save(searchResult.tracks[0]);
1043
+ * // Handle the stream...
1044
+ * }
1045
+ *
1046
+ * // Backward compatibility - filename as string
1047
+ * const stream = await player.save(track, "my-song.mp3");
1048
+ */
1049
+ async save(track: Track, options?: SaveOptions | string): Promise<Readable> {
1050
+ this.debug(`[Player] save called for track: ${track.title}`);
1051
+
1052
+ // Parse options - support both SaveOptions object and filename string (backward compatibility)
1053
+ let saveOptions: SaveOptions = {};
1054
+ if (typeof options === "string") {
1055
+ saveOptions = { filename: options };
1056
+ } else if (options) {
1057
+ saveOptions = options;
1058
+ }
1059
+
1060
+ try {
1061
+ // Try extensions first
1062
+ let streamInfo: StreamInfo | null = await this.pluginManager.getStream(track);
1063
+
1064
+ if (!streamInfo || !streamInfo.stream) {
1065
+ throw new Error(`No save stream available for track: ${track.title}`);
1066
+ }
1067
+
1068
+ this.debug(`[Player] Save stream obtained for track: ${track.title}`);
1069
+ if (saveOptions.filename) {
1070
+ this.debug(`[Player] Save options - filename: ${saveOptions.filename}, quality: ${saveOptions.quality || "default"}`);
1071
+ }
1072
+
1073
+ // Apply filters if any are active
1074
+ let finalStream = streamInfo.stream;
1075
+
1076
+ if (saveOptions.filter || saveOptions.seek) {
1077
+ try {
1078
+ this.filter.clearAll();
1079
+ this.filter.applyFilters(saveOptions.filter || []);
1080
+ } catch (err) {
1081
+ this.debug(`[Player] Error applying save filters:`, err);
1082
+ }
1083
+
1084
+ this.debug(`[Player] Applying filters to save stream: ${this.filter.getFilterString() || "none"}`);
1085
+ finalStream = await this.filter.applyFiltersAndSeek(streamInfo.stream, saveOptions.seek || 0).catch((err) => {
1086
+ this.debug(`[Player] Error applying filters to save stream:`, err);
1087
+ return streamInfo!.stream; // Fallback to original stream
1088
+ });
1089
+ }
1090
+
1091
+ // Return the stream directly - caller can pipe it to fs.createWriteStream()
1092
+ return finalStream;
1093
+ } catch (error) {
1094
+ this.debug(`[Player] save error:`, error);
1095
+ this.emit("playerError", error as Error, track);
1096
+ throw error;
1097
+ }
1098
+ }
1099
+
1100
+ /**
1101
+ * Loop the current track or queue
1102
+ *
1103
+ * @param {LoopMode | number} mode - The loop mode to set ("off", "track", "queue") or number (0=off, 1=track, 2=queue)
1104
+ * @returns {LoopMode} The loop mode
1105
+ * @example
1106
+ * const loopMode = player.loop("track"); // Loop current track
1107
+ * const loopQueue = player.loop("queue"); // Loop entire queue
1108
+ * const loopTrack = player.loop(1); // Loop current track (same as "track")
1109
+ * const loopQueueNum = player.loop(2); // Loop entire queue (same as "queue")
1110
+ * const noLoop = player.loop("off"); // No loop
1111
+ * const noLoopNum = player.loop(0); // No loop (same as "off")
1112
+ * console.log(`Loop mode: ${loopMode}`);
1113
+ */
1114
+ loop(mode?: LoopMode | number): LoopMode {
1115
+ this.debug(`[Player] loop called with mode: ${mode}`);
1116
+
1117
+ if (typeof mode === "number") {
1118
+ // Number mode: convert to text mode
1119
+ switch (mode) {
1120
+ case 0:
1121
+ return this.queue.loop("off");
1122
+ case 1:
1123
+ return this.queue.loop("track");
1124
+ case 2:
1125
+ return this.queue.loop("queue");
1126
+ default:
1127
+ this.debug(`[Player] Invalid loop number: ${mode}, using "off"`);
1128
+ return this.queue.loop("off");
1129
+ }
1130
+ }
1131
+
1132
+ return this.queue.loop(mode as LoopMode);
1133
+ }
1134
+
1135
+ /**
1136
+ * Set the auto-play mode
1137
+ *
1138
+ * @param {boolean} mode - The auto-play mode to set
1139
+ * @returns {boolean} The auto-play mode
1140
+ * @example
1141
+ * const autoPlayMode = player.autoPlay(true);
1142
+ * console.log(`Auto-play mode: ${autoPlayMode}`);
1143
+ */
1144
+ autoPlay(mode?: boolean): boolean {
1145
+ return this.queue.autoPlay(mode);
1146
+ }
1147
+
1148
+ /**
1149
+ * Set the volume of the current track
1150
+ *
1151
+ * @param {number} volume - The volume to set
1152
+ * @returns {boolean} True if volume was set successfully
1153
+ * @example
1154
+ * const volumeSet = player.setVolume(50);
1155
+ * console.log(`Volume set: ${volumeSet}`);
1156
+ */
1157
+ setVolume(volume: number): boolean {
1158
+ this.debug(`[Player] setVolume called: ${volume}`);
1159
+ if (volume < 0 || volume > 200) return false;
1160
+
1161
+ const oldVolume = this.volume;
1162
+ this.volume = volume;
1163
+ const resourceVolume = this.currentResource?.volume;
1164
+
1165
+ if (resourceVolume) {
1166
+ if (this.volumeInterval) clearInterval(this.volumeInterval);
1167
+
1168
+ const start = resourceVolume.volume;
1169
+ const target = this.volume / 100;
1170
+ const steps = 10;
1171
+ let currentStep = 0;
1172
+
1173
+ this.volumeInterval = setInterval(() => {
1174
+ currentStep++;
1175
+ const value = start + ((target - start) * currentStep) / steps;
1176
+ resourceVolume.setVolume(value);
1177
+ if (currentStep >= steps) {
1178
+ clearInterval(this.volumeInterval!);
1179
+ this.volumeInterval = null;
1180
+ }
1181
+ }, 300);
1182
+ }
1183
+
1184
+ this.emit("volumeChange", oldVolume, volume);
1185
+ return true;
1186
+ }
1187
+
1188
+ /**
1189
+ * Shuffle the queue
1190
+ *
1191
+ * @returns {void}
1192
+ * @example
1193
+ * player.shuffle();
1194
+ */
1195
+ shuffle(): void {
1196
+ this.debug(`[Player] shuffle called`);
1197
+ this.queue.shuffle();
1198
+ }
1199
+
1200
+ /**
1201
+ * Clear the queue
1202
+ *
1203
+ * @returns {void}
1204
+ * @example
1205
+ * player.clearQueue();
1206
+ */
1207
+ clearQueue(): void {
1208
+ this.debug(`[Player] clearQueue called`);
1209
+ this.queue.clear();
1210
+ }
1211
+
1212
+ /**
1213
+ * Insert a track or list of tracks into the upcoming queue at a specific position (0 = play after current).
1214
+ * - If `query` is a string, performs a search and inserts resulting tracks (playlist supported).
1215
+ * - If a Track or Track[] is provided, inserts directly.
1216
+ * Does not auto-start playback; it only modifies the queue.
1217
+ *
1218
+ * @param {string | Track | Track[]} query - The track or tracks to insert
1219
+ * @param {number} index - The index to insert the tracks at
1220
+ * @param {string} requestedBy - The user ID who requested the insert
1221
+ * @returns {Promise<boolean>} True if the tracks were inserted successfully
1222
+ * @example
1223
+ * const inserted = await player.insert("Song Name", 0, userId);
1224
+ * console.log(`Inserted: ${inserted}`);
1225
+ */
1226
+ async insert(query: string | Track | Track[], index: number, requestedBy?: string): Promise<boolean> {
1227
+ try {
1228
+ this.debug(`[Player] insert called at index ${index} with type: ${typeof query}`);
1229
+ let tracksToAdd: Track[] = [];
1230
+ let isPlaylist = false;
1231
+
1232
+ if (typeof query === "string") {
1233
+ const searchResult = await this.search(query, requestedBy || "Unknown");
1234
+ tracksToAdd = searchResult.tracks || [];
1235
+ isPlaylist = !!searchResult.playlist;
1236
+ } else if (Array.isArray(query)) {
1237
+ tracksToAdd = query;
1238
+ isPlaylist = query.length > 1;
1239
+ } else if (query) {
1240
+ tracksToAdd = [query];
1241
+ }
1242
+
1243
+ if (!tracksToAdd || tracksToAdd.length === 0) {
1244
+ this.debug(`[Player] insert: no tracks resolved`);
1245
+ throw new Error("No tracks to insert");
1246
+ }
1247
+
1248
+ if (tracksToAdd.length === 1) {
1249
+ this.queue.insert(tracksToAdd[0], index);
1250
+ this.emit("queueAdd", tracksToAdd[0]);
1251
+ this.debug(`[Player] Inserted track at index ${index}: ${tracksToAdd[0].title}`);
1252
+ } else {
1253
+ this.queue.insertMultiple(tracksToAdd, index);
1254
+ this.emit("queueAddList", tracksToAdd);
1255
+ this.debug(`[Player] Inserted ${tracksToAdd.length} ${isPlaylist ? "playlist " : ""}tracks at index ${index}`);
1256
+ }
1257
+
1258
+ return true;
1259
+ } catch (error) {
1260
+ this.debug(`[Player] insert error:`, error);
1261
+ this.emit("playerError", error as Error);
1262
+ return false;
1263
+ }
1264
+ }
1265
+
1266
+ /**
1267
+ * Remove a track from the queue
1268
+ *
1269
+ * @param {number} index - The index of the track to remove
1270
+ * @returns {Track | null} The removed track or null
1271
+ * @example
1272
+ * const removed = player.remove(0);
1273
+ * console.log(`Removed: ${removed?.title}`);
1274
+ */
1275
+ remove(index: number): Track | null {
1276
+ this.debug(`[Player] remove called for index: ${index}`);
1277
+ const track = this.queue.remove(index);
1278
+ if (track) {
1279
+ this.emit("queueRemove", track, index);
1280
+ }
1281
+ return track;
1282
+ }
1283
+
1284
+ /**
1285
+ * Get the progress bar of the current track
1286
+ *
1287
+ * @param {ProgressBarOptions} options - The options for the progress bar
1288
+ * @returns {string} The progress bar
1289
+ * @example
1290
+ * const progressBar = player.getProgressBar();
1291
+ * console.log(`Progress bar: ${progressBar}`);
1292
+ */
1293
+ getProgressBar(options: ProgressBarOptions = {}): string {
1294
+ const { size = 20, barChar = "▬", progressChar = "🔘" } = options;
1295
+ const track = this.queue.currentTrack;
1296
+ const resource = this.currentResource;
1297
+ if (!track || !resource) return "";
1298
+
1299
+ const total = track.duration > 1000 ? track.duration : track.duration * 1000;
1300
+ if (!total) return this.formatTime(resource.playbackDuration);
1301
+
1302
+ const current = resource.playbackDuration;
1303
+ const ratio = Math.min(current / total, 1);
1304
+ const progress = Math.round(ratio * size);
1305
+ const bar = barChar.repeat(progress) + progressChar + barChar.repeat(size - progress);
1306
+
1307
+ return `${this.formatTime(current)} | ${bar} | ${this.formatTime(total)}`;
1308
+ }
1309
+
1310
+ /**
1311
+ * Get the time of the current track
1312
+ *
1313
+ * @returns {Object} The time of the current track
1314
+ * @example
1315
+ * const time = player.getTime();
1316
+ * console.log(`Time: ${time.current}`);
1317
+ */
1318
+ getTime() {
1319
+ const resource = this.currentResource;
1320
+ const track = this.queue.currentTrack;
1321
+ if (!track || !resource)
1322
+ return {
1323
+ current: 0,
1324
+ total: 0,
1325
+ format: "00:00",
1326
+ };
1327
+
1328
+ const total = track.duration > 1000 ? track.duration : track.duration * 1000;
1329
+
1330
+ return {
1331
+ current: resource?.playbackDuration,
1332
+ total: total,
1333
+ format: this.formatTime(resource.playbackDuration),
1334
+ };
1335
+ }
1336
+
1337
+ /**
1338
+ * Format the time in the format of HH:MM:SS
1339
+ *
1340
+ * @param {number} ms - The time in milliseconds
1341
+ * @returns {string} The formatted time
1342
+ * @example
1343
+ * const formattedTime = player.formatTime(1000);
1344
+ * console.log(`Formatted time: ${formattedTime}`);
1345
+ */
1346
+ formatTime(ms: number): string {
1347
+ const totalSeconds = Math.floor(ms / 1000);
1348
+ const hours = Math.floor(totalSeconds / 3600);
1349
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
1350
+ const seconds = totalSeconds % 60;
1351
+ const parts: string[] = [];
1352
+ if (hours > 0) parts.push(String(hours).padStart(2, "0"));
1353
+ parts.push(String(minutes).padStart(2, "0"));
1354
+ parts.push(String(seconds).padStart(2, "0"));
1355
+ return parts.join(":");
1356
+ }
1357
+
1358
+ /**
1359
+ * Destroy the player
1360
+ *
1361
+ * @returns {void}
1362
+ * @example
1363
+ * player.destroy();
1364
+ */
1365
+ destroy(): void {
1366
+ this.debug(`[Player] destroy called`);
1367
+ if (this.leaveTimeout) {
1368
+ clearTimeout(this.leaveTimeout);
1369
+ this.leaveTimeout = null;
1370
+ }
1371
+
1372
+ // Destroy current stream before stopping audio
1373
+ this.destroyCurrentStream();
1374
+
1375
+ this.audioPlayer.removeAllListeners();
1376
+ this.audioPlayer.stop(true);
1377
+
1378
+ if (this.ttsPlayer) {
1379
+ try {
1380
+ this.ttsPlayer.stop(true);
1381
+ } catch {}
1382
+ this.ttsPlayer = null;
1383
+ }
1384
+
1385
+ if (this.connection) {
1386
+ this.connection.destroy();
1387
+ this.connection = null;
1388
+ }
1389
+
1390
+ this.queue.clear();
1391
+ this.pluginManager.clear();
1392
+ this.filter.destroy();
1393
+ this.extensionManager.destroy();
1394
+ this.isPlaying = false;
1395
+ this.isPaused = false;
1396
+
1397
+ // Clear any remaining intervals
1398
+ if (this.volumeInterval) {
1399
+ clearInterval(this.volumeInterval);
1400
+ this.volumeInterval = null;
1401
+ }
1402
+
1403
+ this.emit("playerDestroy");
1404
+ this.removeAllListeners();
1405
+ }
1406
+
1407
+ //#endregion
1408
+ //#region utils
1409
+ private scheduleLeave(): void {
1410
+ this.debug(`[Player] scheduleLeave called`);
1411
+ if (this.leaveTimeout) {
1412
+ clearTimeout(this.leaveTimeout);
1413
+ }
1414
+
1415
+ if (this.options.leaveOnEmpty && this.options.leaveTimeout) {
1416
+ this.leaveTimeout = setTimeout(() => {
1417
+ this.debug(`[Player] Leaving voice channel after timeoutMs`);
1418
+ this.destroy();
1419
+ }, this.options.leaveTimeout);
1420
+ }
1421
+ }
1422
+
1423
+ /**
1424
+ * Refesh player resource (apply filter)
1425
+ *
1426
+ * @param {boolean} applyToCurrent - Apply filter for curent track
1427
+ * @param {number} position - Position to seek to in milliseconds
1428
+ * @returns {Promise<boolean>}
1429
+ * @example
1430
+ * const refreshed = await player.refeshPlayerResource(true, 1000);
1431
+ * console.log(`Refreshed: ${refreshed}`);
1432
+ */
1433
+ public async refeshPlayerResource(applyToCurrent: boolean = true, position: number = -1): Promise<boolean> {
1434
+ if (!applyToCurrent || !this.queue.currentTrack || !(this.isPlaying || this.isPaused)) {
1435
+ return false;
1436
+ }
1437
+
1438
+ try {
1439
+ const track = this.queue.currentTrack;
1440
+ this.debug(`[Player] Refreshing player resource for track: ${track.title}`);
1441
+
1442
+ // Get current position for seeking
1443
+ const currentPosition = position > 0 ? position : this.currentResource?.playbackDuration || 0;
1444
+
1445
+ const streaminfo = await this.getStream(track);
1446
+ if (!streaminfo?.stream) {
1447
+ this.debug(`[Player] No stream to refresh`);
1448
+ return false;
1449
+ }
1450
+
1451
+ // Create AudioResource with filters and seek to current position
1452
+ const resource = await this.createResource(streaminfo, track, currentPosition);
1453
+
1454
+ // Stop current playback and destroy old resource/stream
1455
+ const wasPlaying = this.isPlaying;
1456
+ const wasPaused = this.isPaused;
1457
+
1458
+ this.audioPlayer.stop();
1459
+
1460
+ // Properly destroy the old resource and stream
1461
+ try {
1462
+ if (this.currentResource) {
1463
+ const oldStream = (this.currentResource as any)._readableState?.stream || (this.currentResource as any).stream;
1464
+ if (oldStream && typeof oldStream.destroy === "function") {
1465
+ oldStream.destroy();
1466
+ }
1467
+ }
1468
+ } catch (error) {
1469
+ this.debug(`[Player] Error destroying old stream in refeshPlayerResource:`, error);
1470
+ }
1471
+
1472
+ this.currentResource = resource;
1473
+
1474
+ // Subscribe to new resource
1475
+ if (this.connection) {
1476
+ this.connection.subscribe(this.audioPlayer);
1477
+ this.audioPlayer.play(resource);
1478
+ }
1479
+
1480
+ // Restore playing state
1481
+ if (wasPlaying && !wasPaused) {
1482
+ this.isPlaying = true;
1483
+ this.isPaused = false;
1484
+ } else if (wasPaused) {
1485
+ this.isPlaying = false;
1486
+ this.isPaused = true;
1487
+ this.audioPlayer.pause();
1488
+ }
1489
+
1490
+ this.debug(`[Player] Successfully applied filter to current track at position ${currentPosition}ms`);
1491
+ return true;
1492
+ } catch (error) {
1493
+ this.debug(`[Player] Error applying filter to current track:`, error);
1494
+ // Filter was still added to active filters, so return true
1495
+ return true;
1496
+ }
1497
+ }
1498
+
1499
+ /**
1500
+ * Attach an extension to the player
1501
+ *
1502
+ * @param {BaseExtension} extension - The extension to attach
1503
+ * @example
1504
+ * player.attachExtension(new MyExtension());
1505
+ */
1506
+ public attachExtension(extension: BaseExtension): void {
1507
+ this.extensionManager.register(extension);
1508
+ }
1509
+
1510
+ /**
1511
+ * Detach an extension from the player
1512
+ *
1513
+ * @param {BaseExtension} extension - The extension to detach
1514
+ * @example
1515
+ * player.detachExtension(new MyExtension());
1516
+ */
1517
+ public detachExtension(extension: BaseExtension): void {
1518
+ this.extensionManager.unregister(extension);
1519
+ }
1520
+
1521
+ /**
1522
+ * Get all extensions attached to the player
1523
+ *
1524
+ * @returns {readonly BaseExtension[]} All attached extensions
1525
+ * @example
1526
+ * const extensions = player.getExtensions();
1527
+ * console.log(`Extensions: ${extensions.length}`);
1528
+ */
1529
+ public getExtensions(): readonly BaseExtension[] {
1530
+ return this.extensionManager.getAll();
1531
+ }
1532
+
1533
+ private clearLeaveTimeout(): void {
1534
+ if (this.leaveTimeout) {
1535
+ clearTimeout(this.leaveTimeout);
1536
+ this.leaveTimeout = null;
1537
+ this.debug(`[Player] Cleared leave timeoutMs`);
1538
+ }
1539
+ }
1540
+
1541
+ private debug(message?: any, ...optionalParams: any[]): void {
1542
+ if (this.listenerCount("debug") > 0) {
1543
+ this.emit("debug", message, ...optionalParams);
1544
+ }
1545
+ }
1546
+
1547
+ private setupEventListeners(): void {
1548
+ this.audioPlayer.on("stateChange", (oldState, newState) => {
1549
+ this.debug(`[Player] AudioPlayer stateChange from ${oldState.status} to ${newState.status}`);
1550
+ if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) {
1551
+ // Track ended
1552
+ const track = this.queue.currentTrack;
1553
+ if (track) {
1554
+ this.debug(`[Player] Track ended: ${track.title}`);
1555
+ this.emit("trackEnd", track);
1556
+ }
1557
+ this.playNext();
1558
+ } else if (
1559
+ newState.status === AudioPlayerStatus.Playing &&
1560
+ (oldState.status === AudioPlayerStatus.Idle || oldState.status === AudioPlayerStatus.Buffering)
1561
+ ) {
1562
+ // Track started
1563
+ this.clearLeaveTimeout();
1564
+ this.isPlaying = true;
1565
+ this.isPaused = false;
1566
+ const track = this.queue.currentTrack;
1567
+ if (track) {
1568
+ this.debug(`[Player] Track started: ${track.title}`);
1569
+ this.emit("trackStart", track);
1570
+ }
1571
+ } else if (newState.status === AudioPlayerStatus.Paused && oldState.status !== AudioPlayerStatus.Paused) {
1572
+ // Track paused
1573
+ this.isPaused = true;
1574
+ const track = this.queue.currentTrack;
1575
+ if (track) {
1576
+ this.debug(`[Player] Player paused on track: ${track.title}`);
1577
+ this.emit("playerPause", track);
1578
+ }
1579
+ } else if (newState.status !== AudioPlayerStatus.Paused && oldState.status === AudioPlayerStatus.Paused) {
1580
+ // Track resumed
1581
+ this.isPaused = false;
1582
+ const track = this.queue.currentTrack;
1583
+ if (track) {
1584
+ this.debug(`[Player] Player resumed on track: ${track.title}`);
1585
+ this.emit("playerResume", track);
1586
+ }
1587
+ } else if (newState.status === AudioPlayerStatus.AutoPaused) {
1588
+ this.debug(`[Player] AudioPlayerStatus.AutoPaused`);
1589
+ } else if (newState.status === AudioPlayerStatus.Buffering) {
1590
+ this.debug(`[Player] AudioPlayerStatus.Buffering`);
1591
+ }
1592
+ });
1593
+ this.audioPlayer.on("error", (error) => {
1594
+ this.debug(`[Player] AudioPlayer error:`, error);
1595
+ this.emit("playerError", error, this.queue.currentTrack || undefined);
1596
+ this.playNext();
1597
+ });
1598
+
1599
+ this.audioPlayer.on("debug", (...args) => {
1600
+ if (this.manager.debugEnabled) {
1601
+ this.emit("debug", ...args);
1602
+ }
1603
+ });
1604
+ }
1605
+
1606
+ addPlugin(plugin: SourcePlugin): void {
1607
+ this.debug(`[Player] Adding plugin: ${plugin.name}`);
1608
+ this.pluginManager.register(plugin);
1609
+ }
1610
+
1611
+ removePlugin(name: string): boolean {
1612
+ this.debug(`[Player] Removing plugin: ${name}`);
1613
+ return this.pluginManager.unregister(name);
1614
+ }
1615
+
1616
+ //#endregion
1617
+ //#region Getters
1618
+
1619
+ /**
1620
+ * Get the size of the queue
1621
+ *
1622
+ * @returns {number} The size of the queue
1623
+ * @example
1624
+ * const queueSize = player.queueSize;
1625
+ * console.log(`Queue size: ${queueSize}`);
1626
+ */
1627
+ get queueSize(): number {
1628
+ return this.queue.size;
1629
+ }
1630
+
1631
+ /**
1632
+ * Get the current track
1633
+ *
1634
+ * @returns {Track | null} The current track or null
1635
+ * @example
1636
+ * const currentTrack = player.currentTrack;
1637
+ * console.log(`Current track: ${currentTrack?.title}`);
1638
+ */
1639
+ get currentTrack(): Track | null {
1640
+ return this.queue.currentTrack;
1641
+ }
1642
+
1643
+ /**
1644
+ * Get the previous track
1645
+ *
1646
+ * @returns {Track | null} The previous track or null
1647
+ * @example
1648
+ * const previousTrack = player.previousTrack;
1649
+ * console.log(`Previous track: ${previousTrack?.title}`);
1650
+ */
1651
+ get previousTrack(): Track | null {
1652
+ return this.queue.previousTracks?.at(-1) ?? null;
1653
+ }
1654
+
1655
+ /**
1656
+ * Get the upcoming tracks
1657
+ *
1658
+ * @returns {Track[]} The upcoming tracks
1659
+ * @example
1660
+ * const upcomingTracks = player.upcomingTracks;
1661
+ * console.log(`Upcoming tracks: ${upcomingTracks.length}`);
1662
+ */
1663
+ get upcomingTracks(): Track[] {
1664
+ return this.queue.getTracks();
1665
+ }
1666
+
1667
+ /**
1668
+ * Get the previous tracks
1669
+ *
1670
+ * @returns {Track[]} The previous tracks
1671
+ * @example
1672
+ * const previousTracks = player.previousTracks;
1673
+ * console.log(`Previous tracks: ${previousTracks.length}`);
1674
+ */
1675
+ get previousTracks(): Track[] {
1676
+ return this.queue.previousTracks;
1677
+ }
1678
+
1679
+ /**
1680
+ * Get the available plugins
1681
+ *
1682
+ * @returns {string[]} The available plugins
1683
+ * @example
1684
+ * const availablePlugins = player.availablePlugins;
1685
+ * console.log(`Available plugins: ${availablePlugins.length}`);
1686
+ */
1687
+ get availablePlugins(): string[] {
1688
+ return this.pluginManager.getAll().map((p) => p.name);
1689
+ }
1690
+
1691
+ /**
1692
+ * Get the related tracks
1693
+ *
1694
+ * @returns {Track[] | null} The related tracks or null
1695
+ * @example
1696
+ * const relatedTracks = player.relatedTracks;
1697
+ * console.log(`Related tracks: ${relatedTracks?.length}`);
1698
+ */
1699
+ get relatedTracks(): Track[] | null {
1700
+ return this.queue.relatedTracks();
1701
+ }
1702
+
1703
+ //#endregion
1704
+ }