ziplayer 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,578 +1,808 @@
1
- import { EventEmitter } from "events";
2
- import {
3
- createAudioPlayer,
4
- createAudioResource,
5
- entersState,
6
- AudioPlayerStatus,
7
- VoiceConnection,
8
- AudioPlayer as DiscordAudioPlayer,
9
- VoiceConnectionStatus,
10
- NoSubscriberBehavior,
11
- joinVoiceChannel,
12
- AudioResource,
13
- StreamType,
14
- } from "@discordjs/voice";
15
-
16
- import { VoiceChannel } from "discord.js";
17
- import { Readable } from "stream";
18
- import { Track, PlayerOptions, PlayerEvents, SourcePlugin, SearchResult, ProgressBarOptions, LoopMode } from "../types";
19
- import { Queue } from "./Queue";
20
- import { PluginManager } from "../plugins";
21
- import type { PlayerManager } from "./PlayerManager";
22
- export declare interface Player {
23
- on<K extends keyof PlayerEvents>(event: K, listener: (...args: PlayerEvents[K]) => void): this;
24
- emit<K extends keyof PlayerEvents>(event: K, ...args: PlayerEvents[K]): boolean;
25
- }
26
-
27
- export class Player extends EventEmitter {
28
- public readonly guildId: string;
29
- public connection: VoiceConnection | null = null;
30
- public audioPlayer: DiscordAudioPlayer;
31
- public queue: Queue;
32
- public volume: number = 100;
33
- public isPlaying: boolean = false;
34
- public isPaused: boolean = false;
35
- public options: PlayerOptions;
36
- public pluginManager: PluginManager;
37
- public userdata?: Record<string, any>;
38
- private manager: PlayerManager;
39
- private leaveTimeout: NodeJS.Timeout | null = null;
40
- private currentResource: AudioResource | null = null;
41
- private volumeInterval: NodeJS.Timeout | null = null;
42
- private skipLoop = false;
43
-
44
- private withTimeout<T>(promise: Promise<T>, message: string): Promise<T> {
45
- const timeout = this.options.extractorTimeout ?? 15000;
46
- return Promise.race([promise, new Promise<never>((_, reject) => setTimeout(() => reject(new Error(message)), timeout))]);
47
- }
48
-
49
- private debug(message?: any, ...optionalParams: any[]): void {
50
- if (this.listenerCount("debug") > 0) {
51
- this.emit("debug", message, ...optionalParams);
52
- }
53
- }
54
-
55
- constructor(guildId: string, options: PlayerOptions = {}, manager: PlayerManager) {
56
- super();
57
- this.debug(`[Player] Constructor called for guildId: ${guildId}`);
58
- this.guildId = guildId;
59
- this.queue = new Queue();
60
- this.manager = manager;
61
- this.audioPlayer = createAudioPlayer({
62
- behaviors: {
63
- noSubscriber: NoSubscriberBehavior.Pause,
64
- maxMissedFrames: 100,
65
- },
66
- });
67
-
68
- this.pluginManager = new PluginManager();
69
-
70
- this.options = {
71
- leaveOnEnd: true,
72
- leaveOnEmpty: true,
73
- leaveTimeout: 100000,
74
- volume: 100,
75
- quality: "high",
76
- extractorTimeout: 50000,
77
- selfDeaf: true,
78
- selfMute: false,
79
- ...options,
80
- };
81
-
82
- this.volume = this.options.volume || 100;
83
- this.userdata = this.options.userdata;
84
- this.setupEventListeners();
85
- }
86
-
87
- private setupEventListeners(): void {
88
- this.audioPlayer.on("stateChange", (oldState, newState) => {
89
- this.debug(`[Player] AudioPlayer stateChange from ${oldState.status} to ${newState.status}`);
90
- if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) {
91
- // Track ended
92
- const track = this.queue.currentTrack;
93
- if (track) {
94
- this.debug(`[Player] Track ended: ${track.title}`);
95
- this.emit("trackEnd", track);
96
- }
97
- this.playNext();
98
- } else if (
99
- newState.status === AudioPlayerStatus.Playing &&
100
- (oldState.status === AudioPlayerStatus.Idle || oldState.status === AudioPlayerStatus.Buffering)
101
- ) {
102
- // Track started
103
- this.isPlaying = true;
104
- this.isPaused = false;
105
- const track = this.queue.currentTrack;
106
- if (track) {
107
- this.debug(`[Player] Track started: ${track.title}`);
108
- this.emit("trackStart", track);
109
- }
110
- } else if (newState.status === AudioPlayerStatus.Paused && oldState.status !== AudioPlayerStatus.Paused) {
111
- // Track paused
112
- this.isPaused = true;
113
- const track = this.queue.currentTrack;
114
- if (track) {
115
- this.debug(`[Player] Player paused on track: ${track.title}`);
116
- this.emit("playerPause", track);
117
- }
118
- } else if (newState.status !== AudioPlayerStatus.Paused && oldState.status === AudioPlayerStatus.Paused) {
119
- // Track resumed
120
- this.isPaused = false;
121
- const track = this.queue.currentTrack;
122
- if (track) {
123
- this.debug(`[Player] Player resumed on track: ${track.title}`);
124
- this.emit("playerResume", track);
125
- }
126
- } else if (newState.status === AudioPlayerStatus.AutoPaused) {
127
- this.debug(`[Player] AudioPlayerStatus.AutoPaused`);
128
- } else if (newState.status === AudioPlayerStatus.Buffering) {
129
- this.debug(`[Player] AudioPlayerStatus.Buffering`);
130
- }
131
- });
132
- this.audioPlayer.on("error", (error) => {
133
- this.debug(`[Player] AudioPlayer error:`, error);
134
- this.emit("playerError", error, this.queue.currentTrack || undefined);
135
- this.playNext();
136
- });
137
-
138
- this.audioPlayer.on("debug", (...args) => {
139
- if (this.manager.debugEnabled) {
140
- this.emit("debug", ...args);
141
- }
142
- });
143
- }
144
-
145
- addPlugin(plugin: SourcePlugin): void {
146
- this.debug(`[Player] Adding plugin: ${plugin.name}`);
147
- this.pluginManager.register(plugin);
148
- }
149
-
150
- removePlugin(name: string): boolean {
151
- this.debug(`[Player] Removing plugin: ${name}`);
152
- return this.pluginManager.unregister(name);
153
- }
154
-
155
- async connect(channel: VoiceChannel): Promise<VoiceConnection> {
156
- try {
157
- this.debug(`[Player] Connecting to voice channel: ${channel.id}`);
158
- const connection = joinVoiceChannel({
159
- channelId: channel.id,
160
- guildId: channel.guildId,
161
- adapterCreator: channel.guild.voiceAdapterCreator as any,
162
- selfDeaf: this.options.selfDeaf ?? true,
163
- selfMute: this.options.selfMute ?? false,
164
- });
165
-
166
- await entersState(connection, VoiceConnectionStatus.Ready, 50_000);
167
- this.connection = connection;
168
-
169
- connection.on(VoiceConnectionStatus.Disconnected, () => {
170
- this.debug(`[Player] VoiceConnectionStatus.Disconnected`);
171
- this.destroy();
172
- });
173
-
174
- connection.on("error", (error) => {
175
- this.debug(`[Player] Voice connection error:`, error);
176
- this.emit("connectionError", error);
177
- });
178
- connection.subscribe(this.audioPlayer);
179
-
180
- if (this.leaveTimeout) {
181
- clearTimeout(this.leaveTimeout);
182
- this.leaveTimeout = null;
183
- }
184
- return this.connection;
185
- } catch (error) {
186
- this.debug(`[Player] Connection error:`, error);
187
- this.emit("connectionError", error as Error);
188
- this.connection?.destroy();
189
- throw error;
190
- }
191
- }
192
-
193
- async search(query: string, requestedBy: string): Promise<SearchResult> {
194
- this.debug(`[Player] Search called with query: ${query}, requestedBy: ${requestedBy}`);
195
- const plugins = this.pluginManager.getAll();
196
- let lastError: any = null;
197
-
198
- for (const p of plugins) {
199
- try {
200
- this.debug(`[Player] Trying plugin for search: ${p.name}`);
201
- const res = await this.withTimeout(p.search(query, requestedBy), `Search operation timed out for ${p.name}`);
202
- if (res && Array.isArray(res.tracks) && res.tracks.length > 0) {
203
- this.debug(`[Player] Plugin '${p.name}' returned ${res.tracks.length} tracks`);
204
- return res;
205
- }
206
- this.debug(`[Player] Plugin '${p.name}' returned no tracks`);
207
- } catch (error) {
208
- lastError = error;
209
- this.debug(`[Player] Search via plugin '${p.name}' failed:`, error);
210
- // Continue to next plugin
211
- }
212
- }
213
-
214
- this.debug(`[Player] No plugins returned results for query: ${query}`);
215
- if (lastError) this.emit("playerError", lastError as Error);
216
- throw new Error(`No plugin found to handle: ${query}`);
217
- }
218
-
219
- async play(query: string | Track, requestedBy?: string): Promise<boolean> {
220
- try {
221
- this.debug(`[Player] Play called with query: ${typeof query === "string" ? query : query?.title}`);
222
- let tracksToAdd: Track[] = [];
223
- let isPlaylist = false;
224
- if (typeof query === "string") {
225
- const searchResult = await this.search(query, requestedBy || "Unknown");
226
- tracksToAdd = searchResult.tracks;
227
-
228
- if (searchResult.playlist) {
229
- isPlaylist = true;
230
- this.debug(`[Player] Added playlist: ${searchResult.playlist.name} (${tracksToAdd.length} tracks)`);
231
- }
232
- } else {
233
- tracksToAdd = [query];
234
- }
235
-
236
- if (tracksToAdd.length === 0) {
237
- this.debug(`[Player] No tracks found for play`);
238
- throw new Error("No tracks found");
239
- }
240
-
241
- if (isPlaylist) {
242
- this.queue.addMultiple(tracksToAdd);
243
- this.emit("queueAddList", tracksToAdd);
244
- } else {
245
- this.queue.add(tracksToAdd?.[0]);
246
- this.emit("queueAdd", tracksToAdd?.[0]);
247
- }
248
-
249
- // Start playing if not already playing
250
- if (!this.isPlaying) {
251
- return this.playNext();
252
- }
253
-
254
- return true;
255
- } catch (error) {
256
- this.debug(`[Player] Play error:`, error);
257
- this.emit("playerError", error as Error);
258
- return false;
259
- }
260
- }
261
-
262
- private async generateWillNext(): Promise<void> {
263
- const willnext = this.queue.willNextTrack();
264
-
265
- const lastTrack = this.queue.previousTracks[this.queue.previousTracks.length - 1] ?? this.queue.currentTrack;
266
- const plugin = this.pluginManager.findPlugin(lastTrack.url) || this.pluginManager.get(lastTrack.source);
267
- if (plugin && typeof plugin.getRelatedTracks === "function") {
268
- try {
269
- const related = await this.withTimeout(
270
- plugin.getRelatedTracks(lastTrack.url, {
271
- limit: 10,
272
- history: this.queue.previousTracks,
273
- }),
274
- "getRelatedTracks timed out",
275
- );
276
-
277
- if (related && related.length > 0) {
278
- const randomchoice = Math.floor(Math.random() * related.length);
279
- const nextTrack = this.queue.nextTrack ? this.queue.nextTrack : related[randomchoice];
280
- this.queue.willNextTrack(nextTrack);
281
- this.debug(`[Player] Will next track if autoplay: ${nextTrack?.title}`);
282
- this.emit("willPlay", nextTrack, related);
283
- }
284
- } catch (err) {
285
- this.debug(`[Player] getRelatedTracks error:`, err);
286
- }
287
- }
288
- }
289
-
290
- private async playNext(): Promise<boolean> {
291
- this.debug(`[Player] playNext called`);
292
- const track = this.queue.next(this.skipLoop);
293
- this.skipLoop = false;
294
- if (!track) {
295
- if (this.queue.autoPlay()) {
296
- const willnext = this.queue.willNextTrack();
297
- console.log("willnext", willnext);
298
- if (willnext) {
299
- this.debug(`[Player] Auto-playing next track: ${willnext.title}`);
300
- this.queue.addMultiple([willnext]);
301
- return this.playNext();
302
- }
303
- }
304
-
305
- this.debug(`[Player] No next track in queue`);
306
- this.isPlaying = false;
307
- this.emit("queueEnd");
308
-
309
- if (this.options.leaveOnEnd) {
310
- this.scheduleLeave();
311
- }
312
- return false;
313
- }
314
-
315
- this.generateWillNext();
316
-
317
- try {
318
- // Find plugin that can handle this track
319
- const plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
320
-
321
- if (!plugin) {
322
- this.debug(`[Player] No plugin found for track: ${track.title}`);
323
- throw new Error(`No plugin found for track: ${track.title}`);
324
- }
325
-
326
- this.debug(`[Player] Getting stream for track: ${track.title}`);
327
- this.debug(`[Player] Using plugin: ${plugin.name}`);
328
- this.debug(`[Track] Track Info:`, track);
329
- let streamInfo;
330
- try {
331
- streamInfo = await this.withTimeout(plugin.getStream(track), "getStream timed out");
332
- } catch (streamError) {
333
- this.debug(`[Player] getStream failed, trying getFallback:`, streamError);
334
- const allplugs = this.pluginManager.getAll();
335
- for (const p of allplugs) {
336
- if (typeof p.getFallback !== "function") {
337
- continue;
338
- }
339
- try {
340
- streamInfo = await this.withTimeout(p.getFallback(track), `getFallback timed out for plugin ${p.name}`);
341
- if (!streamInfo.stream) continue;
342
- this.debug(`[Player] getFallback succeeded with plugin ${p.name} for track: ${track.title}`);
343
- break;
344
- } catch (fallbackError) {
345
- this.debug(`[Player] getFallback failed with plugin ${p.name}:`, fallbackError);
346
- }
347
- }
348
- if (!streamInfo?.stream) {
349
- throw new Error(`All getFallback attempts failed for track: ${track.title}`);
350
- }
351
- this.debug(streamInfo);
352
- }
353
-
354
- function mapToStreamType(type: string): StreamType {
355
- switch (type) {
356
- case "webm/opus":
357
- return StreamType.WebmOpus;
358
- case "ogg/opus":
359
- return StreamType.OggOpus;
360
- case "arbitrary":
361
- return StreamType.Arbitrary;
362
- default:
363
- return StreamType.Arbitrary;
364
- }
365
- }
366
-
367
- let stream: Readable = streamInfo.stream;
368
- let inputType = mapToStreamType(streamInfo.type);
369
-
370
- this.currentResource = createAudioResource(stream, {
371
- metadata: track,
372
- inputType,
373
- inlineVolume: true,
374
- });
375
-
376
- // Apply initial volume using the resource's VolumeTransformer
377
- if (this.volumeInterval) {
378
- clearInterval(this.volumeInterval);
379
- this.volumeInterval = null;
380
- }
381
- this.currentResource.volume?.setVolume(this.volume / 100);
382
-
383
- this.debug(`[Player] Playing resource for track: ${track.title}`);
384
- this.audioPlayer.play(this.currentResource);
385
-
386
- await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 5_000);
387
-
388
- return true;
389
- } catch (error) {
390
- this.debug(`[Player] playNext error:`, error);
391
- this.emit("playerError", error as Error, track);
392
- return this.playNext();
393
- }
394
- }
395
-
396
- pause(): boolean {
397
- this.debug(`[Player] pause called`);
398
- if (this.isPlaying && !this.isPaused) {
399
- return this.audioPlayer.pause();
400
- }
401
- return false;
402
- }
403
-
404
- resume(): boolean {
405
- this.debug(`[Player] resume called`);
406
- if (this.isPaused) {
407
- const result = this.audioPlayer.unpause();
408
- if (result) {
409
- const track = this.queue.currentTrack;
410
- if (track) {
411
- this.debug(`[Player] Player resumed on track: ${track.title}`);
412
- this.emit("playerResume", track);
413
- }
414
- }
415
- return result;
416
- }
417
- return false;
418
- }
419
-
420
- stop(): boolean {
421
- this.debug(`[Player] stop called`);
422
- this.queue.clear();
423
- const result = this.audioPlayer.stop();
424
- this.isPlaying = false;
425
- this.isPaused = false;
426
- this.emit("playerStop");
427
- return result;
428
- }
429
-
430
- skip(): boolean {
431
- this.debug(`[Player] skip called`);
432
- if (this.isPlaying || this.isPaused) {
433
- this.skipLoop = true;
434
- return this.audioPlayer.stop();
435
- }
436
- return !!this.playNext();
437
- }
438
-
439
- loop(mode?: LoopMode): LoopMode {
440
- return this.queue.loop(mode);
441
- }
442
-
443
- setVolume(volume: number): boolean {
444
- this.debug(`[Player] setVolume called: ${volume}`);
445
- if (volume < 0 || volume > 200) return false;
446
-
447
- const oldVolume = this.volume;
448
- this.volume = volume;
449
- const resourceVolume = this.currentResource?.volume;
450
-
451
- if (resourceVolume) {
452
- if (this.volumeInterval) clearInterval(this.volumeInterval);
453
-
454
- const start = resourceVolume.volume;
455
- const target = this.volume / 100;
456
- const steps = 10;
457
- let currentStep = 0;
458
-
459
- this.volumeInterval = setInterval(() => {
460
- currentStep++;
461
- const value = start + ((target - start) * currentStep) / steps;
462
- resourceVolume.setVolume(value);
463
- if (currentStep >= steps) {
464
- clearInterval(this.volumeInterval!);
465
- this.volumeInterval = null;
466
- }
467
- }, 500);
468
- }
469
-
470
- this.emit("volumeChange", oldVolume, volume);
471
- return true;
472
- }
473
-
474
- shuffle(): void {
475
- this.debug(`[Player] shuffle called`);
476
- this.queue.shuffle();
477
- }
478
-
479
- clearQueue(): void {
480
- this.debug(`[Player] clearQueue called`);
481
- this.queue.clear();
482
- }
483
-
484
- remove(index: number): Track | null {
485
- this.debug(`[Player] remove called for index: ${index}`);
486
- const track = this.queue.remove(index);
487
- if (track) {
488
- this.emit("queueRemove", track, index);
489
- }
490
- return track;
491
- }
492
-
493
- getProgressBar(options: ProgressBarOptions = {}): string {
494
- const { size = 20, barChar = "▬", progressChar = "🔘" } = options;
495
- const track = this.queue.currentTrack;
496
- const resource = this.currentResource;
497
- if (!track || !resource) return "";
498
-
499
- const total = track.duration > 1000 ? track.duration : track.duration * 1000;
500
- if (!total) return this.formatTime(resource.playbackDuration);
501
-
502
- const current = resource.playbackDuration;
503
- const ratio = Math.min(current / total, 1);
504
- const progress = Math.round(ratio * size);
505
- const bar = barChar.repeat(progress) + progressChar + barChar.repeat(size - progress);
506
-
507
- return `${this.formatTime(current)} ${bar} ${this.formatTime(total)}`;
508
- }
509
-
510
- private formatTime(ms: number): string {
511
- const totalSeconds = Math.floor(ms / 1000);
512
- const hours = Math.floor(totalSeconds / 3600);
513
- const minutes = Math.floor((totalSeconds % 3600) / 60);
514
- const seconds = totalSeconds % 60;
515
- const parts: string[] = [];
516
- if (hours > 0) parts.push(String(hours).padStart(2, "0"));
517
- parts.push(String(minutes).padStart(2, "0"));
518
- parts.push(String(seconds).padStart(2, "0"));
519
- return parts.join(":");
520
- }
521
-
522
- private scheduleLeave(): void {
523
- this.debug(`[Player] scheduleLeave called`);
524
- if (this.leaveTimeout) {
525
- clearTimeout(this.leaveTimeout);
526
- }
527
-
528
- if (this.options.leaveOnEmpty && this.options.leaveTimeout) {
529
- this.leaveTimeout = setTimeout(() => {
530
- this.debug(`[Player] Leaving voice channel after timeout`);
531
- this.destroy();
532
- }, this.options.leaveTimeout);
533
- }
534
- }
535
-
536
- destroy(): void {
537
- this.debug(`[Player] destroy called`);
538
- if (this.leaveTimeout) {
539
- clearTimeout(this.leaveTimeout);
540
- this.leaveTimeout = null;
541
- }
542
-
543
- this.audioPlayer.stop(true);
544
-
545
- if (this.connection) {
546
- this.connection.destroy();
547
- this.connection = null;
548
- }
549
-
550
- this.queue.clear();
551
- this.pluginManager.clear();
552
- this.isPlaying = false;
553
- this.isPaused = false;
554
- this.emit("playerDestroy");
555
- this.removeAllListeners();
556
- }
557
-
558
- // Getters
559
- get queueSize(): number {
560
- return this.queue.size;
561
- }
562
-
563
- get currentTrack(): Track | null {
564
- return this.queue.currentTrack;
565
- }
566
-
567
- get upcomingTracks(): Track[] {
568
- return this.queue.getTracks();
569
- }
570
-
571
- get previousTracks(): Track[] {
572
- return this.queue.previousTracks;
573
- }
574
-
575
- get availablePlugins(): string[] {
576
- return this.pluginManager.getAll().map((p) => p.name);
577
- }
578
- }
1
+ import { EventEmitter } from "events";
2
+ import {
3
+ createAudioPlayer,
4
+ createAudioResource,
5
+ entersState,
6
+ AudioPlayerStatus,
7
+ VoiceConnection,
8
+ AudioPlayer as DiscordAudioPlayer,
9
+ VoiceConnectionStatus,
10
+ NoSubscriberBehavior,
11
+ joinVoiceChannel,
12
+ AudioResource,
13
+ StreamType,
14
+ } from "@discordjs/voice";
15
+
16
+ import { VoiceChannel } from "discord.js";
17
+ import { Readable } from "stream";
18
+ import { Track, PlayerOptions, PlayerEvents, SourcePlugin, SearchResult, ProgressBarOptions, LoopMode } from "../types";
19
+ import { Queue } from "./Queue";
20
+ import { PluginManager } from "../plugins";
21
+ import type { PlayerManager } from "./PlayerManager";
22
+ export declare interface Player {
23
+ on<K extends keyof PlayerEvents>(event: K, listener: (...args: PlayerEvents[K]) => void): this;
24
+ emit<K extends keyof PlayerEvents>(event: K, ...args: PlayerEvents[K]): boolean;
25
+ }
26
+
27
+ export class Player extends EventEmitter {
28
+ public readonly guildId: string;
29
+ public connection: VoiceConnection | null = null;
30
+ public audioPlayer: DiscordAudioPlayer;
31
+ public queue: Queue;
32
+ public volume: number = 100;
33
+ public isPlaying: boolean = false;
34
+ public isPaused: boolean = false;
35
+ public options: PlayerOptions;
36
+ public pluginManager: PluginManager;
37
+ public userdata?: Record<string, any>;
38
+ private manager: PlayerManager;
39
+ private leaveTimeout: NodeJS.Timeout | null = null;
40
+ private currentResource: AudioResource | null = null;
41
+ private volumeInterval: NodeJS.Timeout | null = null;
42
+ private skipLoop = false;
43
+
44
+ // TTS support
45
+ private ttsPlayer: DiscordAudioPlayer | null = null;
46
+ private ttsQueue: Array<Track> = [];
47
+ private ttsActive = false;
48
+
49
+ private withTimeout<T>(promise: Promise<T>, message: string): Promise<T> {
50
+ const timeout = this.options.extractorTimeout ?? 15000;
51
+ return Promise.race([promise, new Promise<never>((_, reject) => setTimeout(() => reject(new Error(message)), timeout))]);
52
+ }
53
+
54
+ private debug(message?: any, ...optionalParams: any[]): void {
55
+ if (this.listenerCount("debug") > 0) {
56
+ this.emit("debug", message, ...optionalParams);
57
+ }
58
+ }
59
+
60
+ constructor(guildId: string, options: PlayerOptions = {}, manager: PlayerManager) {
61
+ super();
62
+ this.debug(`[Player] Constructor called for guildId: ${guildId}`);
63
+ this.guildId = guildId;
64
+ this.queue = new Queue();
65
+ this.manager = manager;
66
+ this.audioPlayer = createAudioPlayer({
67
+ behaviors: {
68
+ noSubscriber: NoSubscriberBehavior.Pause,
69
+ maxMissedFrames: 100,
70
+ },
71
+ });
72
+
73
+ this.pluginManager = new PluginManager();
74
+
75
+ this.options = {
76
+ leaveOnEnd: true,
77
+ leaveOnEmpty: true,
78
+ leaveTimeout: 100000,
79
+ volume: 100,
80
+ quality: "high",
81
+ extractorTimeout: 50000,
82
+ selfDeaf: true,
83
+ selfMute: false,
84
+ ...options,
85
+ tts: {
86
+ createPlayer: false,
87
+ interrupt: true,
88
+ volume: 100,
89
+ Max_Time_TTS: 60_000,
90
+ ...(options?.tts || {}),
91
+ },
92
+ };
93
+
94
+ this.volume = this.options.volume || 100;
95
+ this.userdata = this.options.userdata;
96
+ this.setupEventListeners();
97
+
98
+ // Optionally pre-create the TTS AudioPlayer
99
+ if (this.options?.tts?.createPlayer) {
100
+ this.ensureTTSPlayer();
101
+ }
102
+ }
103
+
104
+ private setupEventListeners(): void {
105
+ this.audioPlayer.on("stateChange", (oldState, newState) => {
106
+ this.debug(`[Player] AudioPlayer stateChange from ${oldState.status} to ${newState.status}`);
107
+ if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) {
108
+ // Track ended
109
+ const track = this.queue.currentTrack;
110
+ if (track) {
111
+ this.debug(`[Player] Track ended: ${track.title}`);
112
+ this.emit("trackEnd", track);
113
+ }
114
+ this.playNext();
115
+ } else if (
116
+ newState.status === AudioPlayerStatus.Playing &&
117
+ (oldState.status === AudioPlayerStatus.Idle || oldState.status === AudioPlayerStatus.Buffering)
118
+ ) {
119
+ // Track started
120
+ this.isPlaying = true;
121
+ this.isPaused = false;
122
+ const track = this.queue.currentTrack;
123
+ if (track) {
124
+ this.debug(`[Player] Track started: ${track.title}`);
125
+ this.emit("trackStart", track);
126
+ }
127
+ } else if (newState.status === AudioPlayerStatus.Paused && oldState.status !== AudioPlayerStatus.Paused) {
128
+ // Track paused
129
+ this.isPaused = true;
130
+ const track = this.queue.currentTrack;
131
+ if (track) {
132
+ this.debug(`[Player] Player paused on track: ${track.title}`);
133
+ this.emit("playerPause", track);
134
+ }
135
+ } else if (newState.status !== AudioPlayerStatus.Paused && oldState.status === AudioPlayerStatus.Paused) {
136
+ // Track resumed
137
+ this.isPaused = false;
138
+ const track = this.queue.currentTrack;
139
+ if (track) {
140
+ this.debug(`[Player] Player resumed on track: ${track.title}`);
141
+ this.emit("playerResume", track);
142
+ }
143
+ } else if (newState.status === AudioPlayerStatus.AutoPaused) {
144
+ this.debug(`[Player] AudioPlayerStatus.AutoPaused`);
145
+ } else if (newState.status === AudioPlayerStatus.Buffering) {
146
+ this.debug(`[Player] AudioPlayerStatus.Buffering`);
147
+ }
148
+ });
149
+ this.audioPlayer.on("error", (error) => {
150
+ this.debug(`[Player] AudioPlayer error:`, error);
151
+ this.emit("playerError", error, this.queue.currentTrack || undefined);
152
+ this.playNext();
153
+ });
154
+
155
+ this.audioPlayer.on("debug", (...args) => {
156
+ if (this.manager.debugEnabled) {
157
+ this.emit("debug", ...args);
158
+ }
159
+ });
160
+ }
161
+
162
+ private ensureTTSPlayer(): DiscordAudioPlayer {
163
+ if (this.ttsPlayer) return this.ttsPlayer;
164
+ this.ttsPlayer = createAudioPlayer({
165
+ behaviors: {
166
+ noSubscriber: NoSubscriberBehavior.Pause,
167
+ maxMissedFrames: 100,
168
+ },
169
+ });
170
+ this.ttsPlayer.on("error", (e) => this.debug("[TTS] error:", e));
171
+ return this.ttsPlayer;
172
+ }
173
+
174
+ addPlugin(plugin: SourcePlugin): void {
175
+ this.debug(`[Player] Adding plugin: ${plugin.name}`);
176
+ this.pluginManager.register(plugin);
177
+ }
178
+
179
+ removePlugin(name: string): boolean {
180
+ this.debug(`[Player] Removing plugin: ${name}`);
181
+ return this.pluginManager.unregister(name);
182
+ }
183
+
184
+ async connect(channel: VoiceChannel): Promise<VoiceConnection> {
185
+ try {
186
+ this.debug(`[Player] Connecting to voice channel: ${channel.id}`);
187
+ const connection = joinVoiceChannel({
188
+ channelId: channel.id,
189
+ guildId: channel.guildId,
190
+ adapterCreator: channel.guild.voiceAdapterCreator as any,
191
+ selfDeaf: this.options.selfDeaf ?? true,
192
+ selfMute: this.options.selfMute ?? false,
193
+ });
194
+
195
+ await entersState(connection, VoiceConnectionStatus.Ready, 50_000);
196
+ this.connection = connection;
197
+
198
+ connection.on(VoiceConnectionStatus.Disconnected, () => {
199
+ this.debug(`[Player] VoiceConnectionStatus.Disconnected`);
200
+ this.destroy();
201
+ });
202
+
203
+ connection.on("error", (error) => {
204
+ this.debug(`[Player] Voice connection error:`, error);
205
+ this.emit("connectionError", error);
206
+ });
207
+ connection.subscribe(this.audioPlayer);
208
+
209
+ if (this.leaveTimeout) {
210
+ clearTimeout(this.leaveTimeout);
211
+ this.leaveTimeout = null;
212
+ }
213
+ return this.connection;
214
+ } catch (error) {
215
+ this.debug(`[Player] Connection error:`, error);
216
+ this.emit("connectionError", error as Error);
217
+ this.connection?.destroy();
218
+ throw error;
219
+ }
220
+ }
221
+
222
+ async search(query: string, requestedBy: string): Promise<SearchResult> {
223
+ this.debug(`[Player] Search called with query: ${query}, requestedBy: ${requestedBy}`);
224
+ const plugins = this.pluginManager.getAll();
225
+ let lastError: any = null;
226
+
227
+ for (const p of plugins) {
228
+ try {
229
+ this.debug(`[Player] Trying plugin for search: ${p.name}`);
230
+ const res = await this.withTimeout(p.search(query, requestedBy), `Search operation timed out for ${p.name}`);
231
+ if (res && Array.isArray(res.tracks) && res.tracks.length > 0) {
232
+ this.debug(`[Player] Plugin '${p.name}' returned ${res.tracks.length} tracks`);
233
+ return res;
234
+ }
235
+ this.debug(`[Player] Plugin '${p.name}' returned no tracks`);
236
+ } catch (error) {
237
+ lastError = error;
238
+ this.debug(`[Player] Search via plugin '${p.name}' failed:`, error);
239
+ // Continue to next plugin
240
+ }
241
+ }
242
+
243
+ this.debug(`[Player] No plugins returned results for query: ${query}`);
244
+ if (lastError) this.emit("playerError", lastError as Error);
245
+ throw new Error(`No plugin found to handle: ${query}`);
246
+ }
247
+
248
+ async play(query: string | Track, requestedBy?: string): Promise<boolean> {
249
+ try {
250
+ this.debug(`[Player] Play called with query: ${typeof query === "string" ? query : query?.title}`);
251
+ let tracksToAdd: Track[] = [];
252
+ let isPlaylist = false;
253
+ if (typeof query === "string") {
254
+ const searchResult = await this.search(query, requestedBy || "Unknown");
255
+ tracksToAdd = searchResult.tracks;
256
+
257
+ if (searchResult.playlist) {
258
+ isPlaylist = true;
259
+ this.debug(`[Player] Added playlist: ${searchResult.playlist.name} (${tracksToAdd.length} tracks)`);
260
+ }
261
+ } else {
262
+ tracksToAdd = [query];
263
+ }
264
+
265
+ if (tracksToAdd.length === 0) {
266
+ this.debug(`[Player] No tracks found for play`);
267
+ throw new Error("No tracks found");
268
+ }
269
+
270
+ // If a TTS track is requested and interrupt mode is enabled, handle it separately
271
+ const isTTS = (t: Track | undefined) => {
272
+ if (!t) return false;
273
+ try {
274
+ return typeof t.source === "string" && t.source.toLowerCase().includes("tts");
275
+ } catch {
276
+ return false;
277
+ }
278
+ };
279
+
280
+ const queryLooksTTS = typeof query === "string" && query.trim().toLowerCase().startsWith("tts");
281
+
282
+ if (
283
+ !isPlaylist &&
284
+ tracksToAdd.length > 0 &&
285
+ this.options?.tts?.interrupt !== false &&
286
+ (isTTS(tracksToAdd[0]) || queryLooksTTS)
287
+ ) {
288
+ // Interrupt music playback with TTS (do not modify the music queue)
289
+ this.debug(`[Player] Interrupting with TTS: ${tracksToAdd[0].title}`);
290
+ await this.interruptWithTTSTrack(tracksToAdd[0]);
291
+ return true;
292
+ }
293
+
294
+ if (isPlaylist) {
295
+ this.queue.addMultiple(tracksToAdd);
296
+ this.emit("queueAddList", tracksToAdd);
297
+ } else {
298
+ this.queue.add(tracksToAdd?.[0]);
299
+ this.emit("queueAdd", tracksToAdd?.[0]);
300
+ }
301
+
302
+ // Start playing if not already playing
303
+ if (!this.isPlaying) {
304
+ return this.playNext();
305
+ }
306
+
307
+ return true;
308
+ } catch (error) {
309
+ this.debug(`[Player] Play error:`, error);
310
+ this.emit("playerError", error as Error);
311
+ return false;
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Interrupt current music with a TTS track. Pauses music, swaps the
317
+ * subscription to a dedicated TTS player, plays TTS, then resumes.
318
+ */
319
+ public async interruptWithTTSTrack(track: Track): Promise<void> {
320
+ this.ttsQueue.push(track);
321
+ if (!this.ttsActive) {
322
+ void this.playNextTTS();
323
+ }
324
+ }
325
+
326
+ /** Play queued TTS items sequentially */
327
+ private async playNextTTS(): Promise<void> {
328
+ const next = this.ttsQueue.shift();
329
+ if (!next) return;
330
+ this.ttsActive = true;
331
+
332
+ try {
333
+ if (!this.connection) throw new Error("No voice connection for TTS");
334
+ const ttsPlayer = this.ensureTTSPlayer();
335
+
336
+ // Build resource from plugin stream
337
+ const resource = await this.resourceFromTrack(next);
338
+ if (resource.volume) {
339
+ resource.volume.setVolume((this.options?.tts?.volume ?? this?.volume ?? 100) / 100);
340
+ }
341
+
342
+ const wasPlaying =
343
+ this.audioPlayer.state.status === AudioPlayerStatus.Playing ||
344
+ this.audioPlayer.state.status === AudioPlayerStatus.Buffering;
345
+
346
+ // Pause current music if any
347
+ try {
348
+ this.audioPlayer.pause(true);
349
+ } catch {}
350
+
351
+ // Swap subscription and play TTS
352
+ this.connection.subscribe(ttsPlayer);
353
+ this.emit("ttsStart", { track: next });
354
+ ttsPlayer.play(resource);
355
+
356
+ // Wait until TTS starts then finishes
357
+ await entersState(ttsPlayer, AudioPlayerStatus.Playing, 5_000).catch(() => null);
358
+ await entersState(ttsPlayer, AudioPlayerStatus.Idle, this.options?.tts?.Max_Time_TTS || 60_000).catch(() => null);
359
+
360
+ // Swap back and resume if needed
361
+ this.connection.subscribe(this.audioPlayer);
362
+ if (wasPlaying) {
363
+ try {
364
+ this.audioPlayer.unpause();
365
+ } catch {}
366
+ }
367
+ this.emit("ttsEnd");
368
+ } catch (err) {
369
+ this.debug("[TTS] error while playing:", err);
370
+ this.emit("playerError", err as Error);
371
+ } finally {
372
+ this.ttsActive = false;
373
+ if (this.ttsQueue.length > 0) {
374
+ await this.playNextTTS();
375
+ }
376
+ }
377
+ }
378
+
379
+ /** Build AudioResource for a given track using the plugin pipeline */
380
+ private async resourceFromTrack(track: Track): Promise<AudioResource> {
381
+ // Resolve plugin similar to playNext
382
+ const plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
383
+ if (!plugin) throw new Error(`No plugin found for track: ${track.title}`);
384
+
385
+ let streamInfo: any;
386
+ try {
387
+ streamInfo = await this.withTimeout(plugin.getStream(track), "getStream timed out");
388
+ } catch (streamError) {
389
+ // try fallbacks
390
+ const allplugs = this.pluginManager.getAll();
391
+ for (const p of allplugs) {
392
+ if (typeof (p as any).getFallback !== "function") continue;
393
+ try {
394
+ streamInfo = await this.withTimeout(
395
+ (p as any).getFallback(track),
396
+ `getFallback timed out for plugin ${(p as any).name}`,
397
+ );
398
+ if (!streamInfo?.stream) continue;
399
+ break;
400
+ } catch {}
401
+ }
402
+ if (!streamInfo?.stream) throw new Error(`All getFallback attempts failed for track: ${track.title}`);
403
+ }
404
+
405
+ const mapToStreamType = (type: string): StreamType => {
406
+ switch (type) {
407
+ case "webm/opus":
408
+ return StreamType.WebmOpus;
409
+ case "ogg/opus":
410
+ return StreamType.OggOpus;
411
+ case "arbitrary":
412
+ default:
413
+ return StreamType.Arbitrary;
414
+ }
415
+ };
416
+
417
+ const inputType = mapToStreamType(streamInfo.type);
418
+ return createAudioResource(streamInfo.stream, {
419
+ metadata: track,
420
+ inputType,
421
+ inlineVolume: true,
422
+ });
423
+ }
424
+
425
+ private async generateWillNext(): Promise<void> {
426
+ const lastTrack = this.queue.previousTracks[this.queue.previousTracks.length - 1] ?? this.queue.currentTrack;
427
+ if (!lastTrack) return;
428
+
429
+ // Build list of candidate plugins: preferred first, then others with getRelatedTracks
430
+ const preferred = this.pluginManager.findPlugin(lastTrack.url) || this.pluginManager.get(lastTrack.source);
431
+ const all = this.pluginManager.getAll();
432
+ const candidates = [...(preferred ? [preferred] : []), ...all.filter((p) => p !== preferred)].filter(
433
+ (p) => typeof (p as any).getRelatedTracks === "function",
434
+ );
435
+
436
+ for (const p of candidates) {
437
+ try {
438
+ this.debug(`[Player] Trying related from plugin: ${p.name}`);
439
+ const related = await this.withTimeout(
440
+ (p as any).getRelatedTracks(lastTrack.url, {
441
+ limit: 10,
442
+ history: this.queue.previousTracks,
443
+ }),
444
+ `getRelatedTracks timed out for ${p.name}`,
445
+ );
446
+
447
+ if (Array.isArray(related) && related.length > 0) {
448
+ const randomchoice = Math.floor(Math.random() * related.length);
449
+ const nextTrack = this.queue.nextTrack ? this.queue.nextTrack : related[randomchoice];
450
+ this.queue.willNextTrack(nextTrack);
451
+ this.debug(`[Player] Will next track if autoplay: ${nextTrack?.title} (via ${p.name})`);
452
+ this.emit("willPlay", nextTrack, related);
453
+ return; // success
454
+ }
455
+ this.debug(`[Player] ${p.name} returned no related tracks`);
456
+ } catch (err) {
457
+ this.debug(`[Player] getRelatedTracks error from ${p.name}:`, err);
458
+ // try next candidate
459
+ }
460
+ }
461
+ }
462
+
463
+ private async playNext(): Promise<boolean> {
464
+ this.debug(`[Player] playNext called`);
465
+ const track = this.queue.next(this.skipLoop);
466
+ this.skipLoop = false;
467
+ if (!track) {
468
+ if (this.queue.autoPlay()) {
469
+ const willnext = this.queue.willNextTrack();
470
+ console.log("willnext", willnext);
471
+ if (willnext) {
472
+ this.debug(`[Player] Auto-playing next track: ${willnext.title}`);
473
+ this.queue.addMultiple([willnext]);
474
+ return this.playNext();
475
+ }
476
+ }
477
+
478
+ this.debug(`[Player] No next track in queue`);
479
+ this.isPlaying = false;
480
+ this.emit("queueEnd");
481
+
482
+ if (this.options.leaveOnEnd) {
483
+ this.scheduleLeave();
484
+ }
485
+ return false;
486
+ }
487
+
488
+ this.generateWillNext();
489
+
490
+ try {
491
+ // Find plugin that can handle this track
492
+ const plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
493
+
494
+ if (!plugin) {
495
+ this.debug(`[Player] No plugin found for track: ${track.title}`);
496
+ throw new Error(`No plugin found for track: ${track.title}`);
497
+ }
498
+
499
+ this.debug(`[Player] Getting stream for track: ${track.title}`);
500
+ this.debug(`[Player] Using plugin: ${plugin.name}`);
501
+ this.debug(`[Track] Track Info:`, track);
502
+ let streamInfo;
503
+ try {
504
+ streamInfo = await this.withTimeout(plugin.getStream(track), "getStream timed out");
505
+ } catch (streamError) {
506
+ this.debug(`[Player] getStream failed, trying getFallback:`, streamError);
507
+ const allplugs = this.pluginManager.getAll();
508
+ for (const p of allplugs) {
509
+ if (typeof p.getFallback !== "function") {
510
+ continue;
511
+ }
512
+ try {
513
+ streamInfo = await this.withTimeout(p.getFallback(track), `getFallback timed out for plugin ${p.name}`);
514
+ if (!streamInfo.stream) continue;
515
+ this.debug(`[Player] getFallback succeeded with plugin ${p.name} for track: ${track.title}`);
516
+ break;
517
+ } catch (fallbackError) {
518
+ this.debug(`[Player] getFallback failed with plugin ${p.name}:`, fallbackError);
519
+ }
520
+ }
521
+ if (!streamInfo?.stream) {
522
+ throw new Error(`All getFallback attempts failed for track: ${track.title}`);
523
+ }
524
+ this.debug(streamInfo);
525
+ }
526
+
527
+ function mapToStreamType(type: string): StreamType {
528
+ switch (type) {
529
+ case "webm/opus":
530
+ return StreamType.WebmOpus;
531
+ case "ogg/opus":
532
+ return StreamType.OggOpus;
533
+ case "arbitrary":
534
+ return StreamType.Arbitrary;
535
+ default:
536
+ return StreamType.Arbitrary;
537
+ }
538
+ }
539
+
540
+ let stream: Readable = streamInfo.stream;
541
+ let inputType = mapToStreamType(streamInfo.type);
542
+
543
+ this.currentResource = createAudioResource(stream, {
544
+ metadata: track,
545
+ inputType,
546
+ inlineVolume: true,
547
+ });
548
+
549
+ // Apply initial volume using the resource's VolumeTransformer
550
+ if (this.volumeInterval) {
551
+ clearInterval(this.volumeInterval);
552
+ this.volumeInterval = null;
553
+ }
554
+ this.currentResource.volume?.setVolume(this.volume / 100);
555
+
556
+ this.debug(`[Player] Playing resource for track: ${track.title}`);
557
+ this.audioPlayer.play(this.currentResource);
558
+
559
+ await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 5_000);
560
+
561
+ return true;
562
+ } catch (error) {
563
+ this.debug(`[Player] playNext error:`, error);
564
+ this.emit("playerError", error as Error, track);
565
+ return this.playNext();
566
+ }
567
+ }
568
+
569
+ pause(): boolean {
570
+ this.debug(`[Player] pause called`);
571
+ if (this.isPlaying && !this.isPaused) {
572
+ return this.audioPlayer.pause();
573
+ }
574
+ return false;
575
+ }
576
+
577
+ resume(): boolean {
578
+ this.debug(`[Player] resume called`);
579
+ if (this.isPaused) {
580
+ const result = this.audioPlayer.unpause();
581
+ if (result) {
582
+ const track = this.queue.currentTrack;
583
+ if (track) {
584
+ this.debug(`[Player] Player resumed on track: ${track.title}`);
585
+ this.emit("playerResume", track);
586
+ }
587
+ }
588
+ return result;
589
+ }
590
+ return false;
591
+ }
592
+
593
+ stop(): boolean {
594
+ this.debug(`[Player] stop called`);
595
+ this.queue.clear();
596
+ const result = this.audioPlayer.stop();
597
+ this.isPlaying = false;
598
+ this.isPaused = false;
599
+ this.emit("playerStop");
600
+ return result;
601
+ }
602
+
603
+ skip(): boolean {
604
+ this.debug(`[Player] skip called`);
605
+ if (this.isPlaying || this.isPaused) {
606
+ this.skipLoop = true;
607
+ return this.audioPlayer.stop();
608
+ }
609
+ return !!this.playNext();
610
+ }
611
+
612
+ loop(mode?: LoopMode): LoopMode {
613
+ return this.queue.loop(mode);
614
+ }
615
+
616
+ autoPlay(mode?: boolean): boolean {
617
+ return this.queue.autoPlay(mode);
618
+ }
619
+
620
+ setVolume(volume: number): boolean {
621
+ this.debug(`[Player] setVolume called: ${volume}`);
622
+ if (volume < 0 || volume > 200) return false;
623
+
624
+ const oldVolume = this.volume;
625
+ this.volume = volume;
626
+ const resourceVolume = this.currentResource?.volume;
627
+
628
+ if (resourceVolume) {
629
+ if (this.volumeInterval) clearInterval(this.volumeInterval);
630
+
631
+ const start = resourceVolume.volume;
632
+ const target = this.volume / 100;
633
+ const steps = 10;
634
+ let currentStep = 0;
635
+
636
+ this.volumeInterval = setInterval(() => {
637
+ currentStep++;
638
+ const value = start + ((target - start) * currentStep) / steps;
639
+ resourceVolume.setVolume(value);
640
+ if (currentStep >= steps) {
641
+ clearInterval(this.volumeInterval!);
642
+ this.volumeInterval = null;
643
+ }
644
+ }, 300);
645
+ }
646
+
647
+ this.emit("volumeChange", oldVolume, volume);
648
+ return true;
649
+ }
650
+
651
+ shuffle(): void {
652
+ this.debug(`[Player] shuffle called`);
653
+ this.queue.shuffle();
654
+ }
655
+
656
+ clearQueue(): void {
657
+ this.debug(`[Player] clearQueue called`);
658
+ this.queue.clear();
659
+ }
660
+
661
+ /**
662
+ * Insert a track or list of tracks into the upcoming queue at a specific position (0 = play after current).
663
+ * - If `query` is a string, performs a search and inserts resulting tracks (playlist supported).
664
+ * - If a Track or Track[] is provided, inserts directly.
665
+ * Does not auto-start playback; it only modifies the queue.
666
+ */
667
+ async insert(query: string | Track | Track[], index: number, requestedBy?: string): Promise<boolean> {
668
+ try {
669
+ this.debug(`[Player] insert called at index ${index} with type: ${typeof query}`);
670
+ let tracksToAdd: Track[] = [];
671
+ let isPlaylist = false;
672
+
673
+ if (typeof query === "string") {
674
+ const searchResult = await this.search(query, requestedBy || "Unknown");
675
+ tracksToAdd = searchResult.tracks || [];
676
+ isPlaylist = !!searchResult.playlist;
677
+ } else if (Array.isArray(query)) {
678
+ tracksToAdd = query;
679
+ isPlaylist = query.length > 1;
680
+ } else if (query) {
681
+ tracksToAdd = [query];
682
+ }
683
+
684
+ if (!tracksToAdd || tracksToAdd.length === 0) {
685
+ this.debug(`[Player] insert: no tracks resolved`);
686
+ throw new Error("No tracks to insert");
687
+ }
688
+
689
+ if (tracksToAdd.length === 1) {
690
+ this.queue.insert(tracksToAdd[0], index);
691
+ this.emit("queueAdd", tracksToAdd[0]);
692
+ this.debug(`[Player] Inserted track at index ${index}: ${tracksToAdd[0].title}`);
693
+ } else {
694
+ this.queue.insertMultiple(tracksToAdd, index);
695
+ this.emit("queueAddList", tracksToAdd);
696
+ this.debug(`[Player] Inserted ${tracksToAdd.length} ${isPlaylist ? "playlist " : ""}tracks at index ${index}`);
697
+ }
698
+
699
+ return true;
700
+ } catch (error) {
701
+ this.debug(`[Player] insert error:`, error);
702
+ this.emit("playerError", error as Error);
703
+ return false;
704
+ }
705
+ }
706
+
707
+ remove(index: number): Track | null {
708
+ this.debug(`[Player] remove called for index: ${index}`);
709
+ const track = this.queue.remove(index);
710
+ if (track) {
711
+ this.emit("queueRemove", track, index);
712
+ }
713
+ return track;
714
+ }
715
+
716
+ getProgressBar(options: ProgressBarOptions = {}): string {
717
+ const { size = 20, barChar = "▬", progressChar = "🔘" } = options;
718
+ const track = this.queue.currentTrack;
719
+ const resource = this.currentResource;
720
+ if (!track || !resource) return "";
721
+
722
+ const total = track.duration > 1000 ? track.duration : track.duration * 1000;
723
+ if (!total) return this.formatTime(resource.playbackDuration);
724
+
725
+ const current = resource.playbackDuration;
726
+ const ratio = Math.min(current / total, 1);
727
+ const progress = Math.round(ratio * size);
728
+ const bar = barChar.repeat(progress) + progressChar + barChar.repeat(size - progress);
729
+
730
+ return `${this.formatTime(current)} ${bar} ${this.formatTime(total)}`;
731
+ }
732
+
733
+ private formatTime(ms: number): string {
734
+ const totalSeconds = Math.floor(ms / 1000);
735
+ const hours = Math.floor(totalSeconds / 3600);
736
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
737
+ const seconds = totalSeconds % 60;
738
+ const parts: string[] = [];
739
+ if (hours > 0) parts.push(String(hours).padStart(2, "0"));
740
+ parts.push(String(minutes).padStart(2, "0"));
741
+ parts.push(String(seconds).padStart(2, "0"));
742
+ return parts.join(":");
743
+ }
744
+
745
+ private scheduleLeave(): void {
746
+ this.debug(`[Player] scheduleLeave called`);
747
+ if (this.leaveTimeout) {
748
+ clearTimeout(this.leaveTimeout);
749
+ }
750
+
751
+ if (this.options.leaveOnEmpty && this.options.leaveTimeout) {
752
+ this.leaveTimeout = setTimeout(() => {
753
+ this.debug(`[Player] Leaving voice channel after timeout`);
754
+ this.destroy();
755
+ }, this.options.leaveTimeout);
756
+ }
757
+ }
758
+
759
+ destroy(): void {
760
+ this.debug(`[Player] destroy called`);
761
+ if (this.leaveTimeout) {
762
+ clearTimeout(this.leaveTimeout);
763
+ this.leaveTimeout = null;
764
+ }
765
+
766
+ this.audioPlayer.stop(true);
767
+
768
+ if (this.ttsPlayer) {
769
+ try {
770
+ this.ttsPlayer.stop(true);
771
+ } catch {}
772
+ this.ttsPlayer = null;
773
+ }
774
+
775
+ if (this.connection) {
776
+ this.connection.destroy();
777
+ this.connection = null;
778
+ }
779
+
780
+ this.queue.clear();
781
+ this.pluginManager.clear();
782
+ this.isPlaying = false;
783
+ this.isPaused = false;
784
+ this.emit("playerDestroy");
785
+ this.removeAllListeners();
786
+ }
787
+
788
+ // Getters
789
+ get queueSize(): number {
790
+ return this.queue.size;
791
+ }
792
+
793
+ get currentTrack(): Track | null {
794
+ return this.queue.currentTrack;
795
+ }
796
+
797
+ get upcomingTracks(): Track[] {
798
+ return this.queue.getTracks();
799
+ }
800
+
801
+ get previousTracks(): Track[] {
802
+ return this.queue.previousTracks;
803
+ }
804
+
805
+ get availablePlugins(): string[] {
806
+ return this.pluginManager.getAll().map((p) => p.name);
807
+ }
808
+ }