ziplayer 0.0.9 → 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);
55
194
  }
195
+ }
196
+ return { request, response };
197
+ }
56
198
 
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;
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;
61
209
  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;
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);
230
+ }
231
+ }
232
+ return null;
233
+ }
234
+
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;
240
+ try {
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
+ }
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
+ }
346
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;
@@ -448,8 +772,15 @@ export class Player extends EventEmitter {
448
772
  // Derive timeout from resource/track duration when available, with a sensible cap
449
773
  const md: any = (resource as any)?.metadata ?? {};
450
774
  const declared =
451
- typeof md.duration === "number" ? md.duration : typeof next?.duration === "number" ? next.duration : undefined;
452
- const declaredMs = declared ? (declared > 1000 ? declared : declared * 1000) : undefined;
775
+ typeof md.duration === "number" ? md.duration
776
+ : typeof next?.duration === "number" ? next.duration
777
+ : undefined;
778
+ const declaredMs =
779
+ declared ?
780
+ declared > 1000 ?
781
+ declared
782
+ : declared * 1000
783
+ : undefined;
453
784
  const cap = this.options?.tts?.Max_Time_TTS ?? 60_000;
454
785
  const idleTimeout = declaredMs ? Math.min(cap, Math.max(1_000, declaredMs + 1_500)) : cap;
455
786
  await entersState(ttsPlayer, AudioPlayerStatus.Idle, idleTimeout).catch(() => null);
@@ -481,15 +812,16 @@ export class Player extends EventEmitter {
481
812
 
482
813
  let streamInfo: any;
483
814
  try {
484
- streamInfo = await this.withTimeout(plugin.getStream(track), "getStream timed out");
815
+ streamInfo = await withTimeout(plugin.getStream(track), this.options.extractorTimeout ?? 15000, "getStream timed out");
485
816
  } catch (streamError) {
486
817
  // try fallbacks
487
818
  const allplugs = this.pluginManager.getAll();
488
819
  for (const p of allplugs) {
489
820
  if (typeof (p as any).getFallback !== "function") continue;
490
821
  try {
491
- streamInfo = await this.withTimeout(
822
+ streamInfo = await withTimeout(
492
823
  (p as any).getFallback(track),
824
+ this.options.extractorTimeout ?? 15000,
493
825
  `getFallback timed out for plugin ${(p as any).name}`,
494
826
  );
495
827
  if (!streamInfo?.stream) continue;
@@ -537,11 +869,12 @@ export class Player extends EventEmitter {
537
869
  for (const p of candidates) {
538
870
  try {
539
871
  this.debug(`[Player] Trying related from plugin: ${p.name}`);
540
- const related = await this.withTimeout(
872
+ const related = await withTimeout(
541
873
  (p as any).getRelatedTracks(lastTrack.url, {
542
874
  limit: 10,
543
875
  history: this.queue.previousTracks,
544
876
  }),
877
+ this.options.extractorTimeout ?? 15000,
545
878
  `getRelatedTracks timed out for ${p.name}`,
546
879
  );
547
880
 
@@ -599,6 +932,14 @@ export class Player extends EventEmitter {
599
932
  }
600
933
  }
601
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
+ */
602
943
  pause(): boolean {
603
944
  this.debug(`[Player] pause called`);
604
945
  if (this.isPlaying && !this.isPaused) {
@@ -607,6 +948,14 @@ export class Player extends EventEmitter {
607
948
  return false;
608
949
  }
609
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
+ */
610
959
  resume(): boolean {
611
960
  this.debug(`[Player] resume called`);
612
961
  if (this.isPaused) {
@@ -623,6 +972,14 @@ export class Player extends EventEmitter {
623
972
  return false;
624
973
  }
625
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
+ */
626
983
  stop(): boolean {
627
984
  this.debug(`[Player] stop called`);
628
985
  this.queue.clear();
@@ -633,6 +990,15 @@ export class Player extends EventEmitter {
633
990
  return result;
634
991
  }
635
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
+
636
1002
  skip(): boolean {
637
1003
  this.debug(`[Player] skip called`);
638
1004
  if (this.isPlaying || this.isPaused) {
@@ -644,6 +1010,11 @@ export class Player extends EventEmitter {
644
1010
 
645
1011
  /**
646
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}`);
647
1018
  */
648
1019
  async previous(): Promise<boolean> {
649
1020
  this.debug(`[Player] previous called`);
@@ -654,14 +1025,41 @@ export class Player extends EventEmitter {
654
1025
  return this.startTrack(track);
655
1026
  }
656
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
+ */
657
1037
  loop(mode?: LoopMode): LoopMode {
658
1038
  return this.queue.loop(mode);
659
1039
  }
660
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
+ */
661
1050
  autoPlay(mode?: boolean): boolean {
662
1051
  return this.queue.autoPlay(mode);
663
1052
  }
664
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
+ */
665
1063
  setVolume(volume: number): boolean {
666
1064
  this.debug(`[Player] setVolume called: ${volume}`);
667
1065
  if (volume < 0 || volume > 200) return false;
@@ -693,11 +1091,25 @@ export class Player extends EventEmitter {
693
1091
  return true;
694
1092
  }
695
1093
 
1094
+ /**
1095
+ * Shuffle the queue
1096
+ *
1097
+ * @returns {void}
1098
+ * @example
1099
+ * player.shuffle();
1100
+ */
696
1101
  shuffle(): void {
697
1102
  this.debug(`[Player] shuffle called`);
698
1103
  this.queue.shuffle();
699
1104
  }
700
1105
 
1106
+ /**
1107
+ * Clear the queue
1108
+ *
1109
+ * @returns {void}
1110
+ * @example
1111
+ * player.clearQueue();
1112
+ */
701
1113
  clearQueue(): void {
702
1114
  this.debug(`[Player] clearQueue called`);
703
1115
  this.queue.clear();
@@ -708,6 +1120,14 @@ export class Player extends EventEmitter {
708
1120
  * - If `query` is a string, performs a search and inserts resulting tracks (playlist supported).
709
1121
  * - If a Track or Track[] is provided, inserts directly.
710
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}`);
711
1131
  */
712
1132
  async insert(query: string | Track | Track[], index: number, requestedBy?: string): Promise<boolean> {
713
1133
  try {
@@ -749,6 +1169,15 @@ export class Player extends EventEmitter {
749
1169
  }
750
1170
  }
751
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
+ */
752
1181
  remove(index: number): Track | null {
753
1182
  this.debug(`[Player] remove called for index: ${index}`);
754
1183
  const track = this.queue.remove(index);
@@ -758,6 +1187,15 @@ export class Player extends EventEmitter {
758
1187
  return track;
759
1188
  }
760
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
+ */
761
1199
  getProgressBar(options: ProgressBarOptions = {}): string {
762
1200
  const { size = 20, barChar = "▬", progressChar = "🔘" } = options;
763
1201
  const track = this.queue.currentTrack;
@@ -775,6 +1213,14 @@ export class Player extends EventEmitter {
775
1213
  return `${this.formatTime(current)} | ${bar} | ${this.formatTime(total)}`;
776
1214
  }
777
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
+ */
778
1224
  getTime() {
779
1225
  const resource = this.currentResource;
780
1226
  const track = this.queue.currentTrack;
@@ -794,6 +1240,15 @@ export class Player extends EventEmitter {
794
1240
  };
795
1241
  }
796
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
+ */
797
1252
  formatTime(ms: number): string {
798
1253
  const totalSeconds = Math.floor(ms / 1000);
799
1254
  const hours = Math.floor(totalSeconds / 3600);
@@ -820,6 +1275,13 @@ export class Player extends EventEmitter {
820
1275
  }
821
1276
  }
822
1277
 
1278
+ /**
1279
+ * Destroy the player
1280
+ *
1281
+ * @returns {void}
1282
+ * @example
1283
+ * player.destroy();
1284
+ */
823
1285
  destroy(): void {
824
1286
  this.debug(`[Player] destroy called`);
825
1287
  if (this.leaveTimeout) {
@@ -843,36 +1305,99 @@ export class Player extends EventEmitter {
843
1305
 
844
1306
  this.queue.clear();
845
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 = [];
846
1315
  this.isPlaying = false;
847
1316
  this.isPaused = false;
848
1317
  this.emit("playerDestroy");
849
1318
  this.removeAllListeners();
850
1319
  }
851
1320
 
852
- // 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
+ */
853
1329
  get queueSize(): number {
854
1330
  return this.queue.size;
855
1331
  }
856
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
+ */
857
1341
  get currentTrack(): Track | null {
858
1342
  return this.queue.currentTrack;
859
1343
  }
860
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
+ */
861
1353
  get previousTrack(): Track | null {
862
1354
  return this.queue.previousTracks?.at(-1) ?? null;
863
1355
  }
864
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
+ */
865
1365
  get upcomingTracks(): Track[] {
866
1366
  return this.queue.getTracks();
867
1367
  }
868
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
+ */
869
1377
  get previousTracks(): Track[] {
870
1378
  return this.queue.previousTracks;
871
1379
  }
872
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
+ */
873
1389
  get availablePlugins(): string[] {
874
1390
  return this.pluginManager.getAll().map((p) => p.name);
875
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
+ */
876
1401
  get relatedTracks(): Track[] | null {
877
1402
  return this.queue.relatedTracks();
878
1403
  }