ziplayer 0.0.9 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,15 +15,70 @@ import {
15
15
 
16
16
  import { VoiceChannel } from "discord.js";
17
17
  import { Readable } from "stream";
18
- import { Track, PlayerOptions, PlayerEvents, SourcePlugin, SearchResult, ProgressBarOptions, LoopMode } from "../types";
18
+ import { BaseExtension } from "../extensions";
19
+ import {
20
+ Track,
21
+ PlayerOptions,
22
+ PlayerEvents,
23
+ SourcePlugin,
24
+ SearchResult,
25
+ ProgressBarOptions,
26
+ LoopMode,
27
+ StreamInfo,
28
+ } from "../types";
29
+ import type {
30
+ ExtensionContext,
31
+ ExtensionPlayRequest,
32
+ ExtensionPlayResponse,
33
+ ExtensionAfterPlayPayload,
34
+ ExtensionStreamRequest,
35
+ ExtensionSearchRequest,
36
+ } from "../types";
19
37
  import { Queue } from "./Queue";
20
38
  import { PluginManager } from "../plugins";
39
+ import { withTimeout } from "../utils/timeout";
21
40
  import type { PlayerManager } from "./PlayerManager";
22
41
  export declare interface Player {
23
42
  on<K extends keyof PlayerEvents>(event: K, listener: (...args: PlayerEvents[K]) => void): this;
24
43
  emit<K extends keyof PlayerEvents>(event: K, ...args: PlayerEvents[K]): boolean;
25
44
  }
26
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
+ */
27
82
  export class Player extends EventEmitter {
28
83
  public readonly guildId: string;
29
84
  public connection: VoiceConnection | null = null;
@@ -40,83 +95,264 @@ export class Player extends EventEmitter {
40
95
  private currentResource: AudioResource | null = null;
41
96
  private volumeInterval: NodeJS.Timeout | null = null;
42
97
  private skipLoop = false;
98
+ private extensions: BaseExtension[] = [];
99
+ private extensionContext!: ExtensionContext;
100
+
101
+ // Cache for plugin matching to improve performance
102
+ private pluginCache = new Map<string, SourcePlugin>();
103
+ private readonly PLUGIN_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
104
+ private pluginCacheTimestamps = new Map<string, number>();
43
105
 
44
106
  /**
45
- * Start playing a specific track immediately, replacing the current resource.
107
+ * Attach an extension to the player
108
+ *
109
+ * @param {BaseExtension} extension - The extension to attach
110
+ * @example
111
+ * player.attachExtension(new MyExtension());
46
112
  */
47
- private async startTrack(track: Track): Promise<boolean> {
113
+ public attachExtension(extension: BaseExtension): void {
114
+ if (this.extensions.includes(extension)) return;
115
+ if (!extension.player) extension.player = this;
116
+ this.extensions.push(extension);
117
+ this.invokeExtensionLifecycle(extension, "onRegister");
118
+ }
119
+
120
+ /**
121
+ * Detach an extension from the player
122
+ *
123
+ * @param {BaseExtension} extension - The extension to detach
124
+ * @example
125
+ * player.detachExtension(new MyExtension());
126
+ */
127
+ public detachExtension(extension: BaseExtension): void {
128
+ const index = this.extensions.indexOf(extension);
129
+ if (index === -1) return;
130
+ this.extensions.splice(index, 1);
131
+ this.invokeExtensionLifecycle(extension, "onDestroy");
132
+ if (extension.player === this) {
133
+ extension.player = null;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Get all extensions attached to the player
139
+ *
140
+ * @returns {readonly BaseExtension[]} All attached extensions
141
+ * @example
142
+ * const extensions = player.getExtensions();
143
+ * console.log(`Extensions: ${extensions.length}`);
144
+ */
145
+ public getExtensions(): readonly BaseExtension[] {
146
+ return this.extensions;
147
+ }
148
+
149
+ private invokeExtensionLifecycle(extension: BaseExtension, hook: "onRegister" | "onDestroy"): void {
150
+ const fn = (extension as any)[hook];
151
+ if (typeof fn !== "function") return;
48
152
  try {
49
- // Find plugin that can handle this track
50
- const plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
153
+ const result = fn.call(extension, this.extensionContext);
154
+ if (result && typeof (result as Promise<unknown>).then === "function") {
155
+ (result as Promise<unknown>).catch((err) => this.debug(`[Player] Extension ${extension.name} ${hook} error:`, err));
156
+ }
157
+ } catch (err) {
158
+ this.debug(`[Player] Extension ${extension.name} ${hook} error:`, err);
159
+ }
160
+ }
161
+
162
+ private async runBeforePlayHooks(
163
+ initial: ExtensionPlayRequest,
164
+ ): Promise<{ request: ExtensionPlayRequest; response: ExtensionPlayResponse }> {
165
+ const request: ExtensionPlayRequest = { ...initial };
166
+ const response: ExtensionPlayResponse = {};
167
+ for (const extension of this.extensions) {
168
+ const hook = (extension as any).beforePlay;
169
+ if (typeof hook !== "function") continue;
170
+ try {
171
+ const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
172
+ if (!result) continue;
173
+ if (result.query !== undefined) {
174
+ request.query = result.query;
175
+ response.query = result.query;
176
+ }
177
+ if (result.requestedBy !== undefined) {
178
+ request.requestedBy = result.requestedBy;
179
+ response.requestedBy = result.requestedBy;
180
+ }
181
+ if (Array.isArray(result.tracks)) {
182
+ response.tracks = result.tracks;
183
+ }
184
+ if (typeof result.isPlaylist === "boolean") {
185
+ response.isPlaylist = result.isPlaylist;
186
+ }
187
+ if (typeof result.success === "boolean") {
188
+ response.success = result.success;
189
+ }
190
+ if (result.error instanceof Error) {
191
+ response.error = result.error;
192
+ }
193
+ if (typeof result.handled === "boolean") {
194
+ response.handled = result.handled;
195
+ if (result.handled) break;
196
+ }
197
+ } catch (err) {
198
+ this.debug(`[Player] Extension ${extension.name} beforePlay error:`, err);
199
+ }
200
+ }
201
+ return { request, response };
202
+ }
203
+
204
+ private async runAfterPlayHooks(payload: ExtensionAfterPlayPayload): Promise<void> {
205
+ if (this.extensions.length === 0) return;
206
+ const safeTracks = payload.tracks ? [...payload.tracks] : undefined;
207
+ if (safeTracks) {
208
+ Object.freeze(safeTracks);
209
+ }
210
+ const immutablePayload = Object.freeze({ ...payload, tracks: safeTracks });
211
+ for (const extension of this.extensions) {
212
+ const hook = (extension as any).afterPlay;
213
+ if (typeof hook !== "function") continue;
214
+ try {
215
+ await Promise.resolve(hook.call(extension, this.extensionContext, immutablePayload));
216
+ } catch (err) {
217
+ this.debug(`[Player] Extension ${extension.name} afterPlay error:`, err);
218
+ }
219
+ }
220
+ }
51
221
 
52
- if (!plugin) {
53
- this.debug(`[Player] No plugin found for track: ${track.title}`);
54
- throw new Error(`No plugin found for track: ${track.title}`);
222
+ private async extensionsProvideSearch(query: string, requestedBy: string): Promise<SearchResult | null> {
223
+ const request: ExtensionSearchRequest = { query, requestedBy };
224
+ for (const extension of this.extensions) {
225
+ const hook = (extension as any).provideSearch;
226
+ if (typeof hook !== "function") continue;
227
+ try {
228
+ const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
229
+ if (result && Array.isArray(result.tracks) && result.tracks.length > 0) {
230
+ this.debug(`[Player] Extension ${extension.name} handled search for query: ${query}`);
231
+ return result as SearchResult;
232
+ }
233
+ } catch (err) {
234
+ this.debug(`[Player] Extension ${extension.name} provideSearch error:`, err);
55
235
  }
236
+ }
237
+ return null;
238
+ }
56
239
 
57
- this.debug(`[Player] Getting stream for track: ${track.title}`);
58
- this.debug(`[Player] Using plugin: ${plugin.name}`);
59
- this.debug(`[Track] Track Info:`, track);
60
- let streamInfo;
240
+ private async extensionsProvideStream(track: Track): Promise<StreamInfo | null> {
241
+ const request: ExtensionStreamRequest = { track };
242
+ for (const extension of this.extensions) {
243
+ const hook = (extension as any).provideStream;
244
+ if (typeof hook !== "function") continue;
61
245
  try {
62
- streamInfo = await this.withTimeout(plugin.getStream(track), "getStream timed out");
63
- } catch (streamError) {
64
- this.debug(`[Player] getStream failed, trying getFallback:`, streamError);
65
- const allplugs = this.pluginManager.getAll();
66
- for (const p of allplugs) {
67
- if (typeof (p as any).getFallback !== "function") {
68
- continue;
246
+ const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
247
+ if (result && (result as StreamInfo).stream) {
248
+ this.debug(`[Player] Extension ${extension.name} provided stream for track: ${track.title}`);
249
+ return result as StreamInfo;
250
+ }
251
+ } catch (err) {
252
+ this.debug(`[Player] Extension ${extension.name} provideStream error:`, err);
253
+ }
254
+ }
255
+ return null;
256
+ }
257
+
258
+ /**
259
+ * Start playing a specific track immediately, replacing the current resource.
260
+ */
261
+ private async startTrack(track: Track): Promise<boolean> {
262
+ try {
263
+ let streamInfo: StreamInfo | null = await this.extensionsProvideStream(track);
264
+ let plugin: SourcePlugin | undefined;
265
+
266
+ if (!streamInfo) {
267
+ plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
268
+
269
+ if (!plugin) {
270
+ this.debug(`[Player] No plugin found for track: ${track.title}`);
271
+ throw new Error(`No plugin found for track: ${track.title}`);
272
+ }
273
+
274
+ this.debug(`[Player] Getting stream for track: ${track.title}`);
275
+ this.debug(`[Player] Using plugin: ${plugin.name}`);
276
+ this.debug(`[Track] Track Info:`, track);
277
+ try {
278
+ streamInfo = await withTimeout(plugin.getStream(track), this.options.extractorTimeout ?? 15000, "getStream timed out");
279
+ } catch (streamError) {
280
+ this.debug(`[Player] getStream failed, trying getFallback:`, streamError);
281
+ const allplugs = this.pluginManager.getAll();
282
+ for (const p of allplugs) {
283
+ if (typeof (p as any).getFallback !== "function") {
284
+ continue;
285
+ }
286
+ try {
287
+ streamInfo = await withTimeout(
288
+ (p as any).getFallback(track),
289
+ this.options.extractorTimeout ?? 15000,
290
+ `getFallback timed out for plugin ${p.name}`,
291
+ );
292
+ if (!(streamInfo as any)?.stream) continue;
293
+ this.debug(`[Player] getFallback succeeded with plugin ${p.name} for track: ${track.title}`);
294
+ break;
295
+ } catch (fallbackError) {
296
+ this.debug(`[Player] getFallback failed with plugin ${p.name}:`, fallbackError);
297
+ }
69
298
  }
70
- try {
71
- streamInfo = await this.withTimeout((p as any).getFallback(track), `getFallback timed out for plugin ${p.name}`);
72
- if (!(streamInfo as any).stream) continue;
73
- this.debug(`[Player] getFallback succeeded with plugin ${p.name} for track: ${track.title}`);
74
- break;
75
- } catch (fallbackError) {
76
- this.debug(`[Player] getFallback failed with plugin ${p.name}:`, fallbackError);
299
+ if (!(streamInfo as any)?.stream) {
300
+ throw new Error(`All getFallback attempts failed for track: ${track.title}`);
77
301
  }
78
302
  }
303
+ } else {
304
+ this.debug(`[Player] Using extension-provided stream for track: ${track.title}`);
305
+ }
79
306
 
80
- if (!(streamInfo as any)?.stream) {
81
- throw new Error(`All getFallback attempts failed for track: ${track.title}`);
82
- }
307
+ if (plugin) {
83
308
  this.debug(streamInfo);
84
309
  }
85
310
 
86
- function mapToStreamType(type: string): StreamType {
87
- switch (type) {
88
- case "webm/opus":
89
- return StreamType.WebmOpus;
90
- case "ogg/opus":
91
- return StreamType.OggOpus;
92
- case "arbitrary":
93
- return StreamType.Arbitrary;
94
- default:
95
- return StreamType.Arbitrary;
311
+ // Kiểm tra nếu có stream thực sự để tạo AudioResource
312
+ if (streamInfo && (streamInfo as any).stream) {
313
+ function mapToStreamType(type: string | undefined): StreamType {
314
+ switch (type) {
315
+ case "webm/opus":
316
+ return StreamType.WebmOpus;
317
+ case "ogg/opus":
318
+ return StreamType.OggOpus;
319
+ case "arbitrary":
320
+ default:
321
+ return StreamType.Arbitrary;
322
+ }
96
323
  }
97
- }
98
324
 
99
- let stream: Readable = (streamInfo as any).stream;
100
- let inputType = mapToStreamType((streamInfo as any).type);
325
+ const stream: Readable = (streamInfo as StreamInfo).stream;
326
+ const inputType = mapToStreamType((streamInfo as StreamInfo).type);
101
327
 
102
- this.currentResource = createAudioResource(stream, {
103
- metadata: track,
104
- inputType,
105
- inlineVolume: true,
106
- });
328
+ this.currentResource = createAudioResource(stream, {
329
+ metadata: track,
330
+ inputType,
331
+ inlineVolume: true,
332
+ });
107
333
 
108
- // Apply initial volume using the resource's VolumeTransformer
109
- if (this.volumeInterval) {
110
- clearInterval(this.volumeInterval);
111
- this.volumeInterval = null;
112
- }
113
- this.currentResource.volume?.setVolume(this.volume / 100);
334
+ // Apply initial volume using the resource's VolumeTransformer
335
+ if (this.volumeInterval) {
336
+ clearInterval(this.volumeInterval);
337
+ this.volumeInterval = null;
338
+ }
339
+ this.currentResource.volume?.setVolume(this.volume / 100);
114
340
 
115
- this.debug(`[Player] Playing resource for track: ${track.title}`);
116
- this.audioPlayer.play(this.currentResource);
341
+ this.debug(`[Player] Playing resource for track: ${track.title}`);
342
+ this.audioPlayer.play(this.currentResource);
117
343
 
118
- await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 5_000);
119
- return true;
344
+ await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 5_000);
345
+ return true;
346
+ } else if (streamInfo && !(streamInfo as any).stream) {
347
+ // Extension đang xử lý phát nhạc (như Lavalink) - chỉ đánh dấu đang phát
348
+ this.debug(`[Player] Extension is handling playback for track: ${track.title}`);
349
+ this.isPlaying = true;
350
+ this.isPaused = false;
351
+ this.emit("trackStart", track);
352
+ return true;
353
+ } else {
354
+ throw new Error(`No stream available for track: ${track.title}`);
355
+ }
120
356
  } catch (error) {
121
357
  this.debug(`[Player] startTrack error:`, error);
122
358
  this.emit("playerError", error as Error, track);
@@ -136,11 +372,6 @@ export class Player extends EventEmitter {
136
372
  }
137
373
  }
138
374
 
139
- private withTimeout<T>(promise: Promise<T>, message: string): Promise<T> {
140
- const timeout = this.options.extractorTimeout ?? 15000;
141
- return Promise.race([promise, new Promise<never>((_, reject) => setTimeout(() => reject(new Error(message)), timeout))]);
142
- }
143
-
144
375
  private debug(message?: any, ...optionalParams: any[]): void {
145
376
  if (this.listenerCount("debug") > 0) {
146
377
  this.emit("debug", message, ...optionalParams);
@@ -184,6 +415,7 @@ export class Player extends EventEmitter {
184
415
  this.volume = this.options.volume || 100;
185
416
  this.userdata = this.options.userdata;
186
417
  this.setupEventListeners();
418
+ this.extensionContext = Object.freeze({ player: this, manager });
187
419
 
188
420
  // Optionally pre-create the TTS AudioPlayer
189
421
  if (this.options?.tts?.createPlayer) {
@@ -272,6 +504,14 @@ export class Player extends EventEmitter {
272
504
  return this.pluginManager.unregister(name);
273
505
  }
274
506
 
507
+ /**
508
+ * Connect to a voice channel
509
+ *
510
+ * @param {VoiceChannel} channel - Discord voice channel
511
+ * @returns {Promise<VoiceConnection>} The voice connection
512
+ * @example
513
+ * await player.connect(voiceChannel);
514
+ */
275
515
  async connect(channel: VoiceChannel): Promise<VoiceConnection> {
276
516
  try {
277
517
  this.debug(`[Player] Connecting to voice channel: ${channel.id}`);
@@ -307,15 +547,34 @@ export class Player extends EventEmitter {
307
547
  }
308
548
  }
309
549
 
550
+ /**
551
+ * Search for tracks using the player's extensions and plugins
552
+ *
553
+ * @param {string} query - The query to search for
554
+ * @param {string} requestedBy - The user ID who requested the search
555
+ * @returns {Promise<SearchResult>} The search result
556
+ * @example
557
+ * const result = await player.search("Never Gonna Give You Up", userId);
558
+ * console.log(`Search result: ${result.tracks.length} tracks`);
559
+ */
310
560
  async search(query: string, requestedBy: string): Promise<SearchResult> {
311
561
  this.debug(`[Player] Search called with query: ${query}, requestedBy: ${requestedBy}`);
562
+ const extensionResult = await this.extensionsProvideSearch(query, requestedBy);
563
+ if (extensionResult && Array.isArray(extensionResult.tracks) && extensionResult.tracks.length > 0) {
564
+ this.debug(`[Player] Extension handled search for query: ${query}`);
565
+ return extensionResult;
566
+ }
312
567
  const plugins = this.pluginManager.getAll();
313
568
  let lastError: any = null;
314
569
 
315
570
  for (const p of plugins) {
316
571
  try {
317
572
  this.debug(`[Player] Trying plugin for search: ${p.name}`);
318
- const res = await this.withTimeout(p.search(query, requestedBy), `Search operation timed out for ${p.name}`);
573
+ const res = await withTimeout(
574
+ p.search(query, requestedBy),
575
+ this.options.extractorTimeout ?? 15000,
576
+ `Search operation timed out for ${p.name}`,
577
+ );
319
578
  if (res && Array.isArray(res.tracks) && res.tracks.length > 0) {
320
579
  this.debug(`[Player] Plugin '${p.name}' returned ${res.tracks.length} tracks`);
321
580
  return res;
@@ -333,23 +592,63 @@ export class Player extends EventEmitter {
333
592
  throw new Error(`No plugin found to handle: ${query}`);
334
593
  }
335
594
 
595
+ /**
596
+ * Play a track or search query
597
+ *
598
+ * @param {string | Track} query - Track URL, search query, or Track object
599
+ * @param {string} requestedBy - User ID who requested the track
600
+ * @returns {Promise<boolean>} True if playback started successfully
601
+ * @example
602
+ * await player.play("Never Gonna Give You Up", userId);
603
+ * await player.play("https://youtube.com/watch?v=dQw4w9WgXcQ", userId);
604
+ * await player.play("tts: Hello everyone!", userId);
605
+ */
336
606
  async play(query: string | Track, requestedBy?: string): Promise<boolean> {
607
+ this.debug(`[Player] Play called with query: ${typeof query === "string" ? query : query?.title}`);
608
+ this.clearLeaveTimeout();
609
+ let tracksToAdd: Track[] = [];
610
+ let isPlaylist = false;
611
+ let effectiveRequest: ExtensionPlayRequest = { query, requestedBy };
612
+ let hookResponse: ExtensionPlayResponse = {};
613
+
337
614
  try {
338
- this.debug(`[Player] Play called with query: ${typeof query === "string" ? query : query?.title}`);
339
- // If a leave was scheduled due to previous idle, cancel it now
340
- this.clearLeaveTimeout();
341
- let tracksToAdd: Track[] = [];
342
- let isPlaylist = false;
343
- if (typeof query === "string") {
344
- const searchResult = await this.search(query, requestedBy || "Unknown");
345
- tracksToAdd = searchResult.tracks;
615
+ const hookOutcome = await this.runBeforePlayHooks(effectiveRequest);
616
+ effectiveRequest = hookOutcome.request;
617
+ hookResponse = hookOutcome.response;
618
+ if (effectiveRequest.requestedBy === undefined) {
619
+ effectiveRequest.requestedBy = requestedBy;
620
+ }
621
+
622
+ const hookTracks = Array.isArray(hookResponse.tracks) ? hookResponse.tracks : undefined;
623
+
624
+ if (hookResponse.handled && (!hookTracks || hookTracks.length === 0)) {
625
+ const handledPayload: ExtensionAfterPlayPayload = {
626
+ success: hookResponse.success ?? true,
627
+ query: effectiveRequest.query,
628
+ requestedBy: effectiveRequest.requestedBy,
629
+ tracks: [],
630
+ isPlaylist: hookResponse.isPlaylist ?? false,
631
+ error: hookResponse.error,
632
+ };
633
+ await this.runAfterPlayHooks(handledPayload);
634
+ if (hookResponse.error) {
635
+ this.emit("playerError", hookResponse.error);
636
+ }
637
+ return hookResponse.success ?? true;
638
+ }
346
639
 
640
+ if (hookTracks && hookTracks.length > 0) {
641
+ tracksToAdd = hookTracks;
642
+ isPlaylist = hookResponse.isPlaylist ?? hookTracks.length > 1;
643
+ } else if (typeof effectiveRequest.query === "string") {
644
+ const searchResult = await this.search(effectiveRequest.query, effectiveRequest.requestedBy || "Unknown");
645
+ tracksToAdd = searchResult.tracks;
347
646
  if (searchResult.playlist) {
348
647
  isPlaylist = true;
349
648
  this.debug(`[Player] Added playlist: ${searchResult.playlist.name} (${tracksToAdd.length} tracks)`);
350
649
  }
351
- } else {
352
- tracksToAdd = [query];
650
+ } else if (effectiveRequest.query) {
651
+ tracksToAdd = [effectiveRequest.query as Track];
353
652
  }
354
653
 
355
654
  if (tracksToAdd.length === 0) {
@@ -357,7 +656,6 @@ export class Player extends EventEmitter {
357
656
  throw new Error("No tracks found");
358
657
  }
359
658
 
360
- // If a TTS track is requested and interrupt mode is enabled, handle it separately
361
659
  const isTTS = (t: Track | undefined) => {
362
660
  if (!t) return false;
363
661
  try {
@@ -367,7 +665,8 @@ export class Player extends EventEmitter {
367
665
  }
368
666
  };
369
667
 
370
- const queryLooksTTS = typeof query === "string" && query.trim().toLowerCase().startsWith("tts");
668
+ const queryLooksTTS =
669
+ typeof effectiveRequest.query === "string" && effectiveRequest.query.trim().toLowerCase().startsWith("tts");
371
670
 
372
671
  if (
373
672
  !isPlaylist &&
@@ -375,9 +674,15 @@ export class Player extends EventEmitter {
375
674
  this.options?.tts?.interrupt !== false &&
376
675
  (isTTS(tracksToAdd[0]) || queryLooksTTS)
377
676
  ) {
378
- // Interrupt music playback with TTS (do not modify the music queue)
379
677
  this.debug(`[Player] Interrupting with TTS: ${tracksToAdd[0].title}`);
380
678
  await this.interruptWithTTSTrack(tracksToAdd[0]);
679
+ await this.runAfterPlayHooks({
680
+ success: true,
681
+ query: effectiveRequest.query,
682
+ requestedBy: effectiveRequest.requestedBy,
683
+ tracks: tracksToAdd,
684
+ isPlaylist,
685
+ });
381
686
  return true;
382
687
  }
383
688
 
@@ -385,17 +690,30 @@ export class Player extends EventEmitter {
385
690
  this.queue.addMultiple(tracksToAdd);
386
691
  this.emit("queueAddList", tracksToAdd);
387
692
  } else {
388
- this.queue.add(tracksToAdd?.[0]);
389
- this.emit("queueAdd", tracksToAdd?.[0]);
693
+ this.queue.add(tracksToAdd[0]);
694
+ this.emit("queueAdd", tracksToAdd[0]);
390
695
  }
391
696
 
392
- // Start playing if not already playing
393
- if (!this.isPlaying) {
394
- return this.playNext();
395
- }
697
+ const started = !this.isPlaying ? await this.playNext() : true;
396
698
 
397
- return true;
699
+ await this.runAfterPlayHooks({
700
+ success: started,
701
+ query: effectiveRequest.query,
702
+ requestedBy: effectiveRequest.requestedBy,
703
+ tracks: tracksToAdd,
704
+ isPlaylist,
705
+ });
706
+
707
+ return started;
398
708
  } catch (error) {
709
+ await this.runAfterPlayHooks({
710
+ success: false,
711
+ query: effectiveRequest.query,
712
+ requestedBy: effectiveRequest.requestedBy,
713
+ tracks: tracksToAdd,
714
+ isPlaylist,
715
+ error: error as Error,
716
+ });
399
717
  this.debug(`[Player] Play error:`, error);
400
718
  this.emit("playerError", error as Error);
401
719
  return false;
@@ -405,6 +723,11 @@ export class Player extends EventEmitter {
405
723
  /**
406
724
  * Interrupt current music with a TTS track. Pauses music, swaps the
407
725
  * subscription to a dedicated TTS player, plays TTS, then resumes.
726
+ *
727
+ * @param {Track} track - The track to interrupt with
728
+ * @returns {Promise<void>}
729
+ * @example
730
+ * await player.interruptWithTTSTrack(track);
408
731
  */
409
732
  public async interruptWithTTSTrack(track: Track): Promise<void> {
410
733
  this.ttsQueue.push(track);
@@ -413,7 +736,13 @@ export class Player extends EventEmitter {
413
736
  }
414
737
  }
415
738
 
416
- /** Play queued TTS items sequentially */
739
+ /**
740
+ * Play queued TTS items sequentially
741
+ *
742
+ * @returns {Promise<void>}
743
+ * @example
744
+ * await player.playNextTTS();
745
+ */
417
746
  private async playNextTTS(): Promise<void> {
418
747
  const next = this.ttsQueue.shift();
419
748
  if (!next) return;
@@ -473,32 +802,195 @@ export class Player extends EventEmitter {
473
802
  }
474
803
  }
475
804
 
805
+ /**
806
+ * Get cached plugin or find and cache a new one
807
+ * @param track The track to find plugin for
808
+ * @returns The matching plugin or null if not found
809
+ */
810
+ private getCachedPlugin(track: Track): SourcePlugin | null {
811
+ const cacheKey = `${track.source}:${track.url}`;
812
+ const now = Date.now();
813
+
814
+ // Check if cache is still valid
815
+ const cachedTimestamp = this.pluginCacheTimestamps.get(cacheKey);
816
+ if (cachedTimestamp && now - cachedTimestamp < this.PLUGIN_CACHE_TTL) {
817
+ const cachedPlugin = this.pluginCache.get(cacheKey);
818
+ if (cachedPlugin) {
819
+ this.debug(`[PluginCache] Using cached plugin for ${track.source}: ${cachedPlugin.name}`);
820
+ return cachedPlugin;
821
+ }
822
+ }
823
+
824
+ // Find new plugin and cache it
825
+ this.debug(`[PluginCache] Finding plugin for track: ${track.title} (${track.source})`);
826
+ const plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
827
+
828
+ if (plugin) {
829
+ this.pluginCache.set(cacheKey, plugin);
830
+ this.pluginCacheTimestamps.set(cacheKey, now);
831
+ this.debug(`[PluginCache] Cached plugin: ${plugin.name} for ${track.source}`);
832
+ return plugin;
833
+ }
834
+
835
+ return null;
836
+ }
837
+
838
+ /**
839
+ * Clear expired cache entries
840
+ */
841
+ private clearExpiredCache(): void {
842
+ const now = Date.now();
843
+ for (const [key, timestamp] of this.pluginCacheTimestamps.entries()) {
844
+ if (now - timestamp >= this.PLUGIN_CACHE_TTL) {
845
+ this.pluginCache.delete(key);
846
+ this.pluginCacheTimestamps.delete(key);
847
+ this.debug(`[PluginCache] Cleared expired cache entry: ${key}`);
848
+ }
849
+ }
850
+ }
851
+
852
+ /**
853
+ * Clear all plugin cache entries
854
+ * @example
855
+ * player.clearPluginCache();
856
+ */
857
+ public clearPluginCache(): void {
858
+ const cacheSize = this.pluginCache.size;
859
+ this.pluginCache.clear();
860
+ this.pluginCacheTimestamps.clear();
861
+ this.debug(`[PluginCache] Cleared all ${cacheSize} cache entries`);
862
+ }
863
+
864
+ /**
865
+ * Get plugin cache statistics
866
+ * @returns Cache statistics
867
+ * @example
868
+ * const stats = player.getPluginCacheStats();
869
+ * console.log(`Cache size: ${stats.size}, Hit rate: ${stats.hitRate}%`);
870
+ */
871
+ public getPluginCacheStats(): { size: number; hitRate: number; expiredEntries: number } {
872
+ const now = Date.now();
873
+ let expiredEntries = 0;
874
+
875
+ for (const timestamp of this.pluginCacheTimestamps.values()) {
876
+ if (now - timestamp >= this.PLUGIN_CACHE_TTL) {
877
+ expiredEntries++;
878
+ }
879
+ }
880
+
881
+ return {
882
+ size: this.pluginCache.size,
883
+ hitRate: 0, // Would need to track hits/misses to calculate this
884
+ expiredEntries,
885
+ };
886
+ }
887
+
476
888
  /** Build AudioResource for a given track using the plugin pipeline */
477
889
  private async resourceFromTrack(track: Track): Promise<AudioResource> {
478
- // Resolve plugin similar to playNext
479
- const plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
480
- if (!plugin) throw new Error(`No plugin found for track: ${track.title}`);
890
+ this.debug(`[ResourceFromTrack] Starting resource creation for track: ${track.title} (${track.source})`);
891
+
892
+ // Clear expired cache entries periodically
893
+ if (Math.random() < 0.1) {
894
+ // 10% chance to clean cache
895
+ this.clearExpiredCache();
896
+ }
897
+
898
+ // Resolve plugin using cache
899
+ const plugin = this.getCachedPlugin(track);
900
+ if (!plugin) {
901
+ this.debug(`[ResourceFromTrack] No plugin found for track: ${track.title} (${track.source})`);
902
+ throw new Error(`No plugin found for track: ${track.title}`);
903
+ }
904
+
905
+ this.debug(`[ResourceFromTrack] Using plugin: ${plugin.name} for track: ${track.title}`);
906
+
907
+ let streamInfo: StreamInfo | null = null;
908
+ const timeoutMs = this.options.extractorTimeout ?? 15000;
481
909
 
482
- let streamInfo: any;
483
910
  try {
484
- streamInfo = await this.withTimeout(plugin.getStream(track), "getStream timed out");
911
+ this.debug(`[ResourceFromTrack] Attempting getStream with ${plugin.name}, timeout: ${timeoutMs}ms`);
912
+ const startTime = Date.now();
913
+ streamInfo = await withTimeout(plugin.getStream(track), timeoutMs, "getStream timed out");
914
+ const duration = Date.now() - startTime;
915
+ this.debug(`[ResourceFromTrack] getStream successful with ${plugin.name} in ${duration}ms`);
916
+
917
+ if (!streamInfo?.stream) {
918
+ this.debug(`[ResourceFromTrack] getStream returned no stream from ${plugin.name}`);
919
+ throw new Error(`No stream returned from ${plugin.name}`);
920
+ }
485
921
  } catch (streamError) {
922
+ const errorMessage = streamError instanceof Error ? streamError.message : String(streamError);
923
+ this.debug(`[ResourceFromTrack] getStream failed with ${plugin.name}: ${errorMessage}`);
924
+
925
+ // Log more details for debugging
926
+ if (streamError instanceof Error && streamError.stack) {
927
+ this.debug(`[ResourceFromTrack] getStream error stack:`, streamError.stack);
928
+ }
929
+
486
930
  // try fallbacks
931
+ this.debug(`[ResourceFromTrack] Attempting fallback plugins for track: ${track.title}`);
487
932
  const allplugs = this.pluginManager.getAll();
933
+ let fallbackAttempts = 0;
934
+
488
935
  for (const p of allplugs) {
489
- if (typeof (p as any).getFallback !== "function") continue;
936
+ if (typeof (p as any).getFallback !== "function" && typeof (p as any).getStream !== "function") {
937
+ this.debug(`[ResourceFromTrack] Skipping plugin ${(p as any).name} - no getFallback or getStream method`);
938
+ continue;
939
+ }
940
+
941
+ fallbackAttempts++;
942
+ this.debug(`[ResourceFromTrack] Trying fallback plugin ${(p as any).name} (attempt ${fallbackAttempts})`);
943
+
490
944
  try {
491
- streamInfo = await this.withTimeout(
945
+ // Try getStream first
946
+ const startTime = Date.now();
947
+ streamInfo = await withTimeout(p.getStream(track), timeoutMs, "getStream timed out");
948
+ const duration = Date.now() - startTime;
949
+
950
+ if (streamInfo?.stream) {
951
+ this.debug(`[ResourceFromTrack] Fallback getStream successful with ${(p as any).name} in ${duration}ms`);
952
+ break;
953
+ }
954
+
955
+ // Try getFallback if getStream didn't work
956
+ this.debug(`[ResourceFromTrack] Trying getFallback with ${(p as any).name}`);
957
+ const fallbackStartTime = Date.now();
958
+ streamInfo = await withTimeout(
492
959
  (p as any).getFallback(track),
960
+ timeoutMs,
493
961
  `getFallback timed out for plugin ${(p as any).name}`,
494
962
  );
495
- if (!streamInfo?.stream) continue;
496
- break;
497
- } catch {}
963
+ const fallbackDuration = Date.now() - fallbackStartTime;
964
+
965
+ if (streamInfo?.stream) {
966
+ this.debug(`[ResourceFromTrack] Fallback getFallback successful with ${(p as any).name} in ${fallbackDuration}ms`);
967
+ break;
968
+ }
969
+
970
+ this.debug(`[ResourceFromTrack] Fallback plugin ${(p as any).name} returned no stream`);
971
+ } catch (fallbackError) {
972
+ const errorMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
973
+ this.debug(`[ResourceFromTrack] Fallback plugin ${(p as any).name} failed: ${errorMessage}`);
974
+
975
+ // Log more details for debugging
976
+ if (fallbackError instanceof Error && fallbackError.stack) {
977
+ this.debug(`[ResourceFromTrack] Fallback error stack:`, fallbackError.stack);
978
+ }
979
+ }
980
+ }
981
+
982
+ if (!streamInfo?.stream) {
983
+ this.debug(`[ResourceFromTrack] All ${fallbackAttempts} fallback attempts failed for track: ${track.title}`);
984
+ throw new Error(`All getFallback attempts failed for track: ${track.title}`);
498
985
  }
499
- if (!streamInfo?.stream) throw new Error(`All getFallback attempts failed for track: ${track.title}`);
500
986
  }
501
987
 
988
+ this.debug(
989
+ `[ResourceFromTrack] Stream obtained, type: ${streamInfo.type}, metadata keys: ${Object.keys(
990
+ streamInfo.metadata || {},
991
+ ).join(", ")}`,
992
+ );
993
+
502
994
  const mapToStreamType = (type: string): StreamType => {
503
995
  switch (type) {
504
996
  case "webm/opus":
@@ -512,15 +1004,23 @@ export class Player extends EventEmitter {
512
1004
  };
513
1005
 
514
1006
  const inputType = mapToStreamType(streamInfo.type);
515
- return createAudioResource(streamInfo.stream, {
1007
+ this.debug(`[ResourceFromTrack] Creating AudioResource with inputType: ${inputType}`);
1008
+
1009
+ // Merge metadata safely
1010
+ const mergedMetadata = {
1011
+ ...track,
1012
+ ...(streamInfo.metadata || {}),
1013
+ };
1014
+
1015
+ const audioResource = createAudioResource(streamInfo.stream, {
516
1016
  // Prefer plugin-provided metadata (e.g., precise duration), fallback to track fields
517
- metadata: {
518
- ...(track as any),
519
- ...((streamInfo as any)?.metadata || {}),
520
- },
1017
+ metadata: mergedMetadata,
521
1018
  inputType,
522
1019
  inlineVolume: true,
523
1020
  });
1021
+
1022
+ this.debug(`[ResourceFromTrack] AudioResource created successfully for track: ${track.title}`);
1023
+ return audioResource;
524
1024
  }
525
1025
 
526
1026
  private async generateWillNext(): Promise<void> {
@@ -537,11 +1037,12 @@ export class Player extends EventEmitter {
537
1037
  for (const p of candidates) {
538
1038
  try {
539
1039
  this.debug(`[Player] Trying related from plugin: ${p.name}`);
540
- const related = await this.withTimeout(
1040
+ const related = await withTimeout(
541
1041
  (p as any).getRelatedTracks(lastTrack.url, {
542
1042
  limit: 10,
543
1043
  history: this.queue.previousTracks,
544
1044
  }),
1045
+ this.options.extractorTimeout ?? 15000,
545
1046
  `getRelatedTracks timed out for ${p.name}`,
546
1047
  );
547
1048
 
@@ -599,6 +1100,14 @@ export class Player extends EventEmitter {
599
1100
  }
600
1101
  }
601
1102
 
1103
+ /**
1104
+ * Pause the current track
1105
+ *
1106
+ * @returns {boolean} True if paused successfully
1107
+ * @example
1108
+ * const paused = player.pause();
1109
+ * console.log(`Paused: ${paused}`);
1110
+ */
602
1111
  pause(): boolean {
603
1112
  this.debug(`[Player] pause called`);
604
1113
  if (this.isPlaying && !this.isPaused) {
@@ -607,6 +1116,14 @@ export class Player extends EventEmitter {
607
1116
  return false;
608
1117
  }
609
1118
 
1119
+ /**
1120
+ * Resume the current track
1121
+ *
1122
+ * @returns {boolean} True if resumed successfully
1123
+ * @example
1124
+ * const resumed = player.resume();
1125
+ * console.log(`Resumed: ${resumed}`);
1126
+ */
610
1127
  resume(): boolean {
611
1128
  this.debug(`[Player] resume called`);
612
1129
  if (this.isPaused) {
@@ -623,6 +1140,14 @@ export class Player extends EventEmitter {
623
1140
  return false;
624
1141
  }
625
1142
 
1143
+ /**
1144
+ * Stop the current track
1145
+ *
1146
+ * @returns {boolean} True if stopped successfully
1147
+ * @example
1148
+ * const stopped = player.stop();
1149
+ * console.log(`Stopped: ${stopped}`);
1150
+ */
626
1151
  stop(): boolean {
627
1152
  this.debug(`[Player] stop called`);
628
1153
  this.queue.clear();
@@ -633,6 +1158,15 @@ export class Player extends EventEmitter {
633
1158
  return result;
634
1159
  }
635
1160
 
1161
+ /**
1162
+ * Skip to the next track
1163
+ *
1164
+ * @returns {boolean} True if skipped successfully
1165
+ * @example
1166
+ * const skipped = player.skip();
1167
+ * console.log(`Skipped: ${skipped}`);
1168
+ */
1169
+
636
1170
  skip(): boolean {
637
1171
  this.debug(`[Player] skip called`);
638
1172
  if (this.isPlaying || this.isPaused) {
@@ -644,6 +1178,11 @@ export class Player extends EventEmitter {
644
1178
 
645
1179
  /**
646
1180
  * Go back to the previous track in history and play it.
1181
+ *
1182
+ * @returns {Promise<boolean>} True if previous track was played successfully
1183
+ * @example
1184
+ * const previous = await player.previous();
1185
+ * console.log(`Previous: ${previous}`);
647
1186
  */
648
1187
  async previous(): Promise<boolean> {
649
1188
  this.debug(`[Player] previous called`);
@@ -654,14 +1193,41 @@ export class Player extends EventEmitter {
654
1193
  return this.startTrack(track);
655
1194
  }
656
1195
 
1196
+ /**
1197
+ * Loop the current track
1198
+ *
1199
+ * @param {LoopMode} mode - The loop mode to set
1200
+ * @returns {LoopMode} The loop mode
1201
+ * @example
1202
+ * const loopMode = player.loop("track");
1203
+ * console.log(`Loop mode: ${loopMode}`);
1204
+ */
657
1205
  loop(mode?: LoopMode): LoopMode {
658
1206
  return this.queue.loop(mode);
659
1207
  }
660
1208
 
1209
+ /**
1210
+ * Set the auto-play mode
1211
+ *
1212
+ * @param {boolean} mode - The auto-play mode to set
1213
+ * @returns {boolean} The auto-play mode
1214
+ * @example
1215
+ * const autoPlayMode = player.autoPlay(true);
1216
+ * console.log(`Auto-play mode: ${autoPlayMode}`);
1217
+ */
661
1218
  autoPlay(mode?: boolean): boolean {
662
1219
  return this.queue.autoPlay(mode);
663
1220
  }
664
1221
 
1222
+ /**
1223
+ * Set the volume of the current track
1224
+ *
1225
+ * @param {number} volume - The volume to set
1226
+ * @returns {boolean} True if volume was set successfully
1227
+ * @example
1228
+ * const volumeSet = player.setVolume(50);
1229
+ * console.log(`Volume set: ${volumeSet}`);
1230
+ */
665
1231
  setVolume(volume: number): boolean {
666
1232
  this.debug(`[Player] setVolume called: ${volume}`);
667
1233
  if (volume < 0 || volume > 200) return false;
@@ -693,11 +1259,25 @@ export class Player extends EventEmitter {
693
1259
  return true;
694
1260
  }
695
1261
 
1262
+ /**
1263
+ * Shuffle the queue
1264
+ *
1265
+ * @returns {void}
1266
+ * @example
1267
+ * player.shuffle();
1268
+ */
696
1269
  shuffle(): void {
697
1270
  this.debug(`[Player] shuffle called`);
698
1271
  this.queue.shuffle();
699
1272
  }
700
1273
 
1274
+ /**
1275
+ * Clear the queue
1276
+ *
1277
+ * @returns {void}
1278
+ * @example
1279
+ * player.clearQueue();
1280
+ */
701
1281
  clearQueue(): void {
702
1282
  this.debug(`[Player] clearQueue called`);
703
1283
  this.queue.clear();
@@ -708,6 +1288,14 @@ export class Player extends EventEmitter {
708
1288
  * - If `query` is a string, performs a search and inserts resulting tracks (playlist supported).
709
1289
  * - If a Track or Track[] is provided, inserts directly.
710
1290
  * Does not auto-start playback; it only modifies the queue.
1291
+ *
1292
+ * @param {string | Track | Track[]} query - The track or tracks to insert
1293
+ * @param {number} index - The index to insert the tracks at
1294
+ * @param {string} requestedBy - The user ID who requested the insert
1295
+ * @returns {Promise<boolean>} True if the tracks were inserted successfully
1296
+ * @example
1297
+ * const inserted = await player.insert("Song Name", 0, userId);
1298
+ * console.log(`Inserted: ${inserted}`);
711
1299
  */
712
1300
  async insert(query: string | Track | Track[], index: number, requestedBy?: string): Promise<boolean> {
713
1301
  try {
@@ -749,6 +1337,15 @@ export class Player extends EventEmitter {
749
1337
  }
750
1338
  }
751
1339
 
1340
+ /**
1341
+ * Remove a track from the queue
1342
+ *
1343
+ * @param {number} index - The index of the track to remove
1344
+ * @returns {Track | null} The removed track or null
1345
+ * @example
1346
+ * const removed = player.remove(0);
1347
+ * console.log(`Removed: ${removed?.title}`);
1348
+ */
752
1349
  remove(index: number): Track | null {
753
1350
  this.debug(`[Player] remove called for index: ${index}`);
754
1351
  const track = this.queue.remove(index);
@@ -758,6 +1355,15 @@ export class Player extends EventEmitter {
758
1355
  return track;
759
1356
  }
760
1357
 
1358
+ /**
1359
+ * Get the progress bar of the current track
1360
+ *
1361
+ * @param {ProgressBarOptions} options - The options for the progress bar
1362
+ * @returns {string} The progress bar
1363
+ * @example
1364
+ * const progressBar = player.getProgressBar();
1365
+ * console.log(`Progress bar: ${progressBar}`);
1366
+ */
761
1367
  getProgressBar(options: ProgressBarOptions = {}): string {
762
1368
  const { size = 20, barChar = "▬", progressChar = "🔘" } = options;
763
1369
  const track = this.queue.currentTrack;
@@ -775,6 +1381,14 @@ export class Player extends EventEmitter {
775
1381
  return `${this.formatTime(current)} | ${bar} | ${this.formatTime(total)}`;
776
1382
  }
777
1383
 
1384
+ /**
1385
+ * Get the time of the current track
1386
+ *
1387
+ * @returns {Object} The time of the current track
1388
+ * @example
1389
+ * const time = player.getTime();
1390
+ * console.log(`Time: ${time.current}`);
1391
+ */
778
1392
  getTime() {
779
1393
  const resource = this.currentResource;
780
1394
  const track = this.queue.currentTrack;
@@ -794,6 +1408,15 @@ export class Player extends EventEmitter {
794
1408
  };
795
1409
  }
796
1410
 
1411
+ /**
1412
+ * Format the time in the format of HH:MM:SS
1413
+ *
1414
+ * @param {number} ms - The time in milliseconds
1415
+ * @returns {string} The formatted time
1416
+ * @example
1417
+ * const formattedTime = player.formatTime(1000);
1418
+ * console.log(`Formatted time: ${formattedTime}`);
1419
+ */
797
1420
  formatTime(ms: number): string {
798
1421
  const totalSeconds = Math.floor(ms / 1000);
799
1422
  const hours = Math.floor(totalSeconds / 3600);
@@ -820,6 +1443,13 @@ export class Player extends EventEmitter {
820
1443
  }
821
1444
  }
822
1445
 
1446
+ /**
1447
+ * Destroy the player
1448
+ *
1449
+ * @returns {void}
1450
+ * @example
1451
+ * player.destroy();
1452
+ */
823
1453
  destroy(): void {
824
1454
  this.debug(`[Player] destroy called`);
825
1455
  if (this.leaveTimeout) {
@@ -843,36 +1473,99 @@ export class Player extends EventEmitter {
843
1473
 
844
1474
  this.queue.clear();
845
1475
  this.pluginManager.clear();
1476
+ for (const extension of [...this.extensions]) {
1477
+ this.invokeExtensionLifecycle(extension, "onDestroy");
1478
+ if (extension.player === this) {
1479
+ extension.player = null;
1480
+ }
1481
+ }
1482
+ this.extensions = [];
846
1483
  this.isPlaying = false;
847
1484
  this.isPaused = false;
848
1485
  this.emit("playerDestroy");
849
1486
  this.removeAllListeners();
850
1487
  }
851
1488
 
852
- // Getters
1489
+ /**
1490
+ * Get the size of the queue
1491
+ *
1492
+ * @returns {number} The size of the queue
1493
+ * @example
1494
+ * const queueSize = player.queueSize;
1495
+ * console.log(`Queue size: ${queueSize}`);
1496
+ */
853
1497
  get queueSize(): number {
854
1498
  return this.queue.size;
855
1499
  }
856
1500
 
1501
+ /**
1502
+ * Get the current track
1503
+ *
1504
+ * @returns {Track | null} The current track or null
1505
+ * @example
1506
+ * const currentTrack = player.currentTrack;
1507
+ * console.log(`Current track: ${currentTrack?.title}`);
1508
+ */
857
1509
  get currentTrack(): Track | null {
858
1510
  return this.queue.currentTrack;
859
1511
  }
860
1512
 
1513
+ /**
1514
+ * Get the previous track
1515
+ *
1516
+ * @returns {Track | null} The previous track or null
1517
+ * @example
1518
+ * const previousTrack = player.previousTrack;
1519
+ * console.log(`Previous track: ${previousTrack?.title}`);
1520
+ */
861
1521
  get previousTrack(): Track | null {
862
1522
  return this.queue.previousTracks?.at(-1) ?? null;
863
1523
  }
864
1524
 
1525
+ /**
1526
+ * Get the upcoming tracks
1527
+ *
1528
+ * @returns {Track[]} The upcoming tracks
1529
+ * @example
1530
+ * const upcomingTracks = player.upcomingTracks;
1531
+ * console.log(`Upcoming tracks: ${upcomingTracks.length}`);
1532
+ */
865
1533
  get upcomingTracks(): Track[] {
866
1534
  return this.queue.getTracks();
867
1535
  }
868
1536
 
1537
+ /**
1538
+ * Get the previous tracks
1539
+ *
1540
+ * @returns {Track[]} The previous tracks
1541
+ * @example
1542
+ * const previousTracks = player.previousTracks;
1543
+ * console.log(`Previous tracks: ${previousTracks.length}`);
1544
+ */
869
1545
  get previousTracks(): Track[] {
870
1546
  return this.queue.previousTracks;
871
1547
  }
872
1548
 
1549
+ /**
1550
+ * Get the available plugins
1551
+ *
1552
+ * @returns {string[]} The available plugins
1553
+ * @example
1554
+ * const availablePlugins = player.availablePlugins;
1555
+ * console.log(`Available plugins: ${availablePlugins.length}`);
1556
+ */
873
1557
  get availablePlugins(): string[] {
874
1558
  return this.pluginManager.getAll().map((p) => p.name);
875
1559
  }
1560
+
1561
+ /**
1562
+ * Get the related tracks
1563
+ *
1564
+ * @returns {Track[] | null} The related tracks or null
1565
+ * @example
1566
+ * const relatedTracks = player.relatedTracks;
1567
+ * console.log(`Related tracks: ${relatedTracks?.length}`);
1568
+ */
876
1569
  get relatedTracks(): Track[] | null {
877
1570
  return this.queue.relatedTracks();
878
1571
  }