ziplayer 0.2.5 → 0.2.7

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,1743 +1,1693 @@
1
- import { EventEmitter } from "events";
2
- import {
3
- createAudioPlayer,
4
- createAudioResource,
5
- entersState,
6
- AudioPlayerStatus,
7
- VoiceConnection,
8
- AudioPlayer as DiscordAudioPlayer,
9
- VoiceConnectionStatus,
10
- NoSubscriberBehavior,
11
- joinVoiceChannel,
12
- AudioResource,
13
- StreamType,
14
- } from "@discordjs/voice";
15
-
16
- import { Readable } from "stream";
17
- import 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
- try {
169
- // Get the metadata from current resource to find the stream
170
- if (this.currentResource) {
171
- const stream = (this.currentResource as any)?.metadata?.stream || (this.currentResource as any)?.stream;
172
- if (stream && typeof stream.destroy === "function") {
173
- stream.destroy();
174
- this.debug(`[Player] Destroyed current stream`);
175
- }
176
- }
177
- } catch (error) {
178
- this.debug(`[Player] Error destroying current stream:`, error);
179
- }
180
- }
181
-
182
- //#region Search
183
-
184
- /**
185
- * Search for tracks using the player's extensions and plugins
186
- *
187
- * @param {string} query - The query to search for
188
- * @param {string} requestedBy - The user ID who requested the search
189
- * @returns {Promise<SearchResult>} The search result
190
- * @example
191
- * const result = await player.search("Never Gonna Give You Up", userId);
192
- * console.log(`Search result: ${result.tracks.length} tracks`);
193
- */
194
- async search(query: string, requestedBy: string): Promise<SearchResult> {
195
- this.debug(`[Player] Search called with query: ${query}, requestedBy: ${requestedBy}`);
196
-
197
- // Clear expired search cache periodically
198
- if (Math.random() < 0.1) {
199
- // 10% chance to clean cache
200
- this.clearExpiredSearchCache();
201
- }
202
-
203
- // Check cache first
204
- const cachedResult = this.getCachedSearchResult(query);
205
- if (cachedResult) {
206
- return cachedResult;
207
- }
208
-
209
- // Try extensions first
210
- const extensionResult = await this.extensionManager.provideSearch(query, requestedBy);
211
- if (extensionResult && Array.isArray(extensionResult.tracks) && extensionResult.tracks.length > 0) {
212
- this.debug(`[Player] Extension handled search for query: ${query}`);
213
- this.cacheSearchResult(query, extensionResult);
214
- return extensionResult;
215
- }
216
-
217
- // Get plugins and filter out TTS for regular searches
218
- const allPlugins = this.pluginManager.getAll();
219
- const plugins = allPlugins.filter((p) => {
220
- // Skip TTS plugin for regular searches (unless query starts with "tts:")
221
- if (p.name.toLowerCase() === "tts" && !query.toLowerCase().startsWith("tts:")) {
222
- this.debug(`[Player] Skipping TTS plugin for regular search: ${query}`);
223
- return false;
224
- }
225
- return true;
226
- });
227
-
228
- this.debug(`[Player] Using ${plugins.length} plugins for search (filtered from ${allPlugins.length})`);
229
-
230
- let lastError: any = null;
231
- let searchAttempts = 0;
232
-
233
- for (const p of plugins) {
234
- searchAttempts++;
235
- try {
236
- this.debug(`[Player] Trying plugin for search: ${p.name} (attempt ${searchAttempts}/${plugins.length})`);
237
- const startTime = Date.now();
238
- const res = await withTimeout(
239
- p.search(query, requestedBy),
240
- this.options.extractorTimeout ?? 15000,
241
- `Search operation timed out for ${p.name}`,
242
- );
243
- const duration = Date.now() - startTime;
244
-
245
- if (res && Array.isArray(res.tracks) && res.tracks.length > 0) {
246
- this.debug(`[Player] Plugin '${p.name}' returned ${res.tracks.length} tracks in ${duration}ms`);
247
- this.cacheSearchResult(query, res);
248
- return res;
249
- }
250
- this.debug(`[Player] Plugin '${p.name}' returned no tracks in ${duration}ms`);
251
- } catch (error) {
252
- const errorMessage = error instanceof Error ? error.message : String(error);
253
- this.debug(`[Player] Search via plugin '${p.name}' failed: ${errorMessage}`);
254
- lastError = error;
255
- // Continue to next plugin
256
- }
257
- }
258
-
259
- this.debug(`[Player] No plugins returned results for query: ${query} (tried ${searchAttempts} plugins)`);
260
- if (lastError) this.emit("playerError", lastError as Error);
261
- throw new Error(`No plugin found to handle: ${query}`);
262
- }
263
-
264
- /**
265
- * Get cached search result or null if not found/expired
266
- * @param query The search query
267
- * @returns Cached search result or null
268
- */
269
- private getCachedSearchResult(query: string): SearchResult | null {
270
- const cacheKey = query.toLowerCase().trim();
271
- const now = Date.now();
272
-
273
- const cachedTimestamp = this.searchCacheTimestamps.get(cacheKey);
274
- if (cachedTimestamp && now - cachedTimestamp < this.SEARCH_CACHE_TTL) {
275
- const cachedResult = this.searchCache.get(cacheKey);
276
- if (cachedResult) {
277
- this.debug(`[SearchCache] Using cached search result for: ${query}`);
278
- return cachedResult;
279
- }
280
- }
281
-
282
- return null;
283
- }
284
-
285
- /**
286
- * Cache search result
287
- * @param query The search query
288
- * @param result The search result to cache
289
- */
290
- private cacheSearchResult(query: string, result: SearchResult): void {
291
- const cacheKey = query.toLowerCase().trim();
292
- const now = Date.now();
293
-
294
- this.searchCache.set(cacheKey, result);
295
- this.searchCacheTimestamps.set(cacheKey, now);
296
- this.debug(`[SearchCache] Cached search result for: ${query} (${result.tracks.length} tracks)`);
297
- }
298
-
299
- /**
300
- * Clear expired search cache entries
301
- */
302
- private clearExpiredSearchCache(): void {
303
- const now = Date.now();
304
- for (const [key, timestamp] of this.searchCacheTimestamps.entries()) {
305
- if (now - timestamp >= this.SEARCH_CACHE_TTL) {
306
- this.searchCache.delete(key);
307
- this.searchCacheTimestamps.delete(key);
308
- this.debug(`[SearchCache] Cleared expired cache entry: ${key}`);
309
- }
310
- }
311
- }
312
-
313
- /**
314
- * Clear all search cache entries
315
- * @example
316
- * player.clearSearchCache();
317
- */
318
- public clearSearchCache(): void {
319
- const cacheSize = this.searchCache.size;
320
- this.searchCache.clear();
321
- this.searchCacheTimestamps.clear();
322
- this.debug(`[SearchCache] Cleared all ${cacheSize} search cache entries`);
323
- }
324
-
325
- /**
326
- * Debug method to check for duplicate search calls
327
- * @param query The search query to check
328
- * @returns Debug information about the query
329
- */
330
- public debugSearchQuery(query: string): {
331
- isCached: boolean;
332
- cacheAge?: number;
333
- pluginCount: number;
334
- ttsFiltered: boolean;
335
- } {
336
- const cacheKey = query.toLowerCase().trim();
337
- const now = Date.now();
338
- const cachedTimestamp = this.searchCacheTimestamps.get(cacheKey);
339
- const isCached = cachedTimestamp && now - cachedTimestamp < this.SEARCH_CACHE_TTL;
340
-
341
- const allPlugins = this.pluginManager.getAll();
342
- const plugins = allPlugins.filter((p) => {
343
- if (p.name.toLowerCase() === "tts" && !query.toLowerCase().startsWith("tts:")) {
344
- return false;
345
- }
346
- return true;
347
- });
348
-
349
- return {
350
- isCached: !!isCached,
351
- cacheAge: cachedTimestamp ? now - cachedTimestamp : undefined,
352
- pluginCount: plugins.length,
353
- ttsFiltered: allPlugins.length > plugins.length,
354
- };
355
- }
356
-
357
- /**
358
- * Get related tracks for a given track
359
- * @param {Track} track Track to find related tracks for
360
- * @returns {Track[]} Related tracks or empty array
361
- * @example
362
- * const related = await player.getRelatedTracks(track);
363
- * console.log(`Found ${related.length} related tracks`);
364
- */
365
- async getRelatedTracks(track: Track): Promise<Track[]> {
366
- if (!track) return [];
367
-
368
- const preferred = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
369
-
370
- const all = this.pluginManager.getAll();
371
-
372
- const candidates = [...(preferred ? [preferred] : []), ...all.filter((p) => p !== preferred)].filter(
373
- (p) => typeof (p as any).getRelatedTracks === "function",
374
- );
375
-
376
- for (const p of candidates) {
377
- try {
378
- this.debug(`[Player] Trying related from plugin: ${p.name}`);
379
- const related = await withTimeout(
380
- (p as any).getRelatedTracks(track.url, {
381
- limit: 10,
382
- history: this.queue.previousTracks,
383
- }),
384
- this.options.extractorTimeout ?? 15000,
385
- `getRelatedTracks timed out for ${p.name}`,
386
- );
387
-
388
- if (Array.isArray(related) && related.length > 0) {
389
- return related; // success
390
- }
391
- this.debug(`[Player] ${p.name} returned no related tracks`);
392
- } catch (err) {
393
- this.debug(`[Player] getRelatedTracks error from ${p.name}:`, err);
394
- return [];
395
- // try next candidate
396
- }
397
- }
398
- return [];
399
- }
400
-
401
- private async generateWillNext(): Promise<void> {
402
- const lastTrack = this.queue.previousTracks[this.queue.previousTracks.length - 1] ?? this.queue.currentTrack;
403
- if (!lastTrack) return;
404
- const related = await this.getRelatedTracks(lastTrack);
405
- if (!related || related.length === 0) return;
406
- const randomchoice = Math.floor(Math.random() * related.length);
407
- const nextTrack = this.queue.nextTrack ? this.queue.nextTrack : related[randomchoice];
408
- this.queue.willNextTrack(nextTrack);
409
- this.queue.relatedTracks(related);
410
- this.debug(`[Player] Will next track if autoplay: ${nextTrack?.title}]`);
411
- this.emit("willPlay", nextTrack, related);
412
- }
413
- //#endregion
414
- //#region Play
415
-
416
- /**
417
- * Play a track, search query, search result, or play from queue
418
- *
419
- * @param {string | Track | SearchResult | null} query - Track URL, search query, Track object, SearchResult, or null for play
420
- * @param {string} requestedBy - User ID who requested the track
421
- * @returns {Promise<boolean>} True if playback started successfully
422
- * @example
423
- * await player.play("Never Gonna Give You Up", userId); // Search query
424
- * await player.play("https://youtube.com/watch?v=dQw4w9WgXcQ", userId); // Direct URL
425
- * await player.play("tts: Hello everyone!", userId); // Text-to-Speech
426
- * await player.play(trackObject, userId); // Track object
427
- * await player.play(searchResult, userId); // SearchResult object
428
- * await player.play(null); // play from queue
429
- */
430
- async play(query: string | Track | SearchResult | null, requestedBy?: string): Promise<boolean> {
431
- const debugInfo =
432
- query === null ? "null"
433
- : typeof query === "string" ? query
434
- : "tracks" in query ? `${query.tracks.length} tracks`
435
- : query.title || "unknown";
436
- this.debug(`[Player] Play called with query: ${debugInfo}`);
437
- this.clearLeaveTimeout();
438
- let tracksToAdd: Track[] = [];
439
- let isPlaylist = false;
440
- let effectiveRequest: ExtensionPlayRequest = { query: query as string | Track, requestedBy };
441
- let hookResponse: ExtensionPlayResponse = {};
442
-
443
- try {
444
- // Handle null query - play from queue
445
- if (query === null) {
446
- this.debug(`[Player] Play from queue requested`);
447
- if (this.queue.isEmpty) {
448
- this.debug(`[Player] Queue is empty, nothing to play`);
449
- return false;
450
- }
451
-
452
- if (!this.isPlaying) {
453
- return await this.playNext();
454
- }
455
- return true;
456
- }
457
-
458
- // Handle SearchResult
459
- if (query && typeof query === "object" && "tracks" in query && Array.isArray(query.tracks)) {
460
- this.debug(`[Player] Playing SearchResult with ${query.tracks.length} tracks`);
461
- tracksToAdd = query.tracks;
462
- isPlaylist = !!query.playlist || query.tracks.length > 1;
463
-
464
- if (query.playlist) {
465
- this.debug(`[Player] Added playlist: ${query.playlist.name} (${tracksToAdd.length} tracks)`);
466
- }
467
- } else {
468
- // Handle other types (string, Track)
469
- const hookOutcome = await this.extensionManager.BeforePlayHooks(effectiveRequest);
470
- effectiveRequest = hookOutcome.request;
471
- hookResponse = hookOutcome.response;
472
- if (effectiveRequest.requestedBy === undefined) {
473
- effectiveRequest.requestedBy = requestedBy;
474
- }
475
-
476
- const hookTracks = Array.isArray(hookResponse.tracks) ? hookResponse.tracks : undefined;
477
-
478
- if (hookResponse.handled && (!hookTracks || hookTracks.length === 0)) {
479
- const handledPayload: ExtensionAfterPlayPayload = {
480
- success: hookResponse.success ?? true,
481
- query: effectiveRequest.query,
482
- requestedBy: effectiveRequest.requestedBy,
483
- tracks: [],
484
- isPlaylist: hookResponse.isPlaylist ?? false,
485
- error: hookResponse.error,
486
- };
487
- await this.extensionManager.AfterPlayHooks(handledPayload);
488
- if (hookResponse.error) {
489
- this.emit("playerError", hookResponse.error);
490
- }
491
- return hookResponse.success ?? true;
492
- }
493
-
494
- if (hookTracks && hookTracks.length > 0) {
495
- tracksToAdd = hookTracks;
496
- isPlaylist = hookResponse.isPlaylist ?? hookTracks.length > 1;
497
- } else if (typeof effectiveRequest.query === "string") {
498
- const searchResult = await this.search(effectiveRequest.query, effectiveRequest.requestedBy || "Unknown");
499
- tracksToAdd = searchResult.tracks;
500
- if (searchResult.playlist) {
501
- isPlaylist = true;
502
- this.debug(`[Player] Added playlist: ${searchResult.playlist.name} (${tracksToAdd.length} tracks)`);
503
- }
504
- } else if (effectiveRequest.query) {
505
- tracksToAdd = [effectiveRequest.query as Track];
506
- }
507
- }
508
-
509
- if (tracksToAdd.length === 0) {
510
- this.debug(`[Player] No tracks found for play`);
511
- throw new Error("No tracks found");
512
- }
513
-
514
- const isTTS = (t: Track | undefined) => {
515
- if (!t) return false;
516
- try {
517
- return typeof t.source === "string" && t.source.toLowerCase().includes("tts");
518
- } catch {
519
- return false;
520
- }
521
- };
522
-
523
- const queryLooksTTS =
524
- typeof effectiveRequest.query === "string" && effectiveRequest.query.trim().toLowerCase().startsWith("tts");
525
-
526
- if (
527
- !isPlaylist &&
528
- tracksToAdd.length > 0 &&
529
- this.options?.tts?.interrupt !== false &&
530
- (isTTS(tracksToAdd[0]) || queryLooksTTS)
531
- ) {
532
- this.debug(`[Player] Interrupting with TTS: ${tracksToAdd[0].title}`);
533
- await this.interruptWithTTSTrack(tracksToAdd[0]);
534
- await this.extensionManager.AfterPlayHooks({
535
- success: true,
536
- query: effectiveRequest.query,
537
- requestedBy: effectiveRequest.requestedBy,
538
- tracks: tracksToAdd,
539
- isPlaylist,
540
- });
541
- return true;
542
- }
543
-
544
- if (isPlaylist) {
545
- this.queue.addMultiple(tracksToAdd);
546
- this.emit("queueAddList", tracksToAdd);
547
- } else {
548
- this.queue.add(tracksToAdd[0]);
549
- this.emit("queueAdd", tracksToAdd[0]);
550
- }
551
-
552
- const started = !this.isPlaying ? await this.playNext() : true;
553
-
554
- await this.extensionManager.AfterPlayHooks({
555
- success: started,
556
- query: effectiveRequest.query,
557
- requestedBy: effectiveRequest.requestedBy,
558
- tracks: tracksToAdd,
559
- isPlaylist,
560
- });
561
-
562
- return started;
563
- } catch (error) {
564
- await this.extensionManager.AfterPlayHooks({
565
- success: false,
566
- query: effectiveRequest.query,
567
- requestedBy: effectiveRequest.requestedBy,
568
- tracks: tracksToAdd,
569
- isPlaylist,
570
- error: error as Error,
571
- });
572
- this.debug(`[Player] Play error:`, error);
573
- this.emit("playerError", error as Error);
574
- return false;
575
- }
576
- }
577
-
578
- /**
579
- * Create AudioResource with filters and seek applied
580
- *
581
- * @param {StreamInfo} streamInfo - The stream information
582
- * @param {Track} track - The track being processed
583
- * @param {number} position - Position in milliseconds to seek to (0 = no seek)
584
- * @returns {Promise<AudioResource>} The AudioResource with filters and seek applied
585
- */
586
- private async createResource(streamInfo: StreamInfo, track: Track, position: number = 0): Promise<AudioResource> {
587
- const filterString = this.filter.getFilterString();
588
-
589
- this.debug(`[Player] Creating AudioResource with filters: ${filterString || "none"}, seek: ${position}ms`);
590
-
591
- try {
592
- let stream: Readable = streamInfo.stream;
593
- // Apply filters and seek if needed
594
- if (filterString || position > 0) {
595
- stream = await this.filter.applyFiltersAndSeek(streamInfo.stream, position);
596
- streamInfo.type = StreamType.Arbitrary;
597
- }
598
-
599
- // Create AudioResource with better error handling
600
- const resource = createAudioResource(stream, {
601
- metadata: track,
602
- inputType:
603
- streamInfo.type === "webm/opus" ? StreamType.WebmOpus
604
- : streamInfo.type === "ogg/opus" ? StreamType.OggOpus
605
- : StreamType.Arbitrary,
606
- inlineVolume: true,
607
- });
608
-
609
- return resource;
610
- } catch (error) {
611
- this.debug(`[Player] Error creating AudioResource with filters+seek:`, error);
612
- // Fallback to basic AudioResource
613
- try {
614
- const resource = createAudioResource(streamInfo.stream, {
615
- metadata: track,
616
- inputType:
617
- streamInfo.type === "webm/opus" ? StreamType.WebmOpus
618
- : streamInfo.type === "ogg/opus" ? StreamType.OggOpus
619
- : StreamType.Arbitrary,
620
- inlineVolume: true,
621
- });
622
- return resource;
623
- } catch (fallbackError) {
624
- this.debug(`[Player] Fallback AudioResource creation failed:`, fallbackError);
625
- throw fallbackError;
626
- }
627
- }
628
- }
629
- private async getStream(track: Track): Promise<StreamInfo | null> {
630
- let stream = await this.extensionManager.provideStream(track);
631
- if (stream?.stream) return stream;
632
- stream = await this.pluginManager.getStream(track);
633
- if (stream?.stream) return stream;
634
- throw new Error(`No stream available for track: ${track.title}`);
635
- }
636
-
637
- /**
638
- * Start playing a specific track immediately, replacing the current resource.
639
- */
640
- private async startTrack(track: Track): Promise<boolean> {
641
- try {
642
- let streamInfo: StreamInfo | null = await this.getStream(track);
643
- this.debug(`[Player] Using stream for track: ${track.title}`);
644
- // Kiểm tra nếu có stream thực sự để tạo AudioResource
645
- if (streamInfo && (streamInfo as any).stream) {
646
- try {
647
- // Destroy the old stream and resource before creating a new one
648
- this.destroyCurrentStream();
649
- if (this.currentResource) {
650
- try {
651
- const oldStream = (this.currentResource as any)._readableState?.stream || (this.currentResource as any).stream;
652
- if (oldStream && typeof oldStream.destroy === "function") {
653
- oldStream.destroy();
654
- }
655
- } catch {}
656
- this.currentResource = null;
657
- }
658
-
659
- this.currentResource = await this.createResource(streamInfo, track, 0);
660
- if (this.volumeInterval) {
661
- clearInterval(this.volumeInterval);
662
- this.volumeInterval = null;
663
- }
664
- this.currentResource.volume?.setVolume(this.volume / 100);
665
-
666
- this.debug(`[Player] Playing resource for track: ${track.title}`);
667
- this.audioPlayer.play(this.currentResource);
668
-
669
- await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 5_000);
670
- return true;
671
- } catch (resourceError) {
672
- this.debug(`[Player] Error creating/playing resource:`, resourceError);
673
- // Try fallback without filters
674
- try {
675
- this.debug(`[Player] Attempting fallback without filters`);
676
- const fallbackResource = createAudioResource(streamInfo.stream, {
677
- metadata: track,
678
- inputType:
679
- streamInfo.type === "webm/opus" ? StreamType.WebmOpus
680
- : streamInfo.type === "ogg/opus" ? StreamType.OggOpus
681
- : StreamType.Arbitrary,
682
- inlineVolume: true,
683
- });
684
-
685
- this.currentResource = fallbackResource;
686
- this.currentResource.volume?.setVolume(this.volume / 100);
687
- this.audioPlayer.play(this.currentResource);
688
- await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 5_000);
689
- return true;
690
- } catch (fallbackError) {
691
- this.debug(`[Player] Fallback also failed:`, fallbackError);
692
- throw fallbackError;
693
- }
694
- }
695
- } else if (streamInfo && !(streamInfo as any).stream) {
696
- // Extension đang xử lý phát nhạc (như Lavalink) - chỉ đánh dấu đang phát
697
- this.debug(`[Player] Extension is handling playback for track: ${track.title}`);
698
- this.isPlaying = true;
699
- this.isPaused = false;
700
- this.emit("trackStart", track);
701
- return true;
702
- } else {
703
- throw new Error(`No stream available for track: ${track.title}`);
704
- }
705
- } catch (error) {
706
- this.debug(`[Player] startTrack error:`, error);
707
- this.emit("playerError", error as Error, track);
708
- return false;
709
- }
710
- }
711
-
712
- private async playNext(): Promise<boolean> {
713
- this.debug(`[Player] playNext called`);
714
- const track = this.queue.next(this.skipLoop);
715
- this.skipLoop = false;
716
- if (!track) {
717
- if (this.queue.autoPlay()) {
718
- const willnext = this.queue.willNextTrack();
719
- if (willnext) {
720
- this.debug(`[Player] Auto-playing next track: ${willnext.title}`);
721
- this.queue.addMultiple([willnext]);
722
- return this.playNext();
723
- }
724
- }
725
-
726
- this.debug(`[Player] No next track in queue`);
727
- this.isPlaying = false;
728
- this.emit("queueEnd");
729
-
730
- if (this.options.leaveOnEnd) {
731
- this.scheduleLeave();
732
- }
733
- return false;
734
- }
735
-
736
- this.generateWillNext();
737
- // A new track is about to play; ensure we don't leave mid-playback
738
- this.clearLeaveTimeout();
739
-
740
- try {
741
- return await this.startTrack(track);
742
- } catch (error) {
743
- this.debug(`[Player] playNext error:`, error);
744
- this.emit("playerError", error as Error, track);
745
- return this.playNext();
746
- }
747
- }
748
-
749
- //#endregion
750
- //#region TTS
751
-
752
- private ensureTTSPlayer(): DiscordAudioPlayer {
753
- if (this.ttsPlayer) return this.ttsPlayer;
754
- this.ttsPlayer = createAudioPlayer({
755
- behaviors: {
756
- noSubscriber: NoSubscriberBehavior.Pause,
757
- maxMissedFrames: 100,
758
- },
759
- });
760
- this.ttsPlayer.on("error", (e) => this.debug("[TTS] error:", e));
761
- return this.ttsPlayer;
762
- }
763
- /**
764
- * Interrupt current music with a TTS track. Pauses music, swaps the
765
- * subscription to a dedicated TTS player, plays TTS, then resumes.
766
- *
767
- * @param {Track} track - The track to interrupt with
768
- * @returns {Promise<void>}
769
- * @example
770
- * await player.interruptWithTTSTrack(track);
771
- */
772
- public async interruptWithTTSTrack(track: Track): Promise<void> {
773
- const wasPlaying =
774
- this.audioPlayer.state.status === AudioPlayerStatus.Playing ||
775
- this.audioPlayer.state.status === AudioPlayerStatus.Buffering;
776
-
777
- let ttsResource: AudioResource | null = null;
778
- let ttsStream: any = null;
779
-
780
- try {
781
- if (!this.connection) throw new Error("No voice connection for TTS");
782
- const ttsPlayer = this.ensureTTSPlayer();
783
-
784
- // Build resource from plugin stream
785
- const streamInfo = await this.pluginManager.getStream(track);
786
- if (!streamInfo) {
787
- throw new Error("No stream available for track: ${track.title}");
788
- }
789
- ttsStream = streamInfo.stream;
790
- const resource = await this.createResource(streamInfo as StreamInfo, track);
791
- if (!resource) {
792
- throw new Error("No resource available for track: ${track.title}");
793
- }
794
- ttsResource = resource;
795
- if (resource.volume) {
796
- resource.volume.setVolume((this.options?.tts?.volume ?? this?.volume ?? 100) / 100);
797
- }
798
-
799
- // Pause current music if any
800
- try {
801
- this.pause();
802
- } catch {}
803
-
804
- // Swap subscription and play TTS
805
- this.connection.subscribe(ttsPlayer);
806
- this.emit("ttsStart", { track });
807
- ttsPlayer.play(resource);
808
-
809
- // Wait until TTS starts then finishes
810
- await entersState(ttsPlayer, AudioPlayerStatus.Playing, 5_000).catch(() => null);
811
- // Derive timeoutMs from resource/track duration when available, with a sensible cap
812
- const md: any = (resource as any)?.metadata ?? {};
813
- const declared =
814
- typeof md.duration === "number" ? md.duration
815
- : typeof track?.duration === "number" ? track.duration
816
- : undefined;
817
- const declaredMs =
818
- declared ?
819
- declared > 1000 ?
820
- declared
821
- : declared * 1000
822
- : undefined;
823
- const cap = this.options?.tts?.Max_Time_TTS ?? 60_000;
824
- const idleTimeout = declaredMs ? Math.min(cap, Math.max(1_000, declaredMs + 1_500)) : cap;
825
- await entersState(ttsPlayer, AudioPlayerStatus.Idle, idleTimeout).catch(() => null);
826
-
827
- // Swap back and resume if needed
828
- this.connection.subscribe(this.audioPlayer);
829
- } catch (err) {
830
- this.debug("[TTS] error while playing:", err);
831
- this.emit("playerError", err as Error);
832
- } finally {
833
- // Clean up TTS stream and resource
834
- try {
835
- if (ttsStream && typeof ttsStream.destroy === "function") {
836
- ttsStream.destroy();
837
- }
838
- } catch (error) {
839
- this.debug("[TTS] Error destroying stream:", error);
840
- }
841
-
842
- if (wasPlaying) {
843
- try {
844
- this.resume();
845
- } catch {}
846
- }
847
- this.emit("ttsEnd");
848
- }
849
- }
850
-
851
- //#endregion
852
- //#region Player Function
853
-
854
- /**
855
- * Connect to a voice channel
856
- *
857
- * @param {VoiceChannel} channel - Discord voice channel
858
- * @returns {Promise<VoiceConnection>} The voice connection
859
- * @example
860
- * await player.connect(voiceChannel);
861
- */
862
- async connect(channel: VoiceChannel): Promise<VoiceConnection> {
863
- try {
864
- this.debug(`[Player] Connecting to voice channel: ${channel.id}`);
865
- const connection = joinVoiceChannel({
866
- channelId: channel.id,
867
- guildId: channel.guildId,
868
- adapterCreator: channel.guild.voiceAdapterCreator as any,
869
- selfDeaf: this.options.selfDeaf ?? true,
870
- selfMute: this.options.selfMute ?? false,
871
- });
872
-
873
- await entersState(connection, VoiceConnectionStatus.Ready, 50_000);
874
- this.connection = connection;
875
-
876
- connection.on(VoiceConnectionStatus.Disconnected, () => {
877
- this.debug(`[Player] VoiceConnectionStatus.Disconnected`);
878
- this.destroy();
879
- });
880
-
881
- connection.on("error", (error) => {
882
- this.debug(`[Player] Voice connection error:`, error);
883
- this.emit("connectionError", error);
884
- });
885
- connection.subscribe(this.audioPlayer);
886
-
887
- this.clearLeaveTimeout();
888
- return this.connection;
889
- } catch (error) {
890
- this.debug(`[Player] Connection error:`, error);
891
- this.emit("connectionError", error as Error);
892
- this.connection?.destroy();
893
- throw error;
894
- }
895
- }
896
-
897
- /**
898
- * Pause the current track
899
- *
900
- * @returns {boolean} True if paused successfully
901
- * @example
902
- * const paused = player.pause();
903
- * console.log(`Paused: ${paused}`);
904
- */
905
- pause(): boolean {
906
- this.debug(`[Player] pause called`);
907
- if (this.isPlaying && !this.isPaused) {
908
- return this.audioPlayer.pause();
909
- }
910
- return false;
911
- }
912
-
913
- /**
914
- * Resume the current track
915
- *
916
- * @returns {boolean} True if resumed successfully
917
- * @example
918
- * const resumed = player.resume();
919
- * console.log(`Resumed: ${resumed}`);
920
- */
921
- resume(): boolean {
922
- this.debug(`[Player] resume called`);
923
- if (this.isPaused) {
924
- const result = this.audioPlayer.unpause();
925
- if (result) {
926
- const track = this.queue.currentTrack;
927
- if (track) {
928
- this.debug(`[Player] Player resumed on track: ${track.title}`);
929
- this.emit("playerResume", track);
930
- }
931
- }
932
- return result;
933
- }
934
- return false;
935
- }
936
-
937
- /**
938
- * Stop the current track
939
- *
940
- * @returns {boolean} True if stopped successfully
941
- * @example
942
- * const stopped = player.stop();
943
- * console.log(`Stopped: ${stopped}`);
944
- */
945
- stop(): boolean {
946
- this.debug(`[Player] stop called`);
947
- this.queue.clear();
948
- const result = this.audioPlayer.stop();
949
- this.isPlaying = false;
950
- this.isPaused = false;
951
- this.emit("playerStop");
952
- return result;
953
- }
954
-
955
- /**
956
- * Seek to a specific position in the current track
957
- *
958
- * @param {number} position - Position in milliseconds to seek to
959
- * @returns {Promise<boolean>} True if seek was successful
960
- * @example
961
- * // Seek to 30 seconds (30000ms)
962
- * const success = await player.seek(30000);
963
- * console.log(`Seek successful: ${success}`);
964
- *
965
- * // Seek to 1 minute 30 seconds (90000ms)
966
- * await player.seek(90000);
967
- */
968
- async seek(position: number): Promise<boolean> {
969
- this.debug(`[Player] seek called with position: ${position}ms`);
970
-
971
- const track = this.queue.currentTrack;
972
- if (!track) {
973
- this.debug(`[Player] No current track to seek`);
974
- return false;
975
- }
976
-
977
- const totalDuration = track.duration > 1000 ? track.duration : track.duration * 1000;
978
- if (position < 0 || position > totalDuration) {
979
- this.debug(`[Player] Invalid seek position: ${position}ms (track duration: ${totalDuration}ms)`);
980
- return false;
981
- }
982
-
983
- const streaminfo = await this.getStream(track);
984
- if (!streaminfo?.stream) {
985
- this.debug(`[Player] No stream to seek`);
986
- return false;
987
- }
988
-
989
- await this.refeshPlayerResource(true, position);
990
-
991
- return true;
992
- }
993
-
994
- /**
995
- * Skip to the next track or skip to a specific index
996
- *
997
- * @param {number} index - Optional index to skip to (0 = next track)
998
- * @returns {boolean} True if skipped successfully
999
- * @example
1000
- * const skipped = player.skip(); // Skip to next track
1001
- * const skippedToIndex = player.skip(2); // Skip to track at index 2
1002
- * console.log(`Skipped: ${skipped}`);
1003
- */
1004
- skip(index?: number): boolean {
1005
- this.debug(`[Player] skip called with index: ${index}`);
1006
- try {
1007
- if (typeof index === "number" && index >= 0) {
1008
- // Skip to specific index
1009
- const targetTrack = this.queue.getTrack(index);
1010
- if (!targetTrack) {
1011
- this.debug(`[Player] No track found at index ${index}`);
1012
- return false;
1013
- }
1014
-
1015
- // Remove tracks from 0 to index-1
1016
- for (let i = 0; i < index; i++) {
1017
- this.queue.remove(0);
1018
- }
1019
-
1020
- this.debug(`[Player] Skipped to track at index ${index}: ${targetTrack.title}`);
1021
- if (this.isPlaying || this.isPaused) {
1022
- this.skipLoop = true;
1023
- return this.audioPlayer.stop();
1024
- }
1025
- return true;
1026
- }
1027
-
1028
- if (this.isPlaying || this.isPaused) {
1029
- this.skipLoop = true;
1030
- return this.audioPlayer.stop();
1031
- }
1032
-
1033
- return true;
1034
- } catch (error) {
1035
- this.debug(`[Player] skip error:`, error);
1036
- return false;
1037
- }
1038
- }
1039
-
1040
- /**
1041
- * Go back to the previous track in history and play it.
1042
- *
1043
- * @returns {Promise<boolean>} True if previous track was played successfully
1044
- * @example
1045
- * const previous = await player.previous();
1046
- * console.log(`Previous: ${previous}`);
1047
- */
1048
- async previous(): Promise<boolean> {
1049
- this.debug(`[Player] previous called`);
1050
- const track = this.queue.previous();
1051
- if (!track) return false;
1052
- if (this.queue.currentTrack) this.insert(this.queue.currentTrack, 0);
1053
- this.clearLeaveTimeout();
1054
- return this.startTrack(track);
1055
- }
1056
-
1057
- /**
1058
- * Save a track's stream to a file and return a Readable stream
1059
- *
1060
- * @param {Track} track - The track to save
1061
- * @param {SaveOptions | string} options - Save options or filename string (for backward compatibility)
1062
- * @returns {Promise<Readable>} A Readable stream containing the audio data
1063
- * @example
1064
- * // Save current track to file
1065
- * const track = player.currentTrack;
1066
- * if (track) {
1067
- * const stream = await player.save(track);
1068
- *
1069
- * // Use fs to write the stream to file
1070
- * const fs = require('fs');
1071
- * const writeStream = fs.createWriteStream('saved-song.mp3');
1072
- * stream.pipe(writeStream);
1073
- *
1074
- * writeStream.on('finish', () => {
1075
- * console.log('File saved successfully!');
1076
- * });
1077
- * }
1078
- *
1079
- * // Save any track by URL
1080
- * const searchResult = await player.search("Never Gonna Give You Up", userId);
1081
- * if (searchResult.tracks.length > 0) {
1082
- * const stream = await player.save(searchResult.tracks[0]);
1083
- * // Handle the stream...
1084
- * }
1085
- *
1086
- * // Backward compatibility - filename as string
1087
- * const stream = await player.save(track, "my-song.mp3");
1088
- */
1089
- async save(track: Track, options?: SaveOptions | string): Promise<Readable> {
1090
- this.debug(`[Player] save called for track: ${track.title}`);
1091
-
1092
- // Parse options - support both SaveOptions object and filename string (backward compatibility)
1093
- let saveOptions: SaveOptions = {};
1094
- if (typeof options === "string") {
1095
- saveOptions = { filename: options };
1096
- } else if (options) {
1097
- saveOptions = options;
1098
- }
1099
-
1100
- try {
1101
- // Try extensions first
1102
- let streamInfo: StreamInfo | null = await this.pluginManager.getStream(track);
1103
-
1104
- if (!streamInfo || !streamInfo.stream) {
1105
- throw new Error(`No save stream available for track: ${track.title}`);
1106
- }
1107
-
1108
- this.debug(`[Player] Save stream obtained for track: ${track.title}`);
1109
- if (saveOptions.filename) {
1110
- this.debug(`[Player] Save options - filename: ${saveOptions.filename}, quality: ${saveOptions.quality || "default"}`);
1111
- }
1112
-
1113
- // Apply filters if any are active
1114
- let finalStream = streamInfo.stream;
1115
-
1116
- if (saveOptions.filter || saveOptions.seek) {
1117
- try {
1118
- this.filter.clearAll();
1119
- this.filter.applyFilters(saveOptions.filter || []);
1120
- } catch (err) {
1121
- this.debug(`[Player] Error applying save filters:`, err);
1122
- }
1123
-
1124
- this.debug(`[Player] Applying filters to save stream: ${this.filter.getFilterString() || "none"}`);
1125
- finalStream = await this.filter.applyFiltersAndSeek(streamInfo.stream, saveOptions.seek || 0).catch((err) => {
1126
- this.debug(`[Player] Error applying filters to save stream:`, err);
1127
- return streamInfo!.stream; // Fallback to original stream
1128
- });
1129
- }
1130
-
1131
- // Return the stream directly - caller can pipe it to fs.createWriteStream()
1132
- return finalStream;
1133
- } catch (error) {
1134
- this.debug(`[Player] save error:`, error);
1135
- this.emit("playerError", error as Error, track);
1136
- throw error;
1137
- }
1138
- }
1139
-
1140
- /**
1141
- * Loop the current track or queue
1142
- *
1143
- * @param {LoopMode | number} mode - The loop mode to set ("off", "track", "queue") or number (0=off, 1=track, 2=queue)
1144
- * @returns {LoopMode} The loop mode
1145
- * @example
1146
- * const loopMode = player.loop("track"); // Loop current track
1147
- * const loopQueue = player.loop("queue"); // Loop entire queue
1148
- * const loopTrack = player.loop(1); // Loop current track (same as "track")
1149
- * const loopQueueNum = player.loop(2); // Loop entire queue (same as "queue")
1150
- * const noLoop = player.loop("off"); // No loop
1151
- * const noLoopNum = player.loop(0); // No loop (same as "off")
1152
- * console.log(`Loop mode: ${loopMode}`);
1153
- */
1154
- loop(mode?: LoopMode | number): LoopMode {
1155
- this.debug(`[Player] loop called with mode: ${mode}`);
1156
-
1157
- if (typeof mode === "number") {
1158
- // Number mode: convert to text mode
1159
- switch (mode) {
1160
- case 0:
1161
- return this.queue.loop("off");
1162
- case 1:
1163
- return this.queue.loop("track");
1164
- case 2:
1165
- return this.queue.loop("queue");
1166
- default:
1167
- this.debug(`[Player] Invalid loop number: ${mode}, using "off"`);
1168
- return this.queue.loop("off");
1169
- }
1170
- }
1171
-
1172
- return this.queue.loop(mode as LoopMode);
1173
- }
1174
-
1175
- /**
1176
- * Set the auto-play mode
1177
- *
1178
- * @param {boolean} mode - The auto-play mode to set
1179
- * @returns {boolean} The auto-play mode
1180
- * @example
1181
- * const autoPlayMode = player.autoPlay(true);
1182
- * console.log(`Auto-play mode: ${autoPlayMode}`);
1183
- */
1184
- autoPlay(mode?: boolean): boolean {
1185
- return this.queue.autoPlay(mode);
1186
- }
1187
-
1188
- /**
1189
- * Set the volume of the current track
1190
- *
1191
- * @param {number} volume - The volume to set
1192
- * @returns {boolean} True if volume was set successfully
1193
- * @example
1194
- * const volumeSet = player.setVolume(50);
1195
- * console.log(`Volume set: ${volumeSet}`);
1196
- */
1197
- setVolume(volume: number): boolean {
1198
- this.debug(`[Player] setVolume called: ${volume}`);
1199
- if (volume < 0 || volume > 200) return false;
1200
-
1201
- const oldVolume = this.volume;
1202
- this.volume = volume;
1203
- const resourceVolume = this.currentResource?.volume;
1204
-
1205
- if (resourceVolume) {
1206
- if (this.volumeInterval) clearInterval(this.volumeInterval);
1207
-
1208
- const start = resourceVolume.volume;
1209
- const target = this.volume / 100;
1210
- const steps = 10;
1211
- let currentStep = 0;
1212
-
1213
- this.volumeInterval = setInterval(() => {
1214
- currentStep++;
1215
- const value = start + ((target - start) * currentStep) / steps;
1216
- resourceVolume.setVolume(value);
1217
- if (currentStep >= steps) {
1218
- clearInterval(this.volumeInterval!);
1219
- this.volumeInterval = null;
1220
- }
1221
- }, 300);
1222
- }
1223
-
1224
- this.emit("volumeChange", oldVolume, volume);
1225
- return true;
1226
- }
1227
-
1228
- /**
1229
- * Shuffle the queue
1230
- *
1231
- * @returns {void}
1232
- * @example
1233
- * player.shuffle();
1234
- */
1235
- shuffle(): void {
1236
- this.debug(`[Player] shuffle called`);
1237
- this.queue.shuffle();
1238
- }
1239
-
1240
- /**
1241
- * Clear the queue
1242
- *
1243
- * @returns {void}
1244
- * @example
1245
- * player.clearQueue();
1246
- */
1247
- clearQueue(): void {
1248
- this.debug(`[Player] clearQueue called`);
1249
- this.queue.clear();
1250
- }
1251
-
1252
- /**
1253
- * Insert a track or list of tracks into the upcoming queue at a specific position (0 = play after current).
1254
- * - If `query` is a string, performs a search and inserts resulting tracks (playlist supported).
1255
- * - If a Track or Track[] is provided, inserts directly.
1256
- * Does not auto-start playback; it only modifies the queue.
1257
- *
1258
- * @param {string | Track | Track[]} query - The track or tracks to insert
1259
- * @param {number} index - The index to insert the tracks at
1260
- * @param {string} requestedBy - The user ID who requested the insert
1261
- * @returns {Promise<boolean>} True if the tracks were inserted successfully
1262
- * @example
1263
- * const inserted = await player.insert("Song Name", 0, userId);
1264
- * console.log(`Inserted: ${inserted}`);
1265
- */
1266
- async insert(query: string | Track | Track[], index: number, requestedBy?: string): Promise<boolean> {
1267
- try {
1268
- this.debug(`[Player] insert called at index ${index} with type: ${typeof query}`);
1269
- let tracksToAdd: Track[] = [];
1270
- let isPlaylist = false;
1271
-
1272
- if (typeof query === "string") {
1273
- const searchResult = await this.search(query, requestedBy || "Unknown");
1274
- tracksToAdd = searchResult.tracks || [];
1275
- isPlaylist = !!searchResult.playlist;
1276
- } else if (Array.isArray(query)) {
1277
- tracksToAdd = query;
1278
- isPlaylist = query.length > 1;
1279
- } else if (query) {
1280
- tracksToAdd = [query];
1281
- }
1282
-
1283
- if (!tracksToAdd || tracksToAdd.length === 0) {
1284
- this.debug(`[Player] insert: no tracks resolved`);
1285
- throw new Error("No tracks to insert");
1286
- }
1287
-
1288
- if (tracksToAdd.length === 1) {
1289
- this.queue.insert(tracksToAdd[0], index);
1290
- this.emit("queueAdd", tracksToAdd[0]);
1291
- this.debug(`[Player] Inserted track at index ${index}: ${tracksToAdd[0].title}`);
1292
- } else {
1293
- this.queue.insertMultiple(tracksToAdd, index);
1294
- this.emit("queueAddList", tracksToAdd);
1295
- this.debug(`[Player] Inserted ${tracksToAdd.length} ${isPlaylist ? "playlist " : ""}tracks at index ${index}`);
1296
- }
1297
-
1298
- return true;
1299
- } catch (error) {
1300
- this.debug(`[Player] insert error:`, error);
1301
- this.emit("playerError", error as Error);
1302
- return false;
1303
- }
1304
- }
1305
-
1306
- /**
1307
- * Remove a track from the queue
1308
- *
1309
- * @param {number} index - The index of the track to remove
1310
- * @returns {Track | null} The removed track or null
1311
- * @example
1312
- * const removed = player.remove(0);
1313
- * console.log(`Removed: ${removed?.title}`);
1314
- */
1315
- remove(index: number): Track | null {
1316
- this.debug(`[Player] remove called for index: ${index}`);
1317
- const track = this.queue.remove(index);
1318
- if (track) {
1319
- this.emit("queueRemove", track, index);
1320
- }
1321
- return track;
1322
- }
1323
-
1324
- /**
1325
- * Get the progress bar of the current track
1326
- *
1327
- * @param {ProgressBarOptions} options - The options for the progress bar
1328
- * @returns {string} The progress bar
1329
- * @example
1330
- * const progressBar = player.getProgressBar();
1331
- * console.log(`Progress bar: ${progressBar}`);
1332
- */
1333
- getProgressBar(options: ProgressBarOptions = {}): string {
1334
- const { size = 20, barChar = "▬", progressChar = "🔘" } = options;
1335
- const track = this.queue.currentTrack;
1336
- const resource = this.currentResource;
1337
- if (!track || !resource) return "";
1338
-
1339
- const total = track.duration > 1000 ? track.duration : track.duration * 1000;
1340
- if (!total) return this.formatTime(resource.playbackDuration);
1341
-
1342
- const current = resource.playbackDuration;
1343
- const ratio = Math.min(current / total, 1);
1344
- const progress = Math.round(ratio * size);
1345
- const bar = barChar.repeat(progress) + progressChar + barChar.repeat(size - progress);
1346
-
1347
- return `${this.formatTime(current)} | ${bar} | ${this.formatTime(total)}`;
1348
- }
1349
-
1350
- /**
1351
- * Get the time of the current track
1352
- *
1353
- * @returns {Object} The time of the current track
1354
- * @example
1355
- * const time = player.getTime();
1356
- * console.log(`Time: ${time.current}`);
1357
- */
1358
- getTime() {
1359
- const resource = this.currentResource;
1360
- const track = this.queue.currentTrack;
1361
- if (!track || !resource)
1362
- return {
1363
- current: 0,
1364
- total: 0,
1365
- format: "00:00",
1366
- };
1367
-
1368
- const total = track.duration > 1000 ? track.duration : track.duration * 1000;
1369
-
1370
- return {
1371
- current: resource?.playbackDuration,
1372
- total: total,
1373
- format: this.formatTime(resource.playbackDuration),
1374
- };
1375
- }
1376
-
1377
- /**
1378
- * Format the time in the format of HH:MM:SS
1379
- *
1380
- * @param {number} ms - The time in milliseconds
1381
- * @returns {string} The formatted time
1382
- * @example
1383
- * const formattedTime = player.formatTime(1000);
1384
- * console.log(`Formatted time: ${formattedTime}`);
1385
- */
1386
- formatTime(ms: number): string {
1387
- const totalSeconds = Math.floor(ms / 1000);
1388
- const hours = Math.floor(totalSeconds / 3600);
1389
- const minutes = Math.floor((totalSeconds % 3600) / 60);
1390
- const seconds = totalSeconds % 60;
1391
- const parts: string[] = [];
1392
- if (hours > 0) parts.push(String(hours).padStart(2, "0"));
1393
- parts.push(String(minutes).padStart(2, "0"));
1394
- parts.push(String(seconds).padStart(2, "0"));
1395
- return parts.join(":");
1396
- }
1397
-
1398
- /**
1399
- * Destroy the player
1400
- *
1401
- * @returns {void}
1402
- * @example
1403
- * player.destroy();
1404
- */
1405
- destroy(): void {
1406
- this.debug(`[Player] destroy called`);
1407
- if (this.leaveTimeout) {
1408
- clearTimeout(this.leaveTimeout);
1409
- this.leaveTimeout = null;
1410
- }
1411
-
1412
- // Destroy current stream before stopping audio
1413
- this.destroyCurrentStream();
1414
-
1415
- this.audioPlayer.stop(true);
1416
-
1417
- if (this.ttsPlayer) {
1418
- try {
1419
- this.ttsPlayer.stop(true);
1420
- } catch {}
1421
- this.ttsPlayer = null;
1422
- }
1423
-
1424
- if (this.connection) {
1425
- this.connection.destroy();
1426
- this.connection = null;
1427
- }
1428
-
1429
- this.queue.clear();
1430
- this.pluginManager.clear();
1431
- this.filter.destroy();
1432
- this.extensionManager.destroy();
1433
- this.isPlaying = false;
1434
- this.isPaused = false;
1435
-
1436
- // Clear any remaining intervals
1437
- if (this.volumeInterval) {
1438
- clearInterval(this.volumeInterval);
1439
- this.volumeInterval = null;
1440
- }
1441
-
1442
- this.emit("playerDestroy");
1443
- this.removeAllListeners();
1444
- }
1445
-
1446
- //#endregion
1447
- //#region utils
1448
- private scheduleLeave(): void {
1449
- this.debug(`[Player] scheduleLeave called`);
1450
- if (this.leaveTimeout) {
1451
- clearTimeout(this.leaveTimeout);
1452
- }
1453
-
1454
- if (this.options.leaveOnEmpty && this.options.leaveTimeout) {
1455
- this.leaveTimeout = setTimeout(() => {
1456
- this.debug(`[Player] Leaving voice channel after timeoutMs`);
1457
- this.destroy();
1458
- }, this.options.leaveTimeout);
1459
- }
1460
- }
1461
-
1462
- /**
1463
- * Refesh player resource (apply filter)
1464
- *
1465
- * @param {boolean} applyToCurrent - Apply filter for curent track
1466
- * @param {number} position - Position to seek to in milliseconds
1467
- * @returns {Promise<boolean>}
1468
- * @example
1469
- * const refreshed = await player.refeshPlayerResource(true, 1000);
1470
- * console.log(`Refreshed: ${refreshed}`);
1471
- */
1472
- public async refeshPlayerResource(applyToCurrent: boolean = true, position: number = -1): Promise<boolean> {
1473
- if (!applyToCurrent || !this.queue.currentTrack || !(this.isPlaying || this.isPaused)) {
1474
- return false;
1475
- }
1476
-
1477
- try {
1478
- const track = this.queue.currentTrack;
1479
- this.debug(`[Player] Refreshing player resource for track: ${track.title}`);
1480
-
1481
- // Get current position for seeking
1482
- const currentPosition = position > 0 ? position : this.currentResource?.playbackDuration || 0;
1483
-
1484
- const streaminfo = await this.getStream(track);
1485
- if (!streaminfo?.stream) {
1486
- this.debug(`[Player] No stream to refresh`);
1487
- return false;
1488
- }
1489
-
1490
- // Create AudioResource with filters and seek to current position
1491
- const resource = await this.createResource(streaminfo, track, currentPosition);
1492
-
1493
- // Stop current playback and destroy old resource/stream
1494
- const wasPlaying = this.isPlaying;
1495
- const wasPaused = this.isPaused;
1496
-
1497
- this.audioPlayer.stop();
1498
-
1499
- // Properly destroy the old resource and stream
1500
- try {
1501
- if (this.currentResource) {
1502
- const oldStream = (this.currentResource as any)._readableState?.stream || (this.currentResource as any).stream;
1503
- if (oldStream && typeof oldStream.destroy === "function") {
1504
- oldStream.destroy();
1505
- }
1506
- }
1507
- } catch (error) {
1508
- this.debug(`[Player] Error destroying old stream in refeshPlayerResource:`, error);
1509
- }
1510
-
1511
- this.currentResource = resource;
1512
-
1513
- // Subscribe to new resource
1514
- if (this.connection) {
1515
- this.connection.subscribe(this.audioPlayer);
1516
- this.audioPlayer.play(resource);
1517
- }
1518
-
1519
- // Restore playing state
1520
- if (wasPlaying && !wasPaused) {
1521
- this.isPlaying = true;
1522
- this.isPaused = false;
1523
- } else if (wasPaused) {
1524
- this.isPlaying = false;
1525
- this.isPaused = true;
1526
- this.audioPlayer.pause();
1527
- }
1528
-
1529
- this.debug(`[Player] Successfully applied filter to current track at position ${currentPosition}ms`);
1530
- return true;
1531
- } catch (error) {
1532
- this.debug(`[Player] Error applying filter to current track:`, error);
1533
- // Filter was still added to active filters, so return true
1534
- return true;
1535
- }
1536
- }
1537
-
1538
- /**
1539
- * Attach an extension to the player
1540
- *
1541
- * @param {BaseExtension} extension - The extension to attach
1542
- * @example
1543
- * player.attachExtension(new MyExtension());
1544
- */
1545
- public attachExtension(extension: BaseExtension): void {
1546
- this.extensionManager.register(extension);
1547
- }
1548
-
1549
- /**
1550
- * Detach an extension from the player
1551
- *
1552
- * @param {BaseExtension} extension - The extension to detach
1553
- * @example
1554
- * player.detachExtension(new MyExtension());
1555
- */
1556
- public detachExtension(extension: BaseExtension): void {
1557
- this.extensionManager.unregister(extension);
1558
- }
1559
-
1560
- /**
1561
- * Get all extensions attached to the player
1562
- *
1563
- * @returns {readonly BaseExtension[]} All attached extensions
1564
- * @example
1565
- * const extensions = player.getExtensions();
1566
- * console.log(`Extensions: ${extensions.length}`);
1567
- */
1568
- public getExtensions(): readonly BaseExtension[] {
1569
- return this.extensionManager.getAll();
1570
- }
1571
-
1572
- private clearLeaveTimeout(): void {
1573
- if (this.leaveTimeout) {
1574
- clearTimeout(this.leaveTimeout);
1575
- this.leaveTimeout = null;
1576
- this.debug(`[Player] Cleared leave timeoutMs`);
1577
- }
1578
- }
1579
-
1580
- private debug(message?: any, ...optionalParams: any[]): void {
1581
- if (this.listenerCount("debug") > 0) {
1582
- this.emit("debug", message, ...optionalParams);
1583
- }
1584
- }
1585
-
1586
- private setupEventListeners(): void {
1587
- this.audioPlayer.on("stateChange", (oldState, newState) => {
1588
- this.debug(`[Player] AudioPlayer stateChange from ${oldState.status} to ${newState.status}`);
1589
- if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) {
1590
- // Track ended
1591
- const track = this.queue.currentTrack;
1592
- if (track) {
1593
- this.debug(`[Player] Track ended: ${track.title}`);
1594
- this.emit("trackEnd", track);
1595
- }
1596
- this.playNext();
1597
- } else if (
1598
- newState.status === AudioPlayerStatus.Playing &&
1599
- (oldState.status === AudioPlayerStatus.Idle || oldState.status === AudioPlayerStatus.Buffering)
1600
- ) {
1601
- // Track started
1602
- this.clearLeaveTimeout();
1603
- this.isPlaying = true;
1604
- this.isPaused = false;
1605
- const track = this.queue.currentTrack;
1606
- if (track) {
1607
- this.debug(`[Player] Track started: ${track.title}`);
1608
- this.emit("trackStart", track);
1609
- }
1610
- } else if (newState.status === AudioPlayerStatus.Paused && oldState.status !== AudioPlayerStatus.Paused) {
1611
- // Track paused
1612
- this.isPaused = true;
1613
- const track = this.queue.currentTrack;
1614
- if (track) {
1615
- this.debug(`[Player] Player paused on track: ${track.title}`);
1616
- this.emit("playerPause", track);
1617
- }
1618
- } else if (newState.status !== AudioPlayerStatus.Paused && oldState.status === AudioPlayerStatus.Paused) {
1619
- // Track resumed
1620
- this.isPaused = false;
1621
- const track = this.queue.currentTrack;
1622
- if (track) {
1623
- this.debug(`[Player] Player resumed on track: ${track.title}`);
1624
- this.emit("playerResume", track);
1625
- }
1626
- } else if (newState.status === AudioPlayerStatus.AutoPaused) {
1627
- this.debug(`[Player] AudioPlayerStatus.AutoPaused`);
1628
- } else if (newState.status === AudioPlayerStatus.Buffering) {
1629
- this.debug(`[Player] AudioPlayerStatus.Buffering`);
1630
- }
1631
- });
1632
- this.audioPlayer.on("error", (error) => {
1633
- this.debug(`[Player] AudioPlayer error:`, error);
1634
- this.emit("playerError", error, this.queue.currentTrack || undefined);
1635
- this.playNext();
1636
- });
1637
-
1638
- this.audioPlayer.on("debug", (...args) => {
1639
- if (this.manager.debugEnabled) {
1640
- this.emit("debug", ...args);
1641
- }
1642
- });
1643
- }
1644
-
1645
- addPlugin(plugin: SourcePlugin): void {
1646
- this.debug(`[Player] Adding plugin: ${plugin.name}`);
1647
- this.pluginManager.register(plugin);
1648
- }
1649
-
1650
- removePlugin(name: string): boolean {
1651
- this.debug(`[Player] Removing plugin: ${name}`);
1652
- return this.pluginManager.unregister(name);
1653
- }
1654
-
1655
- //#endregion
1656
- //#region Getters
1657
-
1658
- /**
1659
- * Get the size of the queue
1660
- *
1661
- * @returns {number} The size of the queue
1662
- * @example
1663
- * const queueSize = player.queueSize;
1664
- * console.log(`Queue size: ${queueSize}`);
1665
- */
1666
- get queueSize(): number {
1667
- return this.queue.size;
1668
- }
1669
-
1670
- /**
1671
- * Get the current track
1672
- *
1673
- * @returns {Track | null} The current track or null
1674
- * @example
1675
- * const currentTrack = player.currentTrack;
1676
- * console.log(`Current track: ${currentTrack?.title}`);
1677
- */
1678
- get currentTrack(): Track | null {
1679
- return this.queue.currentTrack;
1680
- }
1681
-
1682
- /**
1683
- * Get the previous track
1684
- *
1685
- * @returns {Track | null} The previous track or null
1686
- * @example
1687
- * const previousTrack = player.previousTrack;
1688
- * console.log(`Previous track: ${previousTrack?.title}`);
1689
- */
1690
- get previousTrack(): Track | null {
1691
- return this.queue.previousTracks?.at(-1) ?? null;
1692
- }
1693
-
1694
- /**
1695
- * Get the upcoming tracks
1696
- *
1697
- * @returns {Track[]} The upcoming tracks
1698
- * @example
1699
- * const upcomingTracks = player.upcomingTracks;
1700
- * console.log(`Upcoming tracks: ${upcomingTracks.length}`);
1701
- */
1702
- get upcomingTracks(): Track[] {
1703
- return this.queue.getTracks();
1704
- }
1705
-
1706
- /**
1707
- * Get the previous tracks
1708
- *
1709
- * @returns {Track[]} The previous tracks
1710
- * @example
1711
- * const previousTracks = player.previousTracks;
1712
- * console.log(`Previous tracks: ${previousTracks.length}`);
1713
- */
1714
- get previousTracks(): Track[] {
1715
- return this.queue.previousTracks;
1716
- }
1717
-
1718
- /**
1719
- * Get the available plugins
1720
- *
1721
- * @returns {string[]} The available plugins
1722
- * @example
1723
- * const availablePlugins = player.availablePlugins;
1724
- * console.log(`Available plugins: ${availablePlugins.length}`);
1725
- */
1726
- get availablePlugins(): string[] {
1727
- return this.pluginManager.getAll().map((p) => p.name);
1728
- }
1729
-
1730
- /**
1731
- * Get the related tracks
1732
- *
1733
- * @returns {Track[] | null} The related tracks or null
1734
- * @example
1735
- * const relatedTracks = player.relatedTracks;
1736
- * console.log(`Related tracks: ${relatedTracks?.length}`);
1737
- */
1738
- get relatedTracks(): Track[] | null {
1739
- return this.queue.relatedTracks();
1740
- }
1741
-
1742
- //#endregion
1743
- }
1
+ import { EventEmitter } from "events";
2
+ import {
3
+ createAudioPlayer,
4
+ createAudioResource,
5
+ entersState,
6
+ AudioPlayerStatus,
7
+ VoiceConnection,
8
+ AudioPlayer as DiscordAudioPlayer,
9
+ VoiceConnectionStatus,
10
+ NoSubscriberBehavior,
11
+ joinVoiceChannel,
12
+ AudioResource,
13
+ StreamType,
14
+ } from "@discordjs/voice";
15
+
16
+ import { Readable } from "stream";
17
+ import type { BaseExtension } from "../extensions";
18
+ import type {
19
+ Track,
20
+ PlayerOptions,
21
+ PlayerEvents,
22
+ SourcePlugin,
23
+ SearchResult,
24
+ ProgressBarOptions,
25
+ LoopMode,
26
+ StreamInfo,
27
+ SaveOptions,
28
+ VoiceChannel,
29
+ ExtensionPlayRequest,
30
+ ExtensionPlayResponse,
31
+ ExtensionAfterPlayPayload,
32
+ } from "../types";
33
+ import type { PlayerManager } from "./PlayerManager";
34
+
35
+ import { Queue } from "./Queue";
36
+ import { PluginManager } from "../plugins";
37
+ import { ExtensionManager } from "../extensions";
38
+ import { withTimeout } from "../utils/timeout";
39
+ import { FilterManager } from "./FilterManager";
40
+
41
+ export declare interface Player {
42
+ on<K extends keyof PlayerEvents>(event: K, listener: (...args: PlayerEvents[K]) => void): this;
43
+ emit<K extends keyof PlayerEvents>(event: K, ...args: PlayerEvents[K]): boolean;
44
+ }
45
+
46
+ /**
47
+ * Represents a music player for a specific Discord guild.
48
+ *
49
+ * @example
50
+ * // Create and configure player
51
+ * const player = await manager.create(guildId, {
52
+ * tts: { interrupt: true, volume: 1 },
53
+ * leaveOnEnd: true,
54
+ * leaveTimeout: 30000
55
+ * });
56
+ *
57
+ * // Connect to voice channel
58
+ * await player.connect(voiceChannel);
59
+ *
60
+ * // Play different types of content
61
+ * await player.play("Never Gonna Give You Up", userId); // Search query
62
+ * await player.play("https://youtube.com/watch?v=dQw4w9WgXcQ", userId); // Direct URL
63
+ * await player.play("tts: Hello everyone!", userId); // Text-to-Speech
64
+ *
65
+ * // Player controls
66
+ * player.pause(); // Pause current track
67
+ * player.resume(); // Resume paused track
68
+ * player.skip(); // Skip to next track
69
+ * player.stop(); // Stop and clear queue
70
+ * player.setVolume(0.5); // Set volume to 50%
71
+ *
72
+ * // Event handling
73
+ * player.on("trackStart", (player, track) => {
74
+ * console.log(`Now playing: ${track.title}`);
75
+ * });
76
+ *
77
+ * player.on("queueEnd", (player) => {
78
+ * console.log("Queue finished");
79
+ * });
80
+ *
81
+ */
82
+ export class Player extends EventEmitter {
83
+ public readonly guildId: string;
84
+ public connection: VoiceConnection | null = null;
85
+ public audioPlayer: DiscordAudioPlayer;
86
+ public queue: Queue;
87
+ public volume: number = 100;
88
+ public isPlaying: boolean = false;
89
+ public isPaused: boolean = false;
90
+ public options: PlayerOptions;
91
+ public pluginManager: PluginManager;
92
+ public extensionManager: ExtensionManager;
93
+ public userdata?: Record<string, any>;
94
+ private manager: PlayerManager;
95
+ private leaveTimeout: NodeJS.Timeout | null = null;
96
+ private currentResource: AudioResource | null = null;
97
+ private volumeInterval: NodeJS.Timeout | null = null;
98
+ private skipLoop = false;
99
+ private filter!: FilterManager;
100
+
101
+ // Cache for search results to avoid duplicate calls
102
+ private searchCache = new Map<string, SearchResult>();
103
+ private readonly SEARCH_CACHE_TTL = 2 * 60 * 1000; // 2 minutes
104
+ private searchCacheTimestamps = new Map<string, number>();
105
+ private ttsPlayer: DiscordAudioPlayer | null = null;
106
+
107
+ constructor(guildId: string, options: PlayerOptions = {}, manager: PlayerManager) {
108
+ super();
109
+ this.debug(`[Player] Constructor called for guildId: ${guildId}`);
110
+ this.guildId = guildId;
111
+ this.queue = new Queue();
112
+ this.manager = manager;
113
+ this.audioPlayer = createAudioPlayer({
114
+ behaviors: {
115
+ noSubscriber: NoSubscriberBehavior.Pause,
116
+ maxMissedFrames: 100,
117
+ },
118
+ });
119
+
120
+ this.options = {
121
+ leaveOnEnd: true,
122
+ leaveOnEmpty: true,
123
+ leaveTimeout: 100000,
124
+ volume: 100,
125
+ quality: "high",
126
+ extractorTimeout: 50000,
127
+ selfDeaf: true,
128
+ selfMute: false,
129
+ ...options,
130
+ tts: {
131
+ createPlayer: false,
132
+ interrupt: true,
133
+ volume: 100,
134
+ Max_Time_TTS: 60_000,
135
+ ...(options?.tts || {}),
136
+ },
137
+ };
138
+ this.filter = new FilterManager(this, this.manager);
139
+ this.extensionManager = new ExtensionManager(this, this.manager);
140
+ this.pluginManager = new PluginManager(this, this.manager, {
141
+ extractorTimeout: this.options.extractorTimeout,
142
+ });
143
+
144
+ this.volume = this.options.volume || 100;
145
+ this.userdata = this.options.userdata;
146
+ this.setupEventListeners();
147
+
148
+ // Initialize filters from options
149
+ if (this.options.filters && this.options.filters.length > 0) {
150
+ this.debug(`[Player] Initializing ${this.options.filters.length} filters from options`);
151
+ // Use async version but don't await in constructor
152
+ this.filter.applyFilters(this.options.filters).catch((error: any) => {
153
+ this.debug(`[Player] Error initializing filters:`, error);
154
+ });
155
+ }
156
+
157
+ // Optionally pre-create the TTS AudioPlayer
158
+ if (this.options?.tts?.createPlayer) {
159
+ this.ensureTTSPlayer();
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Destroy current stream to prevent memory leaks
165
+ * @private
166
+ */
167
+ private destroyCurrentStream(): void {
168
+ if (!this.currentResource) return;
169
+
170
+ const stream = (this.currentResource as any)?.metadata?.stream ?? (this.currentResource as any)?.stream;
171
+
172
+ if (stream?.destroy) {
173
+ stream.destroy();
174
+ }
175
+
176
+ this.currentResource = null;
177
+ }
178
+
179
+ //#region Search
180
+
181
+ /**
182
+ * Search for tracks using the player's extensions and plugins
183
+ *
184
+ * @param {string} query - The query to search for
185
+ * @param {string} requestedBy - The user ID who requested the search
186
+ * @returns {Promise<SearchResult>} The search result
187
+ * @example
188
+ * const result = await player.search("Never Gonna Give You Up", userId);
189
+ * console.log(`Search result: ${result.tracks.length} tracks`);
190
+ */
191
+ async search(query: string, requestedBy: string): Promise<SearchResult> {
192
+ this.debug(`[Player] Search called with query: ${query}, requestedBy: ${requestedBy}`);
193
+
194
+ // Clear expired search cache periodically
195
+ if (Math.random() < 0.1) {
196
+ // 10% chance to clean cache
197
+ this.clearExpiredSearchCache();
198
+ }
199
+
200
+ // Check cache first
201
+ const cachedResult = this.getCachedSearchResult(query);
202
+ if (cachedResult) {
203
+ return cachedResult;
204
+ }
205
+
206
+ // Try extensions first
207
+ const extensionResult = await this.extensionManager.provideSearch(query, requestedBy);
208
+ if (extensionResult && Array.isArray(extensionResult.tracks) && extensionResult.tracks.length > 0) {
209
+ this.debug(`[Player] Extension handled search for query: ${query}`);
210
+ this.cacheSearchResult(query, extensionResult);
211
+ return extensionResult;
212
+ }
213
+
214
+ // Get plugins and filter out TTS for regular searches
215
+ const allPlugins = this.pluginManager.getAll();
216
+ const plugins = allPlugins.filter((p) => {
217
+ // Skip TTS plugin for regular searches (unless query starts with "tts:")
218
+ if (p.name.toLowerCase() === "tts" && !query.toLowerCase().startsWith("tts:")) {
219
+ this.debug(`[Player] Skipping TTS plugin for regular search: ${query}`);
220
+ return false;
221
+ }
222
+ return true;
223
+ });
224
+
225
+ this.debug(`[Player] Using ${plugins.length} plugins for search (filtered from ${allPlugins.length})`);
226
+
227
+ let lastError: any = null;
228
+ let searchAttempts = 0;
229
+
230
+ for (const p of plugins) {
231
+ searchAttempts++;
232
+ try {
233
+ this.debug(`[Player] Trying plugin for search: ${p.name} (attempt ${searchAttempts}/${plugins.length})`);
234
+ const startTime = Date.now();
235
+ const res = await withTimeout(
236
+ p.search(query, requestedBy),
237
+ this.options.extractorTimeout ?? 15000,
238
+ `Search operation timed out for ${p.name}`,
239
+ );
240
+ const duration = Date.now() - startTime;
241
+
242
+ if (res && Array.isArray(res.tracks) && res.tracks.length > 0) {
243
+ this.debug(`[Player] Plugin '${p.name}' returned ${res.tracks.length} tracks in ${duration}ms`);
244
+ this.cacheSearchResult(query, res);
245
+ return res;
246
+ }
247
+ this.debug(`[Player] Plugin '${p.name}' returned no tracks in ${duration}ms`);
248
+ } catch (error) {
249
+ const errorMessage = error instanceof Error ? error.message : String(error);
250
+ this.debug(`[Player] Search via plugin '${p.name}' failed: ${errorMessage}`);
251
+ lastError = error;
252
+ // Continue to next plugin
253
+ }
254
+ }
255
+
256
+ this.debug(`[Player] No plugins returned results for query: ${query} (tried ${searchAttempts} plugins)`);
257
+ if (lastError) this.emit("playerError", lastError as Error);
258
+ throw new Error(`No plugin found to handle: ${query}`);
259
+ }
260
+
261
+ /**
262
+ * Get cached search result or null if not found/expired
263
+ * @param query The search query
264
+ * @returns Cached search result or null
265
+ */
266
+ private getCachedSearchResult(query: string): SearchResult | null {
267
+ const cacheKey = query.toLowerCase().trim();
268
+ const now = Date.now();
269
+
270
+ const cachedTimestamp = this.searchCacheTimestamps.get(cacheKey);
271
+ if (cachedTimestamp && now - cachedTimestamp < this.SEARCH_CACHE_TTL) {
272
+ const cachedResult = this.searchCache.get(cacheKey);
273
+ if (cachedResult) {
274
+ this.debug(`[SearchCache] Using cached search result for: ${query}`);
275
+ return cachedResult;
276
+ }
277
+ }
278
+
279
+ return null;
280
+ }
281
+
282
+ /**
283
+ * Cache search result
284
+ * @param query The search query
285
+ * @param result The search result to cache
286
+ */
287
+ private cacheSearchResult(query: string, result: SearchResult): void {
288
+ const cacheKey = query.toLowerCase().trim();
289
+ const now = Date.now();
290
+
291
+ this.searchCache.set(cacheKey, result);
292
+ this.searchCacheTimestamps.set(cacheKey, now);
293
+ this.debug(`[SearchCache] Cached search result for: ${query} (${result.tracks.length} tracks)`);
294
+ }
295
+
296
+ /**
297
+ * Clear expired search cache entries
298
+ */
299
+ private clearExpiredSearchCache(): void {
300
+ const now = Date.now();
301
+ for (const [key, timestamp] of this.searchCacheTimestamps.entries()) {
302
+ if (now - timestamp >= this.SEARCH_CACHE_TTL) {
303
+ this.searchCache.delete(key);
304
+ this.searchCacheTimestamps.delete(key);
305
+ this.debug(`[SearchCache] Cleared expired cache entry: ${key}`);
306
+ }
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Clear all search cache entries
312
+ * @example
313
+ * player.clearSearchCache();
314
+ */
315
+ public clearSearchCache(): void {
316
+ const cacheSize = this.searchCache.size;
317
+ this.searchCache.clear();
318
+ this.searchCacheTimestamps.clear();
319
+ this.debug(`[SearchCache] Cleared all ${cacheSize} search cache entries`);
320
+ }
321
+
322
+ /**
323
+ * Debug method to check for duplicate search calls
324
+ * @param query The search query to check
325
+ * @returns Debug information about the query
326
+ */
327
+ public debugSearchQuery(query: string): {
328
+ isCached: boolean;
329
+ cacheAge?: number;
330
+ pluginCount: number;
331
+ ttsFiltered: boolean;
332
+ } {
333
+ const cacheKey = query.toLowerCase().trim();
334
+ const now = Date.now();
335
+ const cachedTimestamp = this.searchCacheTimestamps.get(cacheKey);
336
+ const isCached = cachedTimestamp && now - cachedTimestamp < this.SEARCH_CACHE_TTL;
337
+
338
+ const allPlugins = this.pluginManager.getAll();
339
+ const plugins = allPlugins.filter((p) => {
340
+ if (p.name.toLowerCase() === "tts" && !query.toLowerCase().startsWith("tts:")) {
341
+ return false;
342
+ }
343
+ return true;
344
+ });
345
+
346
+ return {
347
+ isCached: !!isCached,
348
+ cacheAge: cachedTimestamp ? now - cachedTimestamp : undefined,
349
+ pluginCount: plugins.length,
350
+ ttsFiltered: allPlugins.length > plugins.length,
351
+ };
352
+ }
353
+
354
+ private async generateWillNext(): Promise<void> {
355
+ const lastTrack = this.queue.previousTracks[this.queue.previousTracks.length - 1] ?? this.queue.currentTrack;
356
+ if (!lastTrack) return;
357
+ const related = await this.pluginManager.getRelatedTracks(lastTrack);
358
+ if (!related || related.length === 0) return;
359
+ const randomchoice = Math.floor(Math.random() * related.length);
360
+ const nextTrack = this.queue.nextTrack ? this.queue.nextTrack : related[randomchoice];
361
+ this.queue.willNextTrack(nextTrack);
362
+ this.queue.relatedTracks(related);
363
+ this.debug(`[Player] Will next track if autoplay: ${nextTrack?.title}]`);
364
+ this.emit("willPlay", nextTrack, related);
365
+ }
366
+ //#endregion
367
+ //#region Play
368
+
369
+ /**
370
+ * Play a track, search query, search result, or play from queue
371
+ *
372
+ * @param {string | Track | SearchResult | null} query - Track URL, search query, Track object, SearchResult, or null for play
373
+ * @param {string} requestedBy - User ID who requested the track
374
+ * @returns {Promise<boolean>} True if playback started successfully
375
+ * @example
376
+ * await player.play("Never Gonna Give You Up", userId); // Search query
377
+ * await player.play("https://youtube.com/watch?v=dQw4w9WgXcQ", userId); // Direct URL
378
+ * await player.play("tts: Hello everyone!", userId); // Text-to-Speech
379
+ * await player.play(trackObject, userId); // Track object
380
+ * await player.play(searchResult, userId); // SearchResult object
381
+ * await player.play(null); // play from queue
382
+ */
383
+ async play(query: string | Track | SearchResult | null, requestedBy?: string): Promise<boolean> {
384
+ const debugInfo =
385
+ query === null ? "null"
386
+ : typeof query === "string" ? query
387
+ : "tracks" in query ? `${query.tracks.length} tracks`
388
+ : query.title || "unknown";
389
+ this.debug(`[Player] Play called with query: ${debugInfo}`);
390
+ this.clearLeaveTimeout();
391
+ let tracksToAdd: Track[] = [];
392
+ let isPlaylist = false;
393
+ let effectiveRequest: ExtensionPlayRequest = { query: query as string | Track, requestedBy };
394
+ let hookResponse: ExtensionPlayResponse = {};
395
+
396
+ try {
397
+ // Handle null query - play from queue
398
+ if (query === null) {
399
+ this.debug(`[Player] Play from queue requested`);
400
+ if (this.queue.isEmpty) {
401
+ this.debug(`[Player] Queue is empty, nothing to play`);
402
+ return false;
403
+ }
404
+
405
+ if (!this.isPlaying) {
406
+ return await this.playNext();
407
+ }
408
+ return true;
409
+ }
410
+
411
+ // Handle SearchResult
412
+ if (query && typeof query === "object" && "tracks" in query && Array.isArray(query.tracks)) {
413
+ this.debug(`[Player] Playing SearchResult with ${query.tracks.length} tracks`);
414
+ tracksToAdd = query.tracks;
415
+ isPlaylist = !!query.playlist || query.tracks.length > 1;
416
+
417
+ if (query.playlist) {
418
+ this.debug(`[Player] Added playlist: ${query.playlist.name} (${tracksToAdd.length} tracks)`);
419
+ }
420
+ } else {
421
+ // Handle other types (string, Track)
422
+ const hookOutcome = await this.extensionManager.BeforePlayHooks(effectiveRequest);
423
+ effectiveRequest = hookOutcome.request;
424
+ hookResponse = hookOutcome.response;
425
+ if (effectiveRequest.requestedBy === undefined) {
426
+ effectiveRequest.requestedBy = requestedBy;
427
+ }
428
+
429
+ const hookTracks = Array.isArray(hookResponse.tracks) ? hookResponse.tracks : undefined;
430
+
431
+ if (hookResponse.handled && (!hookTracks || hookTracks.length === 0)) {
432
+ const handledPayload: ExtensionAfterPlayPayload = {
433
+ success: hookResponse.success ?? true,
434
+ query: effectiveRequest.query,
435
+ requestedBy: effectiveRequest.requestedBy,
436
+ tracks: [],
437
+ isPlaylist: hookResponse.isPlaylist ?? false,
438
+ error: hookResponse.error,
439
+ };
440
+ await this.extensionManager.AfterPlayHooks(handledPayload);
441
+ if (hookResponse.error) {
442
+ this.emit("playerError", hookResponse.error);
443
+ }
444
+ return hookResponse.success ?? true;
445
+ }
446
+
447
+ if (hookTracks && hookTracks.length > 0) {
448
+ tracksToAdd = hookTracks;
449
+ isPlaylist = hookResponse.isPlaylist ?? hookTracks.length > 1;
450
+ } else if (typeof effectiveRequest.query === "string") {
451
+ const searchResult = await this.search(effectiveRequest.query, effectiveRequest.requestedBy || "Unknown");
452
+ tracksToAdd = searchResult.tracks;
453
+ if (searchResult.playlist) {
454
+ isPlaylist = true;
455
+ this.debug(`[Player] Added playlist: ${searchResult.playlist.name} (${tracksToAdd.length} tracks)`);
456
+ }
457
+ } else if (effectiveRequest.query) {
458
+ tracksToAdd = [effectiveRequest.query as Track];
459
+ }
460
+ }
461
+
462
+ if (tracksToAdd.length === 0) {
463
+ this.debug(`[Player] No tracks found for play`);
464
+ throw new Error("No tracks found");
465
+ }
466
+
467
+ const isTTS = (t: Track | undefined) => {
468
+ if (!t) return false;
469
+ try {
470
+ return typeof t.source === "string" && t.source.toLowerCase().includes("tts");
471
+ } catch {
472
+ return false;
473
+ }
474
+ };
475
+
476
+ const queryLooksTTS =
477
+ typeof effectiveRequest.query === "string" && effectiveRequest.query.trim().toLowerCase().startsWith("tts");
478
+
479
+ if (
480
+ !isPlaylist &&
481
+ tracksToAdd.length > 0 &&
482
+ this.options?.tts?.interrupt !== false &&
483
+ (isTTS(tracksToAdd[0]) || queryLooksTTS)
484
+ ) {
485
+ this.debug(`[Player] Interrupting with TTS: ${tracksToAdd[0].title}`);
486
+ await this.interruptWithTTSTrack(tracksToAdd[0]);
487
+ await this.extensionManager.AfterPlayHooks({
488
+ success: true,
489
+ query: effectiveRequest.query,
490
+ requestedBy: effectiveRequest.requestedBy,
491
+ tracks: tracksToAdd,
492
+ isPlaylist,
493
+ });
494
+ return true;
495
+ }
496
+
497
+ if (isPlaylist) {
498
+ this.queue.addMultiple(tracksToAdd);
499
+ this.emit("queueAddList", tracksToAdd);
500
+ } else {
501
+ this.queue.add(tracksToAdd[0]);
502
+ this.emit("queueAdd", tracksToAdd[0]);
503
+ }
504
+
505
+ const started = !this.isPlaying ? await this.playNext() : true;
506
+
507
+ await this.extensionManager.AfterPlayHooks({
508
+ success: started,
509
+ query: effectiveRequest.query,
510
+ requestedBy: effectiveRequest.requestedBy,
511
+ tracks: tracksToAdd,
512
+ isPlaylist,
513
+ });
514
+
515
+ return started;
516
+ } catch (error) {
517
+ await this.extensionManager.AfterPlayHooks({
518
+ success: false,
519
+ query: effectiveRequest.query,
520
+ requestedBy: effectiveRequest.requestedBy,
521
+ tracks: tracksToAdd,
522
+ isPlaylist,
523
+ error: error as Error,
524
+ });
525
+ this.debug(`[Player] Play error:`, error);
526
+ this.emit("playerError", error as Error);
527
+ return false;
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Create AudioResource with filters and seek applied
533
+ *
534
+ * @param {StreamInfo} streamInfo - The stream information
535
+ * @param {Track} track - The track being processed
536
+ * @param {number} position - Position in milliseconds to seek to (0 = no seek)
537
+ * @returns {Promise<AudioResource>} The AudioResource with filters and seek applied
538
+ */
539
+ private async createResource(streamInfo: StreamInfo, track: Track, position: number = 0): Promise<AudioResource> {
540
+ const filterString = this.filter.getFilterString();
541
+
542
+ this.debug(`[Player] Creating AudioResource with filters: ${filterString || "none"}, seek: ${position}ms`);
543
+
544
+ try {
545
+ let stream: Readable = streamInfo.stream;
546
+ // Apply filters and seek if needed
547
+ if (filterString || position > 0) {
548
+ stream = await this.filter.applyFiltersAndSeek(streamInfo.stream, position);
549
+ streamInfo.type = StreamType.Arbitrary;
550
+ }
551
+
552
+ // Create AudioResource with better error handling
553
+ const resource = createAudioResource(stream, {
554
+ metadata: track,
555
+ inputType:
556
+ streamInfo.type === "webm/opus" ? StreamType.WebmOpus
557
+ : streamInfo.type === "ogg/opus" ? StreamType.OggOpus
558
+ : StreamType.Arbitrary,
559
+ inlineVolume: true,
560
+ });
561
+
562
+ return resource;
563
+ } catch (error) {
564
+ this.debug(`[Player] Error creating AudioResource with filters+seek:`, error);
565
+ // Fallback to basic AudioResource
566
+ try {
567
+ const resource = createAudioResource(streamInfo.stream, {
568
+ metadata: track,
569
+ inputType:
570
+ streamInfo.type === "webm/opus" ? StreamType.WebmOpus
571
+ : streamInfo.type === "ogg/opus" ? StreamType.OggOpus
572
+ : StreamType.Arbitrary,
573
+ inlineVolume: true,
574
+ });
575
+ return resource;
576
+ } catch (fallbackError) {
577
+ this.debug(`[Player] Fallback AudioResource creation failed:`, fallbackError);
578
+ throw fallbackError;
579
+ }
580
+ }
581
+ }
582
+ private async getStream(track: Track): Promise<StreamInfo | null> {
583
+ let stream = await this.extensionManager.provideStream(track);
584
+ if (stream?.stream) return stream;
585
+ stream = await this.pluginManager.getStream(track);
586
+ if (stream?.stream) return stream;
587
+ throw new Error(`No stream available for track: ${track.title}`);
588
+ }
589
+
590
+ /**
591
+ * Start playing a specific track immediately, replacing the current resource.
592
+ */
593
+ private async startTrack(track: Track): Promise<boolean> {
594
+ try {
595
+ let streamInfo: StreamInfo | null = await this.getStream(track);
596
+ this.debug(`[Player] Using stream for track: ${track.title}`);
597
+ // Kiểm tra nếu có stream thực sự để tạo AudioResource
598
+ if (streamInfo && (streamInfo as any).stream) {
599
+ try {
600
+ // Destroy the old stream and resource before creating a new one
601
+ this.destroyCurrentStream();
602
+
603
+ this.currentResource = await this.createResource(streamInfo, track, 0);
604
+ if (this.volumeInterval) {
605
+ clearInterval(this.volumeInterval);
606
+ this.volumeInterval = null;
607
+ }
608
+ this.currentResource.volume?.setVolume(this.volume / 100);
609
+
610
+ this.debug(`[Player] Playing resource for track: ${track.title}`);
611
+ this.audioPlayer.play(this.currentResource);
612
+
613
+ await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 5_000);
614
+ return true;
615
+ } catch (resourceError) {
616
+ this.debug(`[Player] Error creating/playing resource:`, resourceError);
617
+ // Try fallback without filters
618
+ try {
619
+ this.debug(`[Player] Attempting fallback without filters`);
620
+ const fallbackResource = createAudioResource(streamInfo.stream, {
621
+ metadata: track,
622
+ inputType:
623
+ streamInfo.type === "webm/opus" ? StreamType.WebmOpus
624
+ : streamInfo.type === "ogg/opus" ? StreamType.OggOpus
625
+ : StreamType.Arbitrary,
626
+ inlineVolume: true,
627
+ });
628
+
629
+ this.currentResource = fallbackResource;
630
+ this.currentResource.volume?.setVolume(this.volume / 100);
631
+ this.audioPlayer.play(this.currentResource);
632
+ await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 5_000);
633
+ return true;
634
+ } catch (fallbackError) {
635
+ this.debug(`[Player] Fallback also failed:`, fallbackError);
636
+ throw fallbackError;
637
+ }
638
+ }
639
+ } else if (streamInfo && !(streamInfo as any).stream) {
640
+ // Extension đang xử lý phát nhạc (như Lavalink) - chỉ đánh dấu đang phát
641
+ this.debug(`[Player] Extension is handling playback for track: ${track.title}`);
642
+ this.isPlaying = true;
643
+ this.isPaused = false;
644
+ this.emit("trackStart", track);
645
+ return true;
646
+ } else {
647
+ throw new Error(`No stream available for track: ${track.title}`);
648
+ }
649
+ } catch (error) {
650
+ this.debug(`[Player] startTrack error:`, error);
651
+ this.emit("playerError", error as Error, track);
652
+ return false;
653
+ }
654
+ }
655
+
656
+ private async playNext(): Promise<boolean> {
657
+ this.debug(`[Player] playNext called by ${new Error().stack?.split("\n")[2]?.trim()}`);
658
+ while (true) {
659
+ const track = this.queue.next(this.skipLoop);
660
+ this.skipLoop = false;
661
+
662
+ if (!track) {
663
+ if (this.queue.autoPlay()) {
664
+ const willnext = this.queue.willNextTrack();
665
+ if (willnext) {
666
+ this.queue.addMultiple([willnext]);
667
+ continue;
668
+ }
669
+ }
670
+ this.debug(`[Player] No next track in queue`);
671
+ this.isPlaying = false;
672
+ this.emit("queueEnd");
673
+
674
+ if (this.options.leaveOnEnd) {
675
+ this.scheduleLeave();
676
+ }
677
+
678
+ return false;
679
+ }
680
+
681
+ this.generateWillNext();
682
+ this.clearLeaveTimeout();
683
+ this.debug(`[Player] playNext called for track: ${track.title}`);
684
+
685
+ try {
686
+ return await this.startTrack(track);
687
+ } catch (err) {
688
+ this.debug(`[Player] playNext error:`, err);
689
+ this.emit("playerError", err as Error, track);
690
+ continue;
691
+ }
692
+ }
693
+ }
694
+
695
+ //#endregion
696
+ //#region TTS
697
+
698
+ private ensureTTSPlayer(): DiscordAudioPlayer {
699
+ if (this.ttsPlayer) return this.ttsPlayer;
700
+ this.ttsPlayer = createAudioPlayer({
701
+ behaviors: {
702
+ noSubscriber: NoSubscriberBehavior.Pause,
703
+ maxMissedFrames: 100,
704
+ },
705
+ });
706
+ this.ttsPlayer.on("error", (e) => this.debug("[TTS] error:", e));
707
+ return this.ttsPlayer;
708
+ }
709
+ /**
710
+ * Interrupt current music with a TTS track. Pauses music, swaps the
711
+ * subscription to a dedicated TTS player, plays TTS, then resumes.
712
+ *
713
+ * @param {Track} track - The track to interrupt with
714
+ * @returns {Promise<void>}
715
+ * @example
716
+ * await player.interruptWithTTSTrack(track);
717
+ */
718
+ public async interruptWithTTSTrack(track: Track): Promise<void> {
719
+ const wasPlaying =
720
+ this.audioPlayer.state.status === AudioPlayerStatus.Playing ||
721
+ this.audioPlayer.state.status === AudioPlayerStatus.Buffering;
722
+
723
+ let ttsResource: AudioResource | null = null;
724
+ let ttsStream: any = null;
725
+
726
+ try {
727
+ if (!this.connection) throw new Error("No voice connection for TTS");
728
+ const ttsPlayer = this.ensureTTSPlayer();
729
+
730
+ // Build resource from plugin stream
731
+ const streamInfo = await this.pluginManager.getStream(track);
732
+ if (!streamInfo) {
733
+ throw new Error("No stream available for track: ${track.title}");
734
+ }
735
+ ttsStream = streamInfo.stream;
736
+ const resource = await this.createResource(streamInfo as StreamInfo, track);
737
+ if (!resource) {
738
+ throw new Error("No resource available for track: ${track.title}");
739
+ }
740
+ ttsResource = resource;
741
+ if (resource.volume) {
742
+ resource.volume.setVolume((this.options?.tts?.volume ?? this?.volume ?? 100) / 100);
743
+ }
744
+
745
+ // Pause current music if any
746
+ try {
747
+ this.pause();
748
+ } catch {}
749
+
750
+ // Swap subscription and play TTS
751
+ this.connection.subscribe(ttsPlayer);
752
+ this.emit("ttsStart", { track });
753
+ ttsPlayer.play(resource);
754
+
755
+ // Wait until TTS starts then finishes
756
+ await entersState(ttsPlayer, AudioPlayerStatus.Playing, 5_000).catch(() => null);
757
+ // Derive timeoutMs from resource/track duration when available, with a sensible cap
758
+ const md: any = (resource as any)?.metadata ?? {};
759
+ const declared =
760
+ typeof md.duration === "number" ? md.duration
761
+ : typeof track?.duration === "number" ? track.duration
762
+ : undefined;
763
+ const declaredMs =
764
+ declared ?
765
+ declared > 1000 ?
766
+ declared
767
+ : declared * 1000
768
+ : undefined;
769
+ const cap = this.options?.tts?.Max_Time_TTS ?? 60_000;
770
+ const idleTimeout = declaredMs ? Math.min(cap, Math.max(1_000, declaredMs + 1_500)) : cap;
771
+ await entersState(ttsPlayer, AudioPlayerStatus.Idle, idleTimeout).catch(() => null);
772
+
773
+ // Swap back and resume if needed
774
+ this.connection.subscribe(this.audioPlayer);
775
+ } catch (err) {
776
+ this.debug("[TTS] error while playing:", err);
777
+ this.emit("playerError", err as Error);
778
+ } finally {
779
+ // Clean up TTS stream and resource
780
+ try {
781
+ if (ttsStream && typeof ttsStream.destroy === "function") {
782
+ ttsStream.destroy();
783
+ }
784
+ } catch (error) {
785
+ this.debug("[TTS] Error destroying stream:", error);
786
+ }
787
+
788
+ if (wasPlaying) {
789
+ try {
790
+ this.resume();
791
+ } catch {}
792
+ }
793
+ this.emit("ttsEnd");
794
+ }
795
+ }
796
+
797
+ //#endregion
798
+ //#region Player Function
799
+
800
+ /**
801
+ * Connect to a voice channel
802
+ *
803
+ * @param {VoiceChannel} channel - Discord voice channel
804
+ * @returns {Promise<VoiceConnection>} The voice connection
805
+ * @example
806
+ * await player.connect(voiceChannel);
807
+ */
808
+ async connect(channel: VoiceChannel): Promise<VoiceConnection> {
809
+ try {
810
+ this.debug(`[Player] Connecting to voice channel: ${channel.id}`);
811
+ const connection = joinVoiceChannel({
812
+ channelId: channel.id,
813
+ guildId: channel.guildId,
814
+ adapterCreator: channel.guild.voiceAdapterCreator as any,
815
+ selfDeaf: this.options.selfDeaf ?? true,
816
+ selfMute: this.options.selfMute ?? false,
817
+ });
818
+
819
+ await entersState(connection, VoiceConnectionStatus.Ready, 50_000);
820
+ this.connection = connection;
821
+
822
+ connection.on(VoiceConnectionStatus.Disconnected, () => {
823
+ this.debug(`[Player] VoiceConnectionStatus.Disconnected`);
824
+ this.destroy();
825
+ });
826
+
827
+ connection.on("error", (error) => {
828
+ this.debug(`[Player] Voice connection error:`, error);
829
+ this.emit("connectionError", error);
830
+ });
831
+ connection.subscribe(this.audioPlayer);
832
+
833
+ this.clearLeaveTimeout();
834
+ return this.connection;
835
+ } catch (error) {
836
+ this.debug(`[Player] Connection error:`, error);
837
+ this.emit("connectionError", error as Error);
838
+ this.connection?.destroy();
839
+ throw error;
840
+ }
841
+ }
842
+
843
+ /**
844
+ * Pause the current track
845
+ *
846
+ * @returns {boolean} True if paused successfully
847
+ * @example
848
+ * const paused = player.pause();
849
+ * console.log(`Paused: ${paused}`);
850
+ */
851
+ pause(): boolean {
852
+ this.debug(`[Player] pause called`);
853
+ if (this.isPlaying && !this.isPaused) {
854
+ return this.audioPlayer.pause();
855
+ }
856
+ return false;
857
+ }
858
+
859
+ /**
860
+ * Resume the current track
861
+ *
862
+ * @returns {boolean} True if resumed successfully
863
+ * @example
864
+ * const resumed = player.resume();
865
+ * console.log(`Resumed: ${resumed}`);
866
+ */
867
+ resume(): boolean {
868
+ this.debug(`[Player] resume called`);
869
+ if (this.isPaused) {
870
+ const result = this.audioPlayer.unpause();
871
+ if (result) {
872
+ const track = this.queue.currentTrack;
873
+ if (track) {
874
+ this.debug(`[Player] Player resumed on track: ${track.title}`);
875
+ this.emit("playerResume", track);
876
+ }
877
+ }
878
+ return result;
879
+ }
880
+ return false;
881
+ }
882
+
883
+ /**
884
+ * Stop the current track
885
+ *
886
+ * @returns {boolean} True if stopped successfully
887
+ * @example
888
+ * const stopped = player.stop();
889
+ * console.log(`Stopped: ${stopped}`);
890
+ */
891
+ stop(): boolean {
892
+ this.debug(`[Player] stop called`);
893
+ this.queue.clear();
894
+ const result = this.audioPlayer.stop();
895
+ this.destroyCurrentStream();
896
+ this.currentResource = null;
897
+
898
+ this.isPlaying = false;
899
+ this.isPaused = false;
900
+ this.emit("playerStop");
901
+ return result;
902
+ }
903
+
904
+ /**
905
+ * Seek to a specific position in the current track
906
+ *
907
+ * @param {number} position - Position in milliseconds to seek to
908
+ * @returns {Promise<boolean>} True if seek was successful
909
+ * @example
910
+ * // Seek to 30 seconds (30000ms)
911
+ * const success = await player.seek(30000);
912
+ * console.log(`Seek successful: ${success}`);
913
+ *
914
+ * // Seek to 1 minute 30 seconds (90000ms)
915
+ * await player.seek(90000);
916
+ */
917
+ async seek(position: number): Promise<boolean> {
918
+ this.debug(`[Player] seek called with position: ${position}ms`);
919
+
920
+ const track = this.queue.currentTrack;
921
+ if (!track) {
922
+ this.debug(`[Player] No current track to seek`);
923
+ return false;
924
+ }
925
+
926
+ const totalDuration = track.duration > 1000 ? track.duration : track.duration * 1000;
927
+ if (position < 0 || position > totalDuration) {
928
+ this.debug(`[Player] Invalid seek position: ${position}ms (track duration: ${totalDuration}ms)`);
929
+ return false;
930
+ }
931
+
932
+ const streaminfo = await this.getStream(track);
933
+ if (!streaminfo?.stream) {
934
+ this.debug(`[Player] No stream to seek`);
935
+ return false;
936
+ }
937
+
938
+ await this.refeshPlayerResource(true, position);
939
+
940
+ return true;
941
+ }
942
+
943
+ /**
944
+ * Skip to the next track or skip to a specific index
945
+ *
946
+ * @param {number} index - Optional index to skip to (0 = next track)
947
+ * @returns {boolean} True if skipped successfully
948
+ * @example
949
+ * const skipped = player.skip(); // Skip to next track
950
+ * const skippedToIndex = player.skip(2); // Skip to track at index 2
951
+ * console.log(`Skipped: ${skipped}`);
952
+ */
953
+ skip(index?: number): boolean {
954
+ this.debug(`[Player] skip called with index: ${index}`);
955
+ try {
956
+ if (typeof index === "number" && index >= 0) {
957
+ // Skip to specific index
958
+ const targetTrack = this.queue.getTrack(index);
959
+ if (!targetTrack) {
960
+ this.debug(`[Player] No track found at index ${index}`);
961
+ return false;
962
+ }
963
+
964
+ // Remove tracks from 0 to index-1
965
+ for (let i = 0; i < index; i++) {
966
+ this.queue.remove(0);
967
+ }
968
+
969
+ this.debug(`[Player] Skipped to track at index ${index}: ${targetTrack.title}`);
970
+ if (this.isPlaying || this.isPaused) {
971
+ this.skipLoop = true;
972
+ return this.audioPlayer.stop();
973
+ }
974
+ return true;
975
+ }
976
+
977
+ if (this.isPlaying || this.isPaused) {
978
+ this.skipLoop = true;
979
+ return this.audioPlayer.stop();
980
+ }
981
+
982
+ return true;
983
+ } catch (error) {
984
+ this.debug(`[Player] skip error:`, error);
985
+ return false;
986
+ }
987
+ }
988
+
989
+ /**
990
+ * Go back to the previous track in history and play it.
991
+ *
992
+ * @returns {Promise<boolean>} True if previous track was played successfully
993
+ * @example
994
+ * const previous = await player.previous();
995
+ * console.log(`Previous: ${previous}`);
996
+ */
997
+ async previous(): Promise<boolean> {
998
+ this.debug(`[Player] previous called`);
999
+ const track = this.queue.previous();
1000
+ if (!track) return false;
1001
+ if (this.queue.currentTrack) this.insert(this.queue.currentTrack, 0);
1002
+ this.clearLeaveTimeout();
1003
+ return this.startTrack(track);
1004
+ }
1005
+
1006
+ /**
1007
+ * Save a track's stream to a file and return a Readable stream
1008
+ *
1009
+ * @param {Track} track - The track to save
1010
+ * @param {SaveOptions | string} options - Save options or filename string (for backward compatibility)
1011
+ * @returns {Promise<Readable>} A Readable stream containing the audio data
1012
+ * @example
1013
+ * // Save current track to file
1014
+ * const track = player.currentTrack;
1015
+ * if (track) {
1016
+ * const stream = await player.save(track);
1017
+ *
1018
+ * // Use fs to write the stream to file
1019
+ * const fs = require('fs');
1020
+ * const writeStream = fs.createWriteStream('saved-song.mp3');
1021
+ * stream.pipe(writeStream);
1022
+ *
1023
+ * writeStream.on('finish', () => {
1024
+ * console.log('File saved successfully!');
1025
+ * });
1026
+ * }
1027
+ *
1028
+ * // Save any track by URL
1029
+ * const searchResult = await player.search("Never Gonna Give You Up", userId);
1030
+ * if (searchResult.tracks.length > 0) {
1031
+ * const stream = await player.save(searchResult.tracks[0]);
1032
+ * // Handle the stream...
1033
+ * }
1034
+ *
1035
+ * // Backward compatibility - filename as string
1036
+ * const stream = await player.save(track, "my-song.mp3");
1037
+ */
1038
+ async save(track: Track, options?: SaveOptions | string): Promise<Readable> {
1039
+ this.debug(`[Player] save called for track: ${track.title}`);
1040
+
1041
+ // Parse options - support both SaveOptions object and filename string (backward compatibility)
1042
+ let saveOptions: SaveOptions = {};
1043
+ if (typeof options === "string") {
1044
+ saveOptions = { filename: options };
1045
+ } else if (options) {
1046
+ saveOptions = options;
1047
+ }
1048
+
1049
+ try {
1050
+ // Try extensions first
1051
+ let streamInfo: StreamInfo | null = await this.pluginManager.getStream(track);
1052
+
1053
+ if (!streamInfo || !streamInfo.stream) {
1054
+ throw new Error(`No save stream available for track: ${track.title}`);
1055
+ }
1056
+
1057
+ this.debug(`[Player] Save stream obtained for track: ${track.title}`);
1058
+ if (saveOptions.filename) {
1059
+ this.debug(`[Player] Save options - filename: ${saveOptions.filename}, quality: ${saveOptions.quality || "default"}`);
1060
+ }
1061
+
1062
+ // Apply filters if any are active
1063
+ let finalStream = streamInfo.stream;
1064
+
1065
+ if (saveOptions.filter || saveOptions.seek) {
1066
+ try {
1067
+ this.filter.clearAll();
1068
+ this.filter.applyFilters(saveOptions.filter || []);
1069
+ } catch (err) {
1070
+ this.debug(`[Player] Error applying save filters:`, err);
1071
+ }
1072
+
1073
+ this.debug(`[Player] Applying filters to save stream: ${this.filter.getFilterString() || "none"}`);
1074
+ finalStream = await this.filter.applyFiltersAndSeek(streamInfo.stream, saveOptions.seek || 0).catch((err) => {
1075
+ this.debug(`[Player] Error applying filters to save stream:`, err);
1076
+ return streamInfo!.stream; // Fallback to original stream
1077
+ });
1078
+ }
1079
+
1080
+ // Return the stream directly - caller can pipe it to fs.createWriteStream()
1081
+ return finalStream;
1082
+ } catch (error) {
1083
+ this.debug(`[Player] save error:`, error);
1084
+ this.emit("playerError", error as Error, track);
1085
+ throw error;
1086
+ }
1087
+ }
1088
+
1089
+ /**
1090
+ * Loop the current track or queue
1091
+ *
1092
+ * @param {LoopMode | number} mode - The loop mode to set ("off", "track", "queue") or number (0=off, 1=track, 2=queue)
1093
+ * @returns {LoopMode} The loop mode
1094
+ * @example
1095
+ * const loopMode = player.loop("track"); // Loop current track
1096
+ * const loopQueue = player.loop("queue"); // Loop entire queue
1097
+ * const loopTrack = player.loop(1); // Loop current track (same as "track")
1098
+ * const loopQueueNum = player.loop(2); // Loop entire queue (same as "queue")
1099
+ * const noLoop = player.loop("off"); // No loop
1100
+ * const noLoopNum = player.loop(0); // No loop (same as "off")
1101
+ * console.log(`Loop mode: ${loopMode}`);
1102
+ */
1103
+ loop(mode?: LoopMode | number): LoopMode {
1104
+ this.debug(`[Player] loop called with mode: ${mode}`);
1105
+
1106
+ if (typeof mode === "number") {
1107
+ // Number mode: convert to text mode
1108
+ switch (mode) {
1109
+ case 0:
1110
+ return this.queue.loop("off");
1111
+ case 1:
1112
+ return this.queue.loop("track");
1113
+ case 2:
1114
+ return this.queue.loop("queue");
1115
+ default:
1116
+ this.debug(`[Player] Invalid loop number: ${mode}, using "off"`);
1117
+ return this.queue.loop("off");
1118
+ }
1119
+ }
1120
+
1121
+ return this.queue.loop(mode as LoopMode);
1122
+ }
1123
+
1124
+ /**
1125
+ * Set the auto-play mode
1126
+ *
1127
+ * @param {boolean} mode - The auto-play mode to set
1128
+ * @returns {boolean} The auto-play mode
1129
+ * @example
1130
+ * const autoPlayMode = player.autoPlay(true);
1131
+ * console.log(`Auto-play mode: ${autoPlayMode}`);
1132
+ */
1133
+ autoPlay(mode?: boolean): boolean {
1134
+ return this.queue.autoPlay(mode);
1135
+ }
1136
+
1137
+ /**
1138
+ * Set the volume of the current track
1139
+ *
1140
+ * @param {number} volume - The volume to set
1141
+ * @returns {boolean} True if volume was set successfully
1142
+ * @example
1143
+ * const volumeSet = player.setVolume(50);
1144
+ * console.log(`Volume set: ${volumeSet}`);
1145
+ */
1146
+ setVolume(volume: number): boolean {
1147
+ this.debug(`[Player] setVolume called: ${volume}`);
1148
+ if (volume < 0 || volume > 200) return false;
1149
+
1150
+ const oldVolume = this.volume;
1151
+ this.volume = volume;
1152
+ const resourceVolume = this.currentResource?.volume;
1153
+
1154
+ if (resourceVolume) {
1155
+ if (this.volumeInterval) clearInterval(this.volumeInterval);
1156
+
1157
+ const start = resourceVolume.volume;
1158
+ const target = this.volume / 100;
1159
+ const steps = 10;
1160
+ let currentStep = 0;
1161
+
1162
+ this.volumeInterval = setInterval(() => {
1163
+ currentStep++;
1164
+ const value = start + ((target - start) * currentStep) / steps;
1165
+ resourceVolume.setVolume(value);
1166
+ if (currentStep >= steps) {
1167
+ clearInterval(this.volumeInterval!);
1168
+ this.volumeInterval = null;
1169
+ }
1170
+ }, 300);
1171
+ }
1172
+
1173
+ this.emit("volumeChange", oldVolume, volume);
1174
+ return true;
1175
+ }
1176
+
1177
+ /**
1178
+ * Shuffle the queue
1179
+ *
1180
+ * @returns {void}
1181
+ * @example
1182
+ * player.shuffle();
1183
+ */
1184
+ shuffle(): void {
1185
+ this.debug(`[Player] shuffle called`);
1186
+ this.queue.shuffle();
1187
+ }
1188
+
1189
+ /**
1190
+ * Clear the queue
1191
+ *
1192
+ * @returns {void}
1193
+ * @example
1194
+ * player.clearQueue();
1195
+ */
1196
+ clearQueue(): void {
1197
+ this.debug(`[Player] clearQueue called`);
1198
+ this.queue.clear();
1199
+ }
1200
+
1201
+ /**
1202
+ * Insert a track or list of tracks into the upcoming queue at a specific position (0 = play after current).
1203
+ * - If `query` is a string, performs a search and inserts resulting tracks (playlist supported).
1204
+ * - If a Track or Track[] is provided, inserts directly.
1205
+ * Does not auto-start playback; it only modifies the queue.
1206
+ *
1207
+ * @param {string | Track | Track[]} query - The track or tracks to insert
1208
+ * @param {number} index - The index to insert the tracks at
1209
+ * @param {string} requestedBy - The user ID who requested the insert
1210
+ * @returns {Promise<boolean>} True if the tracks were inserted successfully
1211
+ * @example
1212
+ * const inserted = await player.insert("Song Name", 0, userId);
1213
+ * console.log(`Inserted: ${inserted}`);
1214
+ */
1215
+ async insert(query: string | Track | Track[], index: number, requestedBy?: string): Promise<boolean> {
1216
+ try {
1217
+ this.debug(`[Player] insert called at index ${index} with type: ${typeof query}`);
1218
+ let tracksToAdd: Track[] = [];
1219
+ let isPlaylist = false;
1220
+
1221
+ if (typeof query === "string") {
1222
+ const searchResult = await this.search(query, requestedBy || "Unknown");
1223
+ tracksToAdd = searchResult.tracks || [];
1224
+ isPlaylist = !!searchResult.playlist;
1225
+ } else if (Array.isArray(query)) {
1226
+ tracksToAdd = query;
1227
+ isPlaylist = query.length > 1;
1228
+ } else if (query) {
1229
+ tracksToAdd = [query];
1230
+ }
1231
+
1232
+ if (!tracksToAdd || tracksToAdd.length === 0) {
1233
+ this.debug(`[Player] insert: no tracks resolved`);
1234
+ throw new Error("No tracks to insert");
1235
+ }
1236
+
1237
+ if (tracksToAdd.length === 1) {
1238
+ this.queue.insert(tracksToAdd[0], index);
1239
+ this.emit("queueAdd", tracksToAdd[0]);
1240
+ this.debug(`[Player] Inserted track at index ${index}: ${tracksToAdd[0].title}`);
1241
+ } else {
1242
+ this.queue.insertMultiple(tracksToAdd, index);
1243
+ this.emit("queueAddList", tracksToAdd);
1244
+ this.debug(`[Player] Inserted ${tracksToAdd.length} ${isPlaylist ? "playlist " : ""}tracks at index ${index}`);
1245
+ }
1246
+
1247
+ return true;
1248
+ } catch (error) {
1249
+ this.debug(`[Player] insert error:`, error);
1250
+ this.emit("playerError", error as Error);
1251
+ return false;
1252
+ }
1253
+ }
1254
+
1255
+ /**
1256
+ * Remove a track from the queue
1257
+ *
1258
+ * @param {number} index - The index of the track to remove
1259
+ * @returns {Track | null} The removed track or null
1260
+ * @example
1261
+ * const removed = player.remove(0);
1262
+ * console.log(`Removed: ${removed?.title}`);
1263
+ */
1264
+ remove(index: number): Track | null {
1265
+ this.debug(`[Player] remove called for index: ${index}`);
1266
+ const track = this.queue.remove(index);
1267
+ if (track) {
1268
+ this.emit("queueRemove", track, index);
1269
+ }
1270
+ return track;
1271
+ }
1272
+
1273
+ /**
1274
+ * Get the progress bar of the current track
1275
+ *
1276
+ * @param {ProgressBarOptions} options - The options for the progress bar
1277
+ * @returns {string} The progress bar
1278
+ * @example
1279
+ * const progressBar = player.getProgressBar();
1280
+ * console.log(`Progress bar: ${progressBar}`);
1281
+ */
1282
+ getProgressBar(options: ProgressBarOptions = {}): string {
1283
+ const { size = 20, barChar = "▬", progressChar = "🔘" } = options;
1284
+ const track = this.queue.currentTrack;
1285
+ const resource = this.currentResource;
1286
+ if (!track || !resource) return "";
1287
+
1288
+ const total = track.duration > 1000 ? track.duration : track.duration * 1000;
1289
+ if (!total) return this.formatTime(resource.playbackDuration);
1290
+
1291
+ const current = resource.playbackDuration;
1292
+ const ratio = Math.min(current / total, 1);
1293
+ const progress = Math.round(ratio * size);
1294
+ const bar = barChar.repeat(progress) + progressChar + barChar.repeat(size - progress);
1295
+
1296
+ return `${this.formatTime(current)} | ${bar} | ${this.formatTime(total)}`;
1297
+ }
1298
+
1299
+ /**
1300
+ * Get the time of the current track
1301
+ *
1302
+ * @returns {Object} The time of the current track
1303
+ * @example
1304
+ * const time = player.getTime();
1305
+ * console.log(`Time: ${time.current}`);
1306
+ */
1307
+ getTime() {
1308
+ const resource = this.currentResource;
1309
+ const track = this.queue.currentTrack;
1310
+ if (!track || !resource)
1311
+ return {
1312
+ current: 0,
1313
+ total: 0,
1314
+ format: "00:00",
1315
+ };
1316
+
1317
+ const total = track.duration > 1000 ? track.duration : track.duration * 1000;
1318
+
1319
+ return {
1320
+ current: resource?.playbackDuration,
1321
+ total: total,
1322
+ format: this.formatTime(resource.playbackDuration),
1323
+ };
1324
+ }
1325
+
1326
+ /**
1327
+ * Format the time in the format of HH:MM:SS
1328
+ *
1329
+ * @param {number} ms - The time in milliseconds
1330
+ * @returns {string} The formatted time
1331
+ * @example
1332
+ * const formattedTime = player.formatTime(1000);
1333
+ * console.log(`Formatted time: ${formattedTime}`);
1334
+ */
1335
+ formatTime(ms: number): string {
1336
+ const totalSeconds = Math.floor(ms / 1000);
1337
+ const hours = Math.floor(totalSeconds / 3600);
1338
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
1339
+ const seconds = totalSeconds % 60;
1340
+ const parts: string[] = [];
1341
+ if (hours > 0) parts.push(String(hours).padStart(2, "0"));
1342
+ parts.push(String(minutes).padStart(2, "0"));
1343
+ parts.push(String(seconds).padStart(2, "0"));
1344
+ return parts.join(":");
1345
+ }
1346
+
1347
+ /**
1348
+ * Destroy the player
1349
+ *
1350
+ * @returns {void}
1351
+ * @example
1352
+ * player.destroy();
1353
+ */
1354
+ destroy(): void {
1355
+ this.debug(`[Player] destroy called`);
1356
+ if (this.leaveTimeout) {
1357
+ clearTimeout(this.leaveTimeout);
1358
+ this.leaveTimeout = null;
1359
+ }
1360
+
1361
+ // Destroy current stream before stopping audio
1362
+ this.destroyCurrentStream();
1363
+
1364
+ this.audioPlayer.removeAllListeners();
1365
+ this.audioPlayer.stop(true);
1366
+
1367
+ if (this.ttsPlayer) {
1368
+ try {
1369
+ this.ttsPlayer.stop(true);
1370
+ } catch {}
1371
+ this.ttsPlayer = null;
1372
+ }
1373
+
1374
+ if (this.connection) {
1375
+ this.connection.destroy();
1376
+ this.connection = null;
1377
+ }
1378
+
1379
+ this.queue.clear();
1380
+ this.pluginManager.clear();
1381
+ this.filter.destroy();
1382
+ this.extensionManager.destroy();
1383
+ this.isPlaying = false;
1384
+ this.isPaused = false;
1385
+
1386
+ // Clear any remaining intervals
1387
+ if (this.volumeInterval) {
1388
+ clearInterval(this.volumeInterval);
1389
+ this.volumeInterval = null;
1390
+ }
1391
+
1392
+ this.emit("playerDestroy");
1393
+ this.removeAllListeners();
1394
+ }
1395
+
1396
+ //#endregion
1397
+ //#region utils
1398
+ private scheduleLeave(): void {
1399
+ this.debug(`[Player] scheduleLeave called`);
1400
+ if (this.leaveTimeout) {
1401
+ clearTimeout(this.leaveTimeout);
1402
+ }
1403
+
1404
+ if (this.options.leaveOnEmpty && this.options.leaveTimeout) {
1405
+ this.leaveTimeout = setTimeout(() => {
1406
+ this.debug(`[Player] Leaving voice channel after timeoutMs`);
1407
+ this.destroy();
1408
+ }, this.options.leaveTimeout);
1409
+ }
1410
+ }
1411
+
1412
+ /**
1413
+ * Refesh player resource (apply filter)
1414
+ *
1415
+ * @param {boolean} applyToCurrent - Apply filter for curent track
1416
+ * @param {number} position - Position to seek to in milliseconds
1417
+ * @returns {Promise<boolean>}
1418
+ * @example
1419
+ * const refreshed = await player.refeshPlayerResource(true, 1000);
1420
+ * console.log(`Refreshed: ${refreshed}`);
1421
+ */
1422
+ public async refeshPlayerResource(applyToCurrent: boolean = true, position: number = -1): Promise<boolean> {
1423
+ if (!applyToCurrent || !this.queue.currentTrack || !(this.isPlaying || this.isPaused)) {
1424
+ return false;
1425
+ }
1426
+
1427
+ try {
1428
+ const track = this.queue.currentTrack;
1429
+ this.debug(`[Player] Refreshing player resource for track: ${track.title}`);
1430
+
1431
+ // Get current position for seeking
1432
+ const currentPosition = position > 0 ? position : this.currentResource?.playbackDuration || 0;
1433
+
1434
+ const streaminfo = await this.getStream(track);
1435
+ if (!streaminfo?.stream) {
1436
+ this.debug(`[Player] No stream to refresh`);
1437
+ return false;
1438
+ }
1439
+
1440
+ // Create AudioResource with filters and seek to current position
1441
+ const resource = await this.createResource(streaminfo, track, currentPosition);
1442
+
1443
+ // Stop current playback and destroy old resource/stream
1444
+ const wasPlaying = this.isPlaying;
1445
+ const wasPaused = this.isPaused;
1446
+
1447
+ this.audioPlayer.stop();
1448
+
1449
+ // Properly destroy the old resource and stream
1450
+ try {
1451
+ if (this.currentResource) {
1452
+ const oldStream = (this.currentResource as any)._readableState?.stream || (this.currentResource as any).stream;
1453
+ if (oldStream && typeof oldStream.destroy === "function") {
1454
+ oldStream.destroy();
1455
+ }
1456
+ }
1457
+ } catch (error) {
1458
+ this.debug(`[Player] Error destroying old stream in refeshPlayerResource:`, error);
1459
+ }
1460
+
1461
+ this.currentResource = resource;
1462
+
1463
+ // Subscribe to new resource
1464
+ if (this.connection) {
1465
+ this.connection.subscribe(this.audioPlayer);
1466
+ this.audioPlayer.play(resource);
1467
+ }
1468
+
1469
+ // Restore playing state
1470
+ if (wasPlaying && !wasPaused) {
1471
+ this.isPlaying = true;
1472
+ this.isPaused = false;
1473
+ } else if (wasPaused) {
1474
+ this.isPlaying = false;
1475
+ this.isPaused = true;
1476
+ this.audioPlayer.pause();
1477
+ }
1478
+
1479
+ this.debug(`[Player] Successfully applied filter to current track at position ${currentPosition}ms`);
1480
+ return true;
1481
+ } catch (error) {
1482
+ this.debug(`[Player] Error applying filter to current track:`, error);
1483
+ // Filter was still added to active filters, so return true
1484
+ return true;
1485
+ }
1486
+ }
1487
+
1488
+ /**
1489
+ * Attach an extension to the player
1490
+ *
1491
+ * @param {BaseExtension} extension - The extension to attach
1492
+ * @example
1493
+ * player.attachExtension(new MyExtension());
1494
+ */
1495
+ public attachExtension(extension: BaseExtension): void {
1496
+ this.extensionManager.register(extension);
1497
+ }
1498
+
1499
+ /**
1500
+ * Detach an extension from the player
1501
+ *
1502
+ * @param {BaseExtension} extension - The extension to detach
1503
+ * @example
1504
+ * player.detachExtension(new MyExtension());
1505
+ */
1506
+ public detachExtension(extension: BaseExtension): void {
1507
+ this.extensionManager.unregister(extension);
1508
+ }
1509
+
1510
+ /**
1511
+ * Get all extensions attached to the player
1512
+ *
1513
+ * @returns {readonly BaseExtension[]} All attached extensions
1514
+ * @example
1515
+ * const extensions = player.getExtensions();
1516
+ * console.log(`Extensions: ${extensions.length}`);
1517
+ */
1518
+ public getExtensions(): readonly BaseExtension[] {
1519
+ return this.extensionManager.getAll();
1520
+ }
1521
+
1522
+ private clearLeaveTimeout(): void {
1523
+ if (this.leaveTimeout) {
1524
+ clearTimeout(this.leaveTimeout);
1525
+ this.leaveTimeout = null;
1526
+ this.debug(`[Player] Cleared leave timeoutMs`);
1527
+ }
1528
+ }
1529
+
1530
+ private debug(message?: any, ...optionalParams: any[]): void {
1531
+ if (this.listenerCount("debug") > 0) {
1532
+ this.emit("debug", message, ...optionalParams);
1533
+ }
1534
+ }
1535
+
1536
+ private setupEventListeners(): void {
1537
+ this.audioPlayer.on("stateChange", (oldState, newState) => {
1538
+ this.debug(`[Player] AudioPlayer stateChange from ${oldState.status} to ${newState.status}`);
1539
+ if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) {
1540
+ // Track ended
1541
+ const track = this.queue.currentTrack;
1542
+ if (track) {
1543
+ this.debug(`[Player] Track ended: ${track.title}`);
1544
+ this.emit("trackEnd", track);
1545
+ }
1546
+ this.playNext();
1547
+ } else if (
1548
+ newState.status === AudioPlayerStatus.Playing &&
1549
+ (oldState.status === AudioPlayerStatus.Idle || oldState.status === AudioPlayerStatus.Buffering)
1550
+ ) {
1551
+ // Track started
1552
+ this.clearLeaveTimeout();
1553
+ this.isPlaying = true;
1554
+ this.isPaused = false;
1555
+ const track = this.queue.currentTrack;
1556
+ if (track) {
1557
+ this.debug(`[Player] Track started: ${track.title}`);
1558
+ this.emit("trackStart", track);
1559
+ }
1560
+ } else if (newState.status === AudioPlayerStatus.Paused && oldState.status !== AudioPlayerStatus.Paused) {
1561
+ // Track paused
1562
+ this.isPaused = true;
1563
+ const track = this.queue.currentTrack;
1564
+ if (track) {
1565
+ this.debug(`[Player] Player paused on track: ${track.title}`);
1566
+ this.emit("playerPause", track);
1567
+ }
1568
+ } else if (newState.status !== AudioPlayerStatus.Paused && oldState.status === AudioPlayerStatus.Paused) {
1569
+ // Track resumed
1570
+ this.isPaused = false;
1571
+ const track = this.queue.currentTrack;
1572
+ if (track) {
1573
+ this.debug(`[Player] Player resumed on track: ${track.title}`);
1574
+ this.emit("playerResume", track);
1575
+ }
1576
+ } else if (newState.status === AudioPlayerStatus.AutoPaused) {
1577
+ this.debug(`[Player] AudioPlayerStatus.AutoPaused`);
1578
+ } else if (newState.status === AudioPlayerStatus.Buffering) {
1579
+ this.debug(`[Player] AudioPlayerStatus.Buffering`);
1580
+ }
1581
+ });
1582
+ this.audioPlayer.on("error", (error) => {
1583
+ this.debug(`[Player] AudioPlayer error:`, error);
1584
+ this.emit("playerError", error, this.queue.currentTrack || undefined);
1585
+ this.playNext();
1586
+ });
1587
+
1588
+ this.audioPlayer.on("debug", (...args) => {
1589
+ if (this.manager.debugEnabled) {
1590
+ this.emit("debug", ...args);
1591
+ }
1592
+ });
1593
+ }
1594
+
1595
+ addPlugin(plugin: SourcePlugin): void {
1596
+ this.debug(`[Player] Adding plugin: ${plugin.name}`);
1597
+ this.pluginManager.register(plugin);
1598
+ }
1599
+
1600
+ removePlugin(name: string): boolean {
1601
+ this.debug(`[Player] Removing plugin: ${name}`);
1602
+ return this.pluginManager.unregister(name);
1603
+ }
1604
+
1605
+ //#endregion
1606
+ //#region Getters
1607
+
1608
+ /**
1609
+ * Get the size of the queue
1610
+ *
1611
+ * @returns {number} The size of the queue
1612
+ * @example
1613
+ * const queueSize = player.queueSize;
1614
+ * console.log(`Queue size: ${queueSize}`);
1615
+ */
1616
+ get queueSize(): number {
1617
+ return this.queue.size;
1618
+ }
1619
+
1620
+ /**
1621
+ * Get the current track
1622
+ *
1623
+ * @returns {Track | null} The current track or null
1624
+ * @example
1625
+ * const currentTrack = player.currentTrack;
1626
+ * console.log(`Current track: ${currentTrack?.title}`);
1627
+ */
1628
+ get currentTrack(): Track | null {
1629
+ return this.queue.currentTrack;
1630
+ }
1631
+
1632
+ /**
1633
+ * Get the previous track
1634
+ *
1635
+ * @returns {Track | null} The previous track or null
1636
+ * @example
1637
+ * const previousTrack = player.previousTrack;
1638
+ * console.log(`Previous track: ${previousTrack?.title}`);
1639
+ */
1640
+ get previousTrack(): Track | null {
1641
+ return this.queue.previousTracks?.at(-1) ?? null;
1642
+ }
1643
+
1644
+ /**
1645
+ * Get the upcoming tracks
1646
+ *
1647
+ * @returns {Track[]} The upcoming tracks
1648
+ * @example
1649
+ * const upcomingTracks = player.upcomingTracks;
1650
+ * console.log(`Upcoming tracks: ${upcomingTracks.length}`);
1651
+ */
1652
+ get upcomingTracks(): Track[] {
1653
+ return this.queue.getTracks();
1654
+ }
1655
+
1656
+ /**
1657
+ * Get the previous tracks
1658
+ *
1659
+ * @returns {Track[]} The previous tracks
1660
+ * @example
1661
+ * const previousTracks = player.previousTracks;
1662
+ * console.log(`Previous tracks: ${previousTracks.length}`);
1663
+ */
1664
+ get previousTracks(): Track[] {
1665
+ return this.queue.previousTracks;
1666
+ }
1667
+
1668
+ /**
1669
+ * Get the available plugins
1670
+ *
1671
+ * @returns {string[]} The available plugins
1672
+ * @example
1673
+ * const availablePlugins = player.availablePlugins;
1674
+ * console.log(`Available plugins: ${availablePlugins.length}`);
1675
+ */
1676
+ get availablePlugins(): string[] {
1677
+ return this.pluginManager.getAll().map((p) => p.name);
1678
+ }
1679
+
1680
+ /**
1681
+ * Get the related tracks
1682
+ *
1683
+ * @returns {Track[] | null} The related tracks or null
1684
+ * @example
1685
+ * const relatedTracks = player.relatedTracks;
1686
+ * console.log(`Related tracks: ${relatedTracks?.length}`);
1687
+ */
1688
+ get relatedTracks(): Track[] | null {
1689
+ return this.queue.relatedTracks();
1690
+ }
1691
+
1692
+ //#endregion
1693
+ }