ziplayer 0.0.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,259 @@ 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;
43
100
 
44
101
  /**
45
- * Start playing a specific track immediately, replacing the current resource.
102
+ * Attach an extension to the player
103
+ *
104
+ * @param {BaseExtension} extension - The extension to attach
105
+ * @example
106
+ * player.attachExtension(new MyExtension());
46
107
  */
47
- private async startTrack(track: Track): Promise<boolean> {
108
+ public attachExtension(extension: BaseExtension): void {
109
+ if (this.extensions.includes(extension)) return;
110
+ if (!extension.player) extension.player = this;
111
+ this.extensions.push(extension);
112
+ this.invokeExtensionLifecycle(extension, "onRegister");
113
+ }
114
+
115
+ /**
116
+ * Detach an extension from the player
117
+ *
118
+ * @param {BaseExtension} extension - The extension to detach
119
+ * @example
120
+ * player.detachExtension(new MyExtension());
121
+ */
122
+ public detachExtension(extension: BaseExtension): void {
123
+ const index = this.extensions.indexOf(extension);
124
+ if (index === -1) return;
125
+ this.extensions.splice(index, 1);
126
+ this.invokeExtensionLifecycle(extension, "onDestroy");
127
+ if (extension.player === this) {
128
+ extension.player = null;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Get all extensions attached to the player
134
+ *
135
+ * @returns {readonly BaseExtension[]} All attached extensions
136
+ * @example
137
+ * const extensions = player.getExtensions();
138
+ * console.log(`Extensions: ${extensions.length}`);
139
+ */
140
+ public getExtensions(): readonly BaseExtension[] {
141
+ return this.extensions;
142
+ }
143
+
144
+ private invokeExtensionLifecycle(extension: BaseExtension, hook: "onRegister" | "onDestroy"): void {
145
+ const fn = (extension as any)[hook];
146
+ if (typeof fn !== "function") return;
48
147
  try {
49
- // Find plugin that can handle this track
50
- const plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
148
+ const result = fn.call(extension, this.extensionContext);
149
+ if (result && typeof (result as Promise<unknown>).then === "function") {
150
+ (result as Promise<unknown>).catch((err) => this.debug(`[Player] Extension ${extension.name} ${hook} error:`, err));
151
+ }
152
+ } catch (err) {
153
+ this.debug(`[Player] Extension ${extension.name} ${hook} error:`, err);
154
+ }
155
+ }
51
156
 
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}`);
157
+ private async runBeforePlayHooks(
158
+ initial: ExtensionPlayRequest,
159
+ ): Promise<{ request: ExtensionPlayRequest; response: ExtensionPlayResponse }> {
160
+ const request: ExtensionPlayRequest = { ...initial };
161
+ const response: ExtensionPlayResponse = {};
162
+ for (const extension of this.extensions) {
163
+ const hook = (extension as any).beforePlay;
164
+ if (typeof hook !== "function") continue;
165
+ try {
166
+ const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
167
+ if (!result) continue;
168
+ if (result.query !== undefined) {
169
+ request.query = result.query;
170
+ response.query = result.query;
171
+ }
172
+ if (result.requestedBy !== undefined) {
173
+ request.requestedBy = result.requestedBy;
174
+ response.requestedBy = result.requestedBy;
175
+ }
176
+ if (Array.isArray(result.tracks)) {
177
+ response.tracks = result.tracks;
178
+ }
179
+ if (typeof result.isPlaylist === "boolean") {
180
+ response.isPlaylist = result.isPlaylist;
181
+ }
182
+ if (typeof result.success === "boolean") {
183
+ response.success = result.success;
184
+ }
185
+ if (result.error instanceof Error) {
186
+ response.error = result.error;
187
+ }
188
+ if (typeof result.handled === "boolean") {
189
+ response.handled = result.handled;
190
+ if (result.handled) break;
191
+ }
192
+ } catch (err) {
193
+ this.debug(`[Player] Extension ${extension.name} beforePlay error:`, err);
194
+ }
195
+ }
196
+ return { request, response };
197
+ }
198
+
199
+ private async runAfterPlayHooks(payload: ExtensionAfterPlayPayload): Promise<void> {
200
+ if (this.extensions.length === 0) return;
201
+ const safeTracks = payload.tracks ? [...payload.tracks] : undefined;
202
+ if (safeTracks) {
203
+ Object.freeze(safeTracks);
204
+ }
205
+ const immutablePayload = Object.freeze({ ...payload, tracks: safeTracks });
206
+ for (const extension of this.extensions) {
207
+ const hook = (extension as any).afterPlay;
208
+ if (typeof hook !== "function") continue;
209
+ try {
210
+ await Promise.resolve(hook.call(extension, this.extensionContext, immutablePayload));
211
+ } catch (err) {
212
+ this.debug(`[Player] Extension ${extension.name} afterPlay error:`, err);
213
+ }
214
+ }
215
+ }
216
+
217
+ private async extensionsProvideSearch(query: string, requestedBy: string): Promise<SearchResult | null> {
218
+ const request: ExtensionSearchRequest = { query, requestedBy };
219
+ for (const extension of this.extensions) {
220
+ const hook = (extension as any).provideSearch;
221
+ if (typeof hook !== "function") continue;
222
+ try {
223
+ const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
224
+ if (result && Array.isArray(result.tracks) && result.tracks.length > 0) {
225
+ this.debug(`[Player] Extension ${extension.name} handled search for query: ${query}`);
226
+ return result as SearchResult;
227
+ }
228
+ } catch (err) {
229
+ this.debug(`[Player] Extension ${extension.name} provideSearch error:`, err);
55
230
  }
231
+ }
232
+ return null;
233
+ }
56
234
 
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;
235
+ private async extensionsProvideStream(track: Track): Promise<StreamInfo | null> {
236
+ const request: ExtensionStreamRequest = { track };
237
+ for (const extension of this.extensions) {
238
+ const hook = (extension as any).provideStream;
239
+ if (typeof hook !== "function") continue;
61
240
  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;
241
+ const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
242
+ if (result && (result as StreamInfo).stream) {
243
+ this.debug(`[Player] Extension ${extension.name} provided stream for track: ${track.title}`);
244
+ return result as StreamInfo;
245
+ }
246
+ } catch (err) {
247
+ this.debug(`[Player] Extension ${extension.name} provideStream error:`, err);
248
+ }
249
+ }
250
+ return null;
251
+ }
252
+
253
+ /**
254
+ * Start playing a specific track immediately, replacing the current resource.
255
+ */
256
+ private async startTrack(track: Track): Promise<boolean> {
257
+ try {
258
+ let streamInfo: StreamInfo | null = await this.extensionsProvideStream(track);
259
+ let plugin: SourcePlugin | undefined;
260
+
261
+ if (!streamInfo) {
262
+ plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
263
+
264
+ if (!plugin) {
265
+ this.debug(`[Player] No plugin found for track: ${track.title}`);
266
+ throw new Error(`No plugin found for track: ${track.title}`);
267
+ }
268
+
269
+ this.debug(`[Player] Getting stream for track: ${track.title}`);
270
+ this.debug(`[Player] Using plugin: ${plugin.name}`);
271
+ this.debug(`[Track] Track Info:`, track);
272
+ try {
273
+ streamInfo = await withTimeout(plugin.getStream(track), this.options.extractorTimeout ?? 15000, "getStream timed out");
274
+ } catch (streamError) {
275
+ this.debug(`[Player] getStream failed, trying getFallback:`, streamError);
276
+ const allplugs = this.pluginManager.getAll();
277
+ for (const p of allplugs) {
278
+ if (typeof (p as any).getFallback !== "function") {
279
+ continue;
280
+ }
281
+ try {
282
+ streamInfo = await withTimeout(
283
+ (p as any).getFallback(track),
284
+ this.options.extractorTimeout ?? 15000,
285
+ `getFallback timed out for plugin ${p.name}`,
286
+ );
287
+ if (!(streamInfo as any)?.stream) continue;
288
+ this.debug(`[Player] getFallback succeeded with plugin ${p.name} for track: ${track.title}`);
289
+ break;
290
+ } catch (fallbackError) {
291
+ this.debug(`[Player] getFallback failed with plugin ${p.name}:`, fallbackError);
292
+ }
69
293
  }
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);
294
+ if (!(streamInfo as any)?.stream) {
295
+ throw new Error(`All getFallback attempts failed for track: ${track.title}`);
77
296
  }
78
297
  }
298
+ } else {
299
+ this.debug(`[Player] Using extension-provided stream for track: ${track.title}`);
300
+ }
79
301
 
80
- if (!(streamInfo as any)?.stream) {
81
- throw new Error(`All getFallback attempts failed for track: ${track.title}`);
82
- }
302
+ if (plugin) {
83
303
  this.debug(streamInfo);
84
304
  }
85
305
 
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;
306
+ // Kiểm tra nếu có stream thực sự để tạo AudioResource
307
+ if (streamInfo && (streamInfo as any).stream) {
308
+ function mapToStreamType(type: string | undefined): StreamType {
309
+ switch (type) {
310
+ case "webm/opus":
311
+ return StreamType.WebmOpus;
312
+ case "ogg/opus":
313
+ return StreamType.OggOpus;
314
+ case "arbitrary":
315
+ default:
316
+ return StreamType.Arbitrary;
317
+ }
96
318
  }
97
- }
98
319
 
99
- let stream: Readable = (streamInfo as any).stream;
100
- let inputType = mapToStreamType((streamInfo as any).type);
320
+ const stream: Readable = (streamInfo as StreamInfo).stream;
321
+ const inputType = mapToStreamType((streamInfo as StreamInfo).type);
101
322
 
102
- this.currentResource = createAudioResource(stream, {
103
- metadata: track,
104
- inputType,
105
- inlineVolume: true,
106
- });
323
+ this.currentResource = createAudioResource(stream, {
324
+ metadata: track,
325
+ inputType,
326
+ inlineVolume: true,
327
+ });
107
328
 
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);
329
+ // Apply initial volume using the resource's VolumeTransformer
330
+ if (this.volumeInterval) {
331
+ clearInterval(this.volumeInterval);
332
+ this.volumeInterval = null;
333
+ }
334
+ this.currentResource.volume?.setVolume(this.volume / 100);
114
335
 
115
- this.debug(`[Player] Playing resource for track: ${track.title}`);
116
- this.audioPlayer.play(this.currentResource);
336
+ this.debug(`[Player] Playing resource for track: ${track.title}`);
337
+ this.audioPlayer.play(this.currentResource);
117
338
 
118
- await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 5_000);
119
- return true;
339
+ await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 5_000);
340
+ return true;
341
+ } else if (streamInfo && !(streamInfo as any).stream) {
342
+ // Extension đang xử lý phát nhạc (như Lavalink) - chỉ đánh dấu đang phát
343
+ this.debug(`[Player] Extension is handling playback for track: ${track.title}`);
344
+ this.isPlaying = true;
345
+ this.isPaused = false;
346
+ this.emit("trackStart", track);
347
+ return true;
348
+ } else {
349
+ throw new Error(`No stream available for track: ${track.title}`);
350
+ }
120
351
  } catch (error) {
121
352
  this.debug(`[Player] startTrack error:`, error);
122
353
  this.emit("playerError", error as Error, track);
@@ -136,11 +367,6 @@ export class Player extends EventEmitter {
136
367
  }
137
368
  }
138
369
 
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
370
  private debug(message?: any, ...optionalParams: any[]): void {
145
371
  if (this.listenerCount("debug") > 0) {
146
372
  this.emit("debug", message, ...optionalParams);
@@ -184,6 +410,7 @@ export class Player extends EventEmitter {
184
410
  this.volume = this.options.volume || 100;
185
411
  this.userdata = this.options.userdata;
186
412
  this.setupEventListeners();
413
+ this.extensionContext = Object.freeze({ player: this, manager });
187
414
 
188
415
  // Optionally pre-create the TTS AudioPlayer
189
416
  if (this.options?.tts?.createPlayer) {
@@ -272,6 +499,14 @@ export class Player extends EventEmitter {
272
499
  return this.pluginManager.unregister(name);
273
500
  }
274
501
 
502
+ /**
503
+ * Connect to a voice channel
504
+ *
505
+ * @param {VoiceChannel} channel - Discord voice channel
506
+ * @returns {Promise<VoiceConnection>} The voice connection
507
+ * @example
508
+ * await player.connect(voiceChannel);
509
+ */
275
510
  async connect(channel: VoiceChannel): Promise<VoiceConnection> {
276
511
  try {
277
512
  this.debug(`[Player] Connecting to voice channel: ${channel.id}`);
@@ -307,15 +542,34 @@ export class Player extends EventEmitter {
307
542
  }
308
543
  }
309
544
 
545
+ /**
546
+ * Search for tracks using the player's extensions and plugins
547
+ *
548
+ * @param {string} query - The query to search for
549
+ * @param {string} requestedBy - The user ID who requested the search
550
+ * @returns {Promise<SearchResult>} The search result
551
+ * @example
552
+ * const result = await player.search("Never Gonna Give You Up", userId);
553
+ * console.log(`Search result: ${result.tracks.length} tracks`);
554
+ */
310
555
  async search(query: string, requestedBy: string): Promise<SearchResult> {
311
556
  this.debug(`[Player] Search called with query: ${query}, requestedBy: ${requestedBy}`);
557
+ const extensionResult = await this.extensionsProvideSearch(query, requestedBy);
558
+ if (extensionResult && Array.isArray(extensionResult.tracks) && extensionResult.tracks.length > 0) {
559
+ this.debug(`[Player] Extension handled search for query: ${query}`);
560
+ return extensionResult;
561
+ }
312
562
  const plugins = this.pluginManager.getAll();
313
563
  let lastError: any = null;
314
564
 
315
565
  for (const p of plugins) {
316
566
  try {
317
567
  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}`);
568
+ const res = await withTimeout(
569
+ p.search(query, requestedBy),
570
+ this.options.extractorTimeout ?? 15000,
571
+ `Search operation timed out for ${p.name}`,
572
+ );
319
573
  if (res && Array.isArray(res.tracks) && res.tracks.length > 0) {
320
574
  this.debug(`[Player] Plugin '${p.name}' returned ${res.tracks.length} tracks`);
321
575
  return res;
@@ -333,23 +587,63 @@ export class Player extends EventEmitter {
333
587
  throw new Error(`No plugin found to handle: ${query}`);
334
588
  }
335
589
 
590
+ /**
591
+ * Play a track or search query
592
+ *
593
+ * @param {string | Track} query - Track URL, search query, or Track object
594
+ * @param {string} requestedBy - User ID who requested the track
595
+ * @returns {Promise<boolean>} True if playback started successfully
596
+ * @example
597
+ * await player.play("Never Gonna Give You Up", userId);
598
+ * await player.play("https://youtube.com/watch?v=dQw4w9WgXcQ", userId);
599
+ * await player.play("tts: Hello everyone!", userId);
600
+ */
336
601
  async play(query: string | Track, requestedBy?: string): Promise<boolean> {
602
+ this.debug(`[Player] Play called with query: ${typeof query === "string" ? query : query?.title}`);
603
+ this.clearLeaveTimeout();
604
+ let tracksToAdd: Track[] = [];
605
+ let isPlaylist = false;
606
+ let effectiveRequest: ExtensionPlayRequest = { query, requestedBy };
607
+ let hookResponse: ExtensionPlayResponse = {};
608
+
337
609
  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;
610
+ const hookOutcome = await this.runBeforePlayHooks(effectiveRequest);
611
+ effectiveRequest = hookOutcome.request;
612
+ hookResponse = hookOutcome.response;
613
+ if (effectiveRequest.requestedBy === undefined) {
614
+ effectiveRequest.requestedBy = requestedBy;
615
+ }
346
616
 
617
+ const hookTracks = Array.isArray(hookResponse.tracks) ? hookResponse.tracks : undefined;
618
+
619
+ if (hookResponse.handled && (!hookTracks || hookTracks.length === 0)) {
620
+ const handledPayload: ExtensionAfterPlayPayload = {
621
+ success: hookResponse.success ?? true,
622
+ query: effectiveRequest.query,
623
+ requestedBy: effectiveRequest.requestedBy,
624
+ tracks: [],
625
+ isPlaylist: hookResponse.isPlaylist ?? false,
626
+ error: hookResponse.error,
627
+ };
628
+ await this.runAfterPlayHooks(handledPayload);
629
+ if (hookResponse.error) {
630
+ this.emit("playerError", hookResponse.error);
631
+ }
632
+ return hookResponse.success ?? true;
633
+ }
634
+
635
+ if (hookTracks && hookTracks.length > 0) {
636
+ tracksToAdd = hookTracks;
637
+ isPlaylist = hookResponse.isPlaylist ?? hookTracks.length > 1;
638
+ } else if (typeof effectiveRequest.query === "string") {
639
+ const searchResult = await this.search(effectiveRequest.query, effectiveRequest.requestedBy || "Unknown");
640
+ tracksToAdd = searchResult.tracks;
347
641
  if (searchResult.playlist) {
348
642
  isPlaylist = true;
349
643
  this.debug(`[Player] Added playlist: ${searchResult.playlist.name} (${tracksToAdd.length} tracks)`);
350
644
  }
351
- } else {
352
- tracksToAdd = [query];
645
+ } else if (effectiveRequest.query) {
646
+ tracksToAdd = [effectiveRequest.query as Track];
353
647
  }
354
648
 
355
649
  if (tracksToAdd.length === 0) {
@@ -357,7 +651,6 @@ export class Player extends EventEmitter {
357
651
  throw new Error("No tracks found");
358
652
  }
359
653
 
360
- // If a TTS track is requested and interrupt mode is enabled, handle it separately
361
654
  const isTTS = (t: Track | undefined) => {
362
655
  if (!t) return false;
363
656
  try {
@@ -367,7 +660,8 @@ export class Player extends EventEmitter {
367
660
  }
368
661
  };
369
662
 
370
- const queryLooksTTS = typeof query === "string" && query.trim().toLowerCase().startsWith("tts");
663
+ const queryLooksTTS =
664
+ typeof effectiveRequest.query === "string" && effectiveRequest.query.trim().toLowerCase().startsWith("tts");
371
665
 
372
666
  if (
373
667
  !isPlaylist &&
@@ -375,9 +669,15 @@ export class Player extends EventEmitter {
375
669
  this.options?.tts?.interrupt !== false &&
376
670
  (isTTS(tracksToAdd[0]) || queryLooksTTS)
377
671
  ) {
378
- // Interrupt music playback with TTS (do not modify the music queue)
379
672
  this.debug(`[Player] Interrupting with TTS: ${tracksToAdd[0].title}`);
380
673
  await this.interruptWithTTSTrack(tracksToAdd[0]);
674
+ await this.runAfterPlayHooks({
675
+ success: true,
676
+ query: effectiveRequest.query,
677
+ requestedBy: effectiveRequest.requestedBy,
678
+ tracks: tracksToAdd,
679
+ isPlaylist,
680
+ });
381
681
  return true;
382
682
  }
383
683
 
@@ -385,17 +685,30 @@ export class Player extends EventEmitter {
385
685
  this.queue.addMultiple(tracksToAdd);
386
686
  this.emit("queueAddList", tracksToAdd);
387
687
  } else {
388
- this.queue.add(tracksToAdd?.[0]);
389
- this.emit("queueAdd", tracksToAdd?.[0]);
688
+ this.queue.add(tracksToAdd[0]);
689
+ this.emit("queueAdd", tracksToAdd[0]);
390
690
  }
391
691
 
392
- // Start playing if not already playing
393
- if (!this.isPlaying) {
394
- return this.playNext();
395
- }
692
+ const started = !this.isPlaying ? await this.playNext() : true;
396
693
 
397
- return true;
694
+ await this.runAfterPlayHooks({
695
+ success: started,
696
+ query: effectiveRequest.query,
697
+ requestedBy: effectiveRequest.requestedBy,
698
+ tracks: tracksToAdd,
699
+ isPlaylist,
700
+ });
701
+
702
+ return started;
398
703
  } catch (error) {
704
+ await this.runAfterPlayHooks({
705
+ success: false,
706
+ query: effectiveRequest.query,
707
+ requestedBy: effectiveRequest.requestedBy,
708
+ tracks: tracksToAdd,
709
+ isPlaylist,
710
+ error: error as Error,
711
+ });
399
712
  this.debug(`[Player] Play error:`, error);
400
713
  this.emit("playerError", error as Error);
401
714
  return false;
@@ -405,6 +718,11 @@ export class Player extends EventEmitter {
405
718
  /**
406
719
  * Interrupt current music with a TTS track. Pauses music, swaps the
407
720
  * subscription to a dedicated TTS player, plays TTS, then resumes.
721
+ *
722
+ * @param {Track} track - The track to interrupt with
723
+ * @returns {Promise<void>}
724
+ * @example
725
+ * await player.interruptWithTTSTrack(track);
408
726
  */
409
727
  public async interruptWithTTSTrack(track: Track): Promise<void> {
410
728
  this.ttsQueue.push(track);
@@ -413,7 +731,13 @@ export class Player extends EventEmitter {
413
731
  }
414
732
  }
415
733
 
416
- /** Play queued TTS items sequentially */
734
+ /**
735
+ * Play queued TTS items sequentially
736
+ *
737
+ * @returns {Promise<void>}
738
+ * @example
739
+ * await player.playNextTTS();
740
+ */
417
741
  private async playNextTTS(): Promise<void> {
418
742
  const next = this.ttsQueue.shift();
419
743
  if (!next) return;
@@ -488,15 +812,16 @@ export class Player extends EventEmitter {
488
812
 
489
813
  let streamInfo: any;
490
814
  try {
491
- streamInfo = await this.withTimeout(plugin.getStream(track), "getStream timed out");
815
+ streamInfo = await withTimeout(plugin.getStream(track), this.options.extractorTimeout ?? 15000, "getStream timed out");
492
816
  } catch (streamError) {
493
817
  // try fallbacks
494
818
  const allplugs = this.pluginManager.getAll();
495
819
  for (const p of allplugs) {
496
820
  if (typeof (p as any).getFallback !== "function") continue;
497
821
  try {
498
- streamInfo = await this.withTimeout(
822
+ streamInfo = await withTimeout(
499
823
  (p as any).getFallback(track),
824
+ this.options.extractorTimeout ?? 15000,
500
825
  `getFallback timed out for plugin ${(p as any).name}`,
501
826
  );
502
827
  if (!streamInfo?.stream) continue;
@@ -544,11 +869,12 @@ export class Player extends EventEmitter {
544
869
  for (const p of candidates) {
545
870
  try {
546
871
  this.debug(`[Player] Trying related from plugin: ${p.name}`);
547
- const related = await this.withTimeout(
872
+ const related = await withTimeout(
548
873
  (p as any).getRelatedTracks(lastTrack.url, {
549
874
  limit: 10,
550
875
  history: this.queue.previousTracks,
551
876
  }),
877
+ this.options.extractorTimeout ?? 15000,
552
878
  `getRelatedTracks timed out for ${p.name}`,
553
879
  );
554
880
 
@@ -606,6 +932,14 @@ export class Player extends EventEmitter {
606
932
  }
607
933
  }
608
934
 
935
+ /**
936
+ * Pause the current track
937
+ *
938
+ * @returns {boolean} True if paused successfully
939
+ * @example
940
+ * const paused = player.pause();
941
+ * console.log(`Paused: ${paused}`);
942
+ */
609
943
  pause(): boolean {
610
944
  this.debug(`[Player] pause called`);
611
945
  if (this.isPlaying && !this.isPaused) {
@@ -614,6 +948,14 @@ export class Player extends EventEmitter {
614
948
  return false;
615
949
  }
616
950
 
951
+ /**
952
+ * Resume the current track
953
+ *
954
+ * @returns {boolean} True if resumed successfully
955
+ * @example
956
+ * const resumed = player.resume();
957
+ * console.log(`Resumed: ${resumed}`);
958
+ */
617
959
  resume(): boolean {
618
960
  this.debug(`[Player] resume called`);
619
961
  if (this.isPaused) {
@@ -630,6 +972,14 @@ export class Player extends EventEmitter {
630
972
  return false;
631
973
  }
632
974
 
975
+ /**
976
+ * Stop the current track
977
+ *
978
+ * @returns {boolean} True if stopped successfully
979
+ * @example
980
+ * const stopped = player.stop();
981
+ * console.log(`Stopped: ${stopped}`);
982
+ */
633
983
  stop(): boolean {
634
984
  this.debug(`[Player] stop called`);
635
985
  this.queue.clear();
@@ -640,6 +990,15 @@ export class Player extends EventEmitter {
640
990
  return result;
641
991
  }
642
992
 
993
+ /**
994
+ * Skip to the next track
995
+ *
996
+ * @returns {boolean} True if skipped successfully
997
+ * @example
998
+ * const skipped = player.skip();
999
+ * console.log(`Skipped: ${skipped}`);
1000
+ */
1001
+
643
1002
  skip(): boolean {
644
1003
  this.debug(`[Player] skip called`);
645
1004
  if (this.isPlaying || this.isPaused) {
@@ -651,6 +1010,11 @@ export class Player extends EventEmitter {
651
1010
 
652
1011
  /**
653
1012
  * Go back to the previous track in history and play it.
1013
+ *
1014
+ * @returns {Promise<boolean>} True if previous track was played successfully
1015
+ * @example
1016
+ * const previous = await player.previous();
1017
+ * console.log(`Previous: ${previous}`);
654
1018
  */
655
1019
  async previous(): Promise<boolean> {
656
1020
  this.debug(`[Player] previous called`);
@@ -661,14 +1025,41 @@ export class Player extends EventEmitter {
661
1025
  return this.startTrack(track);
662
1026
  }
663
1027
 
1028
+ /**
1029
+ * Loop the current track
1030
+ *
1031
+ * @param {LoopMode} mode - The loop mode to set
1032
+ * @returns {LoopMode} The loop mode
1033
+ * @example
1034
+ * const loopMode = player.loop("track");
1035
+ * console.log(`Loop mode: ${loopMode}`);
1036
+ */
664
1037
  loop(mode?: LoopMode): LoopMode {
665
1038
  return this.queue.loop(mode);
666
1039
  }
667
1040
 
1041
+ /**
1042
+ * Set the auto-play mode
1043
+ *
1044
+ * @param {boolean} mode - The auto-play mode to set
1045
+ * @returns {boolean} The auto-play mode
1046
+ * @example
1047
+ * const autoPlayMode = player.autoPlay(true);
1048
+ * console.log(`Auto-play mode: ${autoPlayMode}`);
1049
+ */
668
1050
  autoPlay(mode?: boolean): boolean {
669
1051
  return this.queue.autoPlay(mode);
670
1052
  }
671
1053
 
1054
+ /**
1055
+ * Set the volume of the current track
1056
+ *
1057
+ * @param {number} volume - The volume to set
1058
+ * @returns {boolean} True if volume was set successfully
1059
+ * @example
1060
+ * const volumeSet = player.setVolume(50);
1061
+ * console.log(`Volume set: ${volumeSet}`);
1062
+ */
672
1063
  setVolume(volume: number): boolean {
673
1064
  this.debug(`[Player] setVolume called: ${volume}`);
674
1065
  if (volume < 0 || volume > 200) return false;
@@ -700,11 +1091,25 @@ export class Player extends EventEmitter {
700
1091
  return true;
701
1092
  }
702
1093
 
1094
+ /**
1095
+ * Shuffle the queue
1096
+ *
1097
+ * @returns {void}
1098
+ * @example
1099
+ * player.shuffle();
1100
+ */
703
1101
  shuffle(): void {
704
1102
  this.debug(`[Player] shuffle called`);
705
1103
  this.queue.shuffle();
706
1104
  }
707
1105
 
1106
+ /**
1107
+ * Clear the queue
1108
+ *
1109
+ * @returns {void}
1110
+ * @example
1111
+ * player.clearQueue();
1112
+ */
708
1113
  clearQueue(): void {
709
1114
  this.debug(`[Player] clearQueue called`);
710
1115
  this.queue.clear();
@@ -715,6 +1120,14 @@ export class Player extends EventEmitter {
715
1120
  * - If `query` is a string, performs a search and inserts resulting tracks (playlist supported).
716
1121
  * - If a Track or Track[] is provided, inserts directly.
717
1122
  * Does not auto-start playback; it only modifies the queue.
1123
+ *
1124
+ * @param {string | Track | Track[]} query - The track or tracks to insert
1125
+ * @param {number} index - The index to insert the tracks at
1126
+ * @param {string} requestedBy - The user ID who requested the insert
1127
+ * @returns {Promise<boolean>} True if the tracks were inserted successfully
1128
+ * @example
1129
+ * const inserted = await player.insert("Song Name", 0, userId);
1130
+ * console.log(`Inserted: ${inserted}`);
718
1131
  */
719
1132
  async insert(query: string | Track | Track[], index: number, requestedBy?: string): Promise<boolean> {
720
1133
  try {
@@ -756,6 +1169,15 @@ export class Player extends EventEmitter {
756
1169
  }
757
1170
  }
758
1171
 
1172
+ /**
1173
+ * Remove a track from the queue
1174
+ *
1175
+ * @param {number} index - The index of the track to remove
1176
+ * @returns {Track | null} The removed track or null
1177
+ * @example
1178
+ * const removed = player.remove(0);
1179
+ * console.log(`Removed: ${removed?.title}`);
1180
+ */
759
1181
  remove(index: number): Track | null {
760
1182
  this.debug(`[Player] remove called for index: ${index}`);
761
1183
  const track = this.queue.remove(index);
@@ -765,6 +1187,15 @@ export class Player extends EventEmitter {
765
1187
  return track;
766
1188
  }
767
1189
 
1190
+ /**
1191
+ * Get the progress bar of the current track
1192
+ *
1193
+ * @param {ProgressBarOptions} options - The options for the progress bar
1194
+ * @returns {string} The progress bar
1195
+ * @example
1196
+ * const progressBar = player.getProgressBar();
1197
+ * console.log(`Progress bar: ${progressBar}`);
1198
+ */
768
1199
  getProgressBar(options: ProgressBarOptions = {}): string {
769
1200
  const { size = 20, barChar = "▬", progressChar = "🔘" } = options;
770
1201
  const track = this.queue.currentTrack;
@@ -782,6 +1213,42 @@ export class Player extends EventEmitter {
782
1213
  return `${this.formatTime(current)} | ${bar} | ${this.formatTime(total)}`;
783
1214
  }
784
1215
 
1216
+ /**
1217
+ * Get the time of the current track
1218
+ *
1219
+ * @returns {Object} The time of the current track
1220
+ * @example
1221
+ * const time = player.getTime();
1222
+ * console.log(`Time: ${time.current}`);
1223
+ */
1224
+ getTime() {
1225
+ const resource = this.currentResource;
1226
+ const track = this.queue.currentTrack;
1227
+ if (!track || !resource)
1228
+ return {
1229
+ current: 0,
1230
+ total: 0,
1231
+ format: "00:00",
1232
+ };
1233
+
1234
+ const total = track.duration > 1000 ? track.duration : track.duration * 1000;
1235
+
1236
+ return {
1237
+ current: resource?.playbackDuration,
1238
+ total: total,
1239
+ format: this.formatTime(resource.playbackDuration),
1240
+ };
1241
+ }
1242
+
1243
+ /**
1244
+ * Format the time in the format of HH:MM:SS
1245
+ *
1246
+ * @param {number} ms - The time in milliseconds
1247
+ * @returns {string} The formatted time
1248
+ * @example
1249
+ * const formattedTime = player.formatTime(1000);
1250
+ * console.log(`Formatted time: ${formattedTime}`);
1251
+ */
785
1252
  formatTime(ms: number): string {
786
1253
  const totalSeconds = Math.floor(ms / 1000);
787
1254
  const hours = Math.floor(totalSeconds / 3600);
@@ -808,6 +1275,13 @@ export class Player extends EventEmitter {
808
1275
  }
809
1276
  }
810
1277
 
1278
+ /**
1279
+ * Destroy the player
1280
+ *
1281
+ * @returns {void}
1282
+ * @example
1283
+ * player.destroy();
1284
+ */
811
1285
  destroy(): void {
812
1286
  this.debug(`[Player] destroy called`);
813
1287
  if (this.leaveTimeout) {
@@ -831,36 +1305,99 @@ export class Player extends EventEmitter {
831
1305
 
832
1306
  this.queue.clear();
833
1307
  this.pluginManager.clear();
1308
+ for (const extension of [...this.extensions]) {
1309
+ this.invokeExtensionLifecycle(extension, "onDestroy");
1310
+ if (extension.player === this) {
1311
+ extension.player = null;
1312
+ }
1313
+ }
1314
+ this.extensions = [];
834
1315
  this.isPlaying = false;
835
1316
  this.isPaused = false;
836
1317
  this.emit("playerDestroy");
837
1318
  this.removeAllListeners();
838
1319
  }
839
1320
 
840
- // Getters
1321
+ /**
1322
+ * Get the size of the queue
1323
+ *
1324
+ * @returns {number} The size of the queue
1325
+ * @example
1326
+ * const queueSize = player.queueSize;
1327
+ * console.log(`Queue size: ${queueSize}`);
1328
+ */
841
1329
  get queueSize(): number {
842
1330
  return this.queue.size;
843
1331
  }
844
1332
 
1333
+ /**
1334
+ * Get the current track
1335
+ *
1336
+ * @returns {Track | null} The current track or null
1337
+ * @example
1338
+ * const currentTrack = player.currentTrack;
1339
+ * console.log(`Current track: ${currentTrack?.title}`);
1340
+ */
845
1341
  get currentTrack(): Track | null {
846
1342
  return this.queue.currentTrack;
847
1343
  }
848
1344
 
1345
+ /**
1346
+ * Get the previous track
1347
+ *
1348
+ * @returns {Track | null} The previous track or null
1349
+ * @example
1350
+ * const previousTrack = player.previousTrack;
1351
+ * console.log(`Previous track: ${previousTrack?.title}`);
1352
+ */
849
1353
  get previousTrack(): Track | null {
850
1354
  return this.queue.previousTracks?.at(-1) ?? null;
851
1355
  }
852
1356
 
1357
+ /**
1358
+ * Get the upcoming tracks
1359
+ *
1360
+ * @returns {Track[]} The upcoming tracks
1361
+ * @example
1362
+ * const upcomingTracks = player.upcomingTracks;
1363
+ * console.log(`Upcoming tracks: ${upcomingTracks.length}`);
1364
+ */
853
1365
  get upcomingTracks(): Track[] {
854
1366
  return this.queue.getTracks();
855
1367
  }
856
1368
 
1369
+ /**
1370
+ * Get the previous tracks
1371
+ *
1372
+ * @returns {Track[]} The previous tracks
1373
+ * @example
1374
+ * const previousTracks = player.previousTracks;
1375
+ * console.log(`Previous tracks: ${previousTracks.length}`);
1376
+ */
857
1377
  get previousTracks(): Track[] {
858
1378
  return this.queue.previousTracks;
859
1379
  }
860
1380
 
1381
+ /**
1382
+ * Get the available plugins
1383
+ *
1384
+ * @returns {string[]} The available plugins
1385
+ * @example
1386
+ * const availablePlugins = player.availablePlugins;
1387
+ * console.log(`Available plugins: ${availablePlugins.length}`);
1388
+ */
861
1389
  get availablePlugins(): string[] {
862
1390
  return this.pluginManager.getAll().map((p) => p.name);
863
1391
  }
1392
+
1393
+ /**
1394
+ * Get the related tracks
1395
+ *
1396
+ * @returns {Track[] | null} The related tracks or null
1397
+ * @example
1398
+ * const relatedTracks = player.relatedTracks;
1399
+ * console.log(`Related tracks: ${relatedTracks?.length}`);
1400
+ */
864
1401
  get relatedTracks(): Track[] | null {
865
1402
  return this.queue.relatedTracks();
866
1403
  }