ziplayer 0.2.7-dev.0 → 0.2.7-dev.2

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.
Files changed (54) hide show
  1. package/AI-Guide.md +407 -756
  2. package/README.md +275 -10
  3. package/dist/extensions/BaseExtension.d.ts +1 -0
  4. package/dist/extensions/BaseExtension.d.ts.map +1 -1
  5. package/dist/extensions/BaseExtension.js.map +1 -1
  6. package/dist/extensions/index.d.ts +38 -3
  7. package/dist/extensions/index.d.ts.map +1 -1
  8. package/dist/extensions/index.js +259 -41
  9. package/dist/extensions/index.js.map +1 -1
  10. package/dist/persistence/PersistenceManager.d.ts +95 -0
  11. package/dist/persistence/PersistenceManager.d.ts.map +1 -0
  12. package/dist/persistence/PersistenceManager.js +968 -0
  13. package/dist/persistence/PersistenceManager.js.map +1 -0
  14. package/dist/plugins/BasePlugin.js +1 -1
  15. package/dist/plugins/BasePlugin.js.map +1 -1
  16. package/dist/plugins/index.d.ts +19 -4
  17. package/dist/plugins/index.d.ts.map +1 -1
  18. package/dist/plugins/index.js +204 -113
  19. package/dist/plugins/index.js.map +1 -1
  20. package/dist/structures/FilterManager.js +3 -3
  21. package/dist/structures/FilterManager.js.map +1 -1
  22. package/dist/structures/Player.d.ts +65 -14
  23. package/dist/structures/Player.d.ts.map +1 -1
  24. package/dist/structures/Player.js +330 -88
  25. package/dist/structures/Player.js.map +1 -1
  26. package/dist/structures/PlayerManager.d.ts +127 -91
  27. package/dist/structures/PlayerManager.d.ts.map +1 -1
  28. package/dist/structures/PlayerManager.js +437 -124
  29. package/dist/structures/PlayerManager.js.map +1 -1
  30. package/dist/structures/Queue.d.ts +136 -31
  31. package/dist/structures/Queue.d.ts.map +1 -1
  32. package/dist/structures/Queue.js +265 -46
  33. package/dist/structures/Queue.js.map +1 -1
  34. package/dist/types/index.d.ts +46 -6
  35. package/dist/types/index.d.ts.map +1 -1
  36. package/dist/types/index.js +1 -0
  37. package/dist/types/index.js.map +1 -1
  38. package/dist/types/persistence.d.ts +74 -0
  39. package/dist/types/persistence.d.ts.map +1 -0
  40. package/dist/types/persistence.js +3 -0
  41. package/dist/types/persistence.js.map +1 -0
  42. package/package.json +3 -2
  43. package/src/extensions/BaseExtension.ts +1 -0
  44. package/src/extensions/index.ts +320 -37
  45. package/src/persistence/PersistenceManager.ts +1073 -0
  46. package/src/plugins/BasePlugin.ts +1 -1
  47. package/src/plugins/index.ts +248 -133
  48. package/src/structures/FilterManager.ts +3 -3
  49. package/src/structures/Player.ts +358 -94
  50. package/src/structures/PlayerManager.ts +535 -129
  51. package/src/structures/Queue.ts +300 -55
  52. package/src/types/index.ts +52 -10
  53. package/src/types/persistence.ts +83 -0
  54. package/src/types/plugin.ts +1 -1
@@ -14,6 +14,7 @@ import {
14
14
  } from "@discordjs/voice";
15
15
 
16
16
  import { Readable } from "stream";
17
+ import { LRUCache } from "lru-cache";
17
18
  import type { BaseExtension } from "../extensions";
18
19
  import type {
19
20
  Track,
@@ -26,6 +27,7 @@ import type {
26
27
  StreamInfo,
27
28
  SaveOptions,
28
29
  VoiceChannel,
30
+ PlayerSession,
29
31
  ExtensionPlayRequest,
30
32
  ExtensionPlayResponse,
31
33
  ExtensionAfterPlayPayload,
@@ -37,6 +39,7 @@ import { PluginManager } from "../plugins";
37
39
  import { ExtensionManager } from "../extensions";
38
40
  import { withTimeout } from "../utils/timeout";
39
41
  import { FilterManager } from "./FilterManager";
42
+ import type { PersistenceManager } from "../persistence/PersistenceManager";
40
43
 
41
44
  export declare interface Player {
42
45
  on<K extends keyof PlayerEvents>(event: K, listener: (...args: PlayerEvents[K]) => void): this;
@@ -91,18 +94,28 @@ export class Player extends EventEmitter {
91
94
  public pluginManager: PluginManager;
92
95
  public extensionManager: ExtensionManager;
93
96
  public userdata?: Record<string, any>;
97
+ public _lastActivity: number = Date.now();
94
98
  private manager: PlayerManager;
95
99
  private leaveTimeout: NodeJS.Timeout | null = null;
96
100
  private currentResource: AudioResource | null = null;
97
101
  private volumeInterval: NodeJS.Timeout | null = null;
102
+ private stuckTimer: NodeJS.Timeout | null = null;
103
+
98
104
  private skipLoop = false;
99
105
  private filter!: FilterManager;
100
-
106
+ private refreshLock = false;
107
+ //preloaded resource
108
+ private preloadedResource: AudioResource | null = null;
109
+ private preloadedTrack: Track | null = null;
101
110
  // Cache for search results to avoid duplicate calls
102
- private searchCache = new Map<string, SearchResult>();
111
+ private searchCache: LRUCache<string, SearchResult>;
103
112
  private readonly SEARCH_CACHE_TTL = 2 * 60 * 1000; // 2 minutes
104
- private searchCacheTimestamps = new Map<string, number>();
105
113
  private ttsPlayer: DiscordAudioPlayer | null = null;
114
+ private lastDuration: number = 0;
115
+
116
+ private persistenceManager?: PersistenceManager;
117
+ private lastSaveTime: number = 0;
118
+ private readonly AUTO_SAVE_INTERVAL = 30000; // 30 seconds
106
119
 
107
120
  constructor(guildId: string, options: PlayerOptions = {}, manager: PlayerManager) {
108
121
  super();
@@ -131,7 +144,7 @@ export class Player extends EventEmitter {
131
144
  createPlayer: false,
132
145
  interrupt: true,
133
146
  volume: 100,
134
- Max_Time_TTS: 60_000,
147
+ maxTimeTts: 60_000,
135
148
  ...(options?.tts || {}),
136
149
  },
137
150
  };
@@ -143,6 +156,10 @@ export class Player extends EventEmitter {
143
156
 
144
157
  this.volume = this.options.volume || 100;
145
158
  this.userdata = this.options.userdata;
159
+ this.searchCache = new LRUCache<string, SearchResult>({
160
+ max: 200,
161
+ ttl: this.SEARCH_CACHE_TTL,
162
+ });
146
163
  this.setupEventListeners();
147
164
 
148
165
  // Initialize filters from options
@@ -165,12 +182,13 @@ export class Player extends EventEmitter {
165
182
  * @private
166
183
  */
167
184
  private destroyCurrentStream(): void {
185
+ this.audioPlayer.stop(true);
168
186
  if (!this.currentResource) return;
169
187
 
170
188
  const stream = (this.currentResource as any)?.metadata?.stream ?? (this.currentResource as any)?.stream;
171
189
 
172
- if (stream?.destroy) {
173
- stream.destroy();
190
+ if (stream && typeof stream.destroy === "function") {
191
+ stream.destroy().catch((e: any) => this.debug("Stream destroy error:", e));
174
192
  }
175
193
 
176
194
  this.currentResource = null;
@@ -191,12 +209,6 @@ export class Player extends EventEmitter {
191
209
  async search(query: string, requestedBy: string): Promise<SearchResult> {
192
210
  this.debug(`[Player] Search called with query: ${query}, requestedBy: ${requestedBy}`);
193
211
 
194
- // Clear expired search cache periodically
195
- if (Math.random() < 0.1) {
196
- // 10% chance to clean cache
197
- this.clearExpiredSearchCache();
198
- }
199
-
200
212
  // Check cache first
201
213
  const cachedResult = this.getCachedSearchResult(query);
202
214
  if (cachedResult) {
@@ -265,15 +277,10 @@ export class Player extends EventEmitter {
265
277
  */
266
278
  private getCachedSearchResult(query: string): SearchResult | null {
267
279
  const cacheKey = query.toLowerCase().trim();
268
- const now = Date.now();
269
-
270
- const cachedTimestamp = this.searchCacheTimestamps.get(cacheKey);
271
- if (cachedTimestamp && now - cachedTimestamp < this.SEARCH_CACHE_TTL) {
272
- const cachedResult = this.searchCache.get(cacheKey);
273
- if (cachedResult) {
274
- this.debug(`[SearchCache] Using cached search result for: ${query}`);
275
- return cachedResult;
276
- }
280
+ const cached = this.searchCache.get(cacheKey);
281
+ if (cached) {
282
+ this.debug(`[SearchCache] Using cached search result for: ${query}`);
283
+ return cached;
277
284
  }
278
285
 
279
286
  return null;
@@ -286,10 +293,7 @@ export class Player extends EventEmitter {
286
293
  */
287
294
  private cacheSearchResult(query: string, result: SearchResult): void {
288
295
  const cacheKey = query.toLowerCase().trim();
289
- const now = Date.now();
290
-
291
296
  this.searchCache.set(cacheKey, result);
292
- this.searchCacheTimestamps.set(cacheKey, now);
293
297
  this.debug(`[SearchCache] Cached search result for: ${query} (${result.tracks.length} tracks)`);
294
298
  }
295
299
 
@@ -297,14 +301,8 @@ export class Player extends EventEmitter {
297
301
  * Clear expired search cache entries
298
302
  */
299
303
  private clearExpiredSearchCache(): void {
300
- const now = Date.now();
301
- for (const [key, timestamp] of this.searchCacheTimestamps.entries()) {
302
- if (now - timestamp >= this.SEARCH_CACHE_TTL) {
303
- this.searchCache.delete(key);
304
- this.searchCacheTimestamps.delete(key);
305
- this.debug(`[SearchCache] Cleared expired cache entry: ${key}`);
306
- }
307
- }
304
+ this.searchCache.purgeStale();
305
+ this.debug(`[SearchCache] Purged stale search cache entries`);
308
306
  }
309
307
 
310
308
  /**
@@ -315,7 +313,6 @@ export class Player extends EventEmitter {
315
313
  public clearSearchCache(): void {
316
314
  const cacheSize = this.searchCache.size;
317
315
  this.searchCache.clear();
318
- this.searchCacheTimestamps.clear();
319
316
  this.debug(`[SearchCache] Cleared all ${cacheSize} search cache entries`);
320
317
  }
321
318
 
@@ -331,9 +328,8 @@ export class Player extends EventEmitter {
331
328
  ttsFiltered: boolean;
332
329
  } {
333
330
  const cacheKey = query.toLowerCase().trim();
334
- const now = Date.now();
335
- const cachedTimestamp = this.searchCacheTimestamps.get(cacheKey);
336
- const isCached = cachedTimestamp && now - cachedTimestamp < this.SEARCH_CACHE_TTL;
331
+ const cached = this.searchCache.get(cacheKey);
332
+ const isCached = !!cached;
337
333
 
338
334
  const allPlugins = this.pluginManager.getAll();
339
335
  const plugins = allPlugins.filter((p) => {
@@ -344,8 +340,8 @@ export class Player extends EventEmitter {
344
340
  });
345
341
 
346
342
  return {
347
- isCached: !!isCached,
348
- cacheAge: cachedTimestamp ? now - cachedTimestamp : undefined,
343
+ isCached,
344
+ cacheAge: undefined,
349
345
  pluginCount: plugins.length,
350
346
  ttsFiltered: allPlugins.length > plugins.length,
351
347
  };
@@ -419,7 +415,7 @@ export class Player extends EventEmitter {
419
415
  }
420
416
  } else {
421
417
  // Handle other types (string, Track)
422
- const hookOutcome = await this.extensionManager.BeforePlayHooks(effectiveRequest);
418
+ const hookOutcome = await this.extensionManager.beforePlayHooks(effectiveRequest);
423
419
  effectiveRequest = hookOutcome.request;
424
420
  hookResponse = hookOutcome.response;
425
421
  if (effectiveRequest.requestedBy === undefined) {
@@ -437,7 +433,7 @@ export class Player extends EventEmitter {
437
433
  isPlaylist: hookResponse.isPlaylist ?? false,
438
434
  error: hookResponse.error,
439
435
  };
440
- await this.extensionManager.AfterPlayHooks(handledPayload);
436
+ await this.extensionManager.afterPlayHooks(handledPayload);
441
437
  if (hookResponse.error) {
442
438
  this.emit("playerError", hookResponse.error);
443
439
  }
@@ -484,7 +480,7 @@ export class Player extends EventEmitter {
484
480
  ) {
485
481
  this.debug(`[Player] Interrupting with TTS: ${tracksToAdd[0].title}`);
486
482
  await this.interruptWithTTSTrack(tracksToAdd[0]);
487
- await this.extensionManager.AfterPlayHooks({
483
+ await this.extensionManager.afterPlayHooks({
488
484
  success: true,
489
485
  query: effectiveRequest.query,
490
486
  requestedBy: effectiveRequest.requestedBy,
@@ -504,7 +500,7 @@ export class Player extends EventEmitter {
504
500
 
505
501
  const started = !this.isPlaying ? await this.playNext() : true;
506
502
 
507
- await this.extensionManager.AfterPlayHooks({
503
+ await this.extensionManager.afterPlayHooks({
508
504
  success: started,
509
505
  query: effectiveRequest.query,
510
506
  requestedBy: effectiveRequest.requestedBy,
@@ -514,7 +510,7 @@ export class Player extends EventEmitter {
514
510
 
515
511
  return started;
516
512
  } catch (error) {
517
- await this.extensionManager.AfterPlayHooks({
513
+ await this.extensionManager.afterPlayHooks({
518
514
  success: false,
519
515
  query: effectiveRequest.query,
520
516
  requestedBy: effectiveRequest.requestedBy,
@@ -528,6 +524,31 @@ export class Player extends EventEmitter {
528
524
  }
529
525
  }
530
526
 
527
+ async preloadNext() {
528
+ const next = this.queue.nextTrack;
529
+ if (!next) return;
530
+
531
+ try {
532
+ const stream = await this.getStream(next);
533
+
534
+ if (!stream || !(stream as any).stream) {
535
+ this.debug(`[Player] No stream available to preload for track: ${next.title}`);
536
+ return;
537
+ }
538
+
539
+ const resource = createAudioResource(stream.stream, {
540
+ inlineVolume: true,
541
+ });
542
+
543
+ this.preloadedResource = resource;
544
+ this.preloadedTrack = next;
545
+
546
+ this.debug("Preloaded next track:", next.title);
547
+ } catch (err) {
548
+ this.debug("Preload failed:", err);
549
+ }
550
+ }
551
+
531
552
  /**
532
553
  * Create AudioResource with filters and seek applied
533
554
  *
@@ -592,6 +613,30 @@ export class Player extends EventEmitter {
592
613
  */
593
614
  private async startTrack(track: Track): Promise<boolean> {
594
615
  try {
616
+ if (
617
+ this.preloadedResource &&
618
+ this.preloadedTrack?.id === track.id &&
619
+ this.preloadedResource.playStream?.readable !== false
620
+ ) {
621
+ this.debug(`[Player] Using preloaded resource for track: ${track.title}`);
622
+ this.audioPlayer.stop(true);
623
+ this.destroyCurrentStream();
624
+ this.currentResource = this.preloadedResource;
625
+ this.currentResource.volume?.setVolume(this.volume / 100);
626
+ this.audioPlayer.play(this.currentResource);
627
+ await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 5_000);
628
+
629
+ if (this.preloadedResource) {
630
+ try {
631
+ (this.preloadedResource.playStream as any)?.destroy?.();
632
+ } catch {}
633
+ }
634
+
635
+ this.preloadedResource = null;
636
+ this.preloadedTrack = null;
637
+ return true;
638
+ }
639
+
595
640
  let streamInfo: StreamInfo | null = await this.getStream(track);
596
641
  this.debug(`[Player] Using stream for track: ${track.title}`);
597
642
  // Kiểm tra nếu có stream thực sự để tạo AudioResource
@@ -654,7 +699,7 @@ export class Player extends EventEmitter {
654
699
  }
655
700
 
656
701
  private async playNext(): Promise<boolean> {
657
- this.debug(`[Player] playNext called by ${new Error().stack?.split("\n")[2]?.trim()}`);
702
+ this.debug("[Player] playNext called");
658
703
  while (true) {
659
704
  const track = this.queue.next(this.skipLoop);
660
705
  this.skipLoop = false;
@@ -678,12 +723,18 @@ export class Player extends EventEmitter {
678
723
  return false;
679
724
  }
680
725
 
681
- this.generateWillNext();
726
+ this.generateWillNext().catch((err) => this.debug("[Player] generateWillNext error:", err));
682
727
  this.clearLeaveTimeout();
683
728
  this.debug(`[Player] playNext called for track: ${track.title}`);
684
729
 
685
730
  try {
686
- return await this.startTrack(track);
731
+ const started = await this.startTrack(track);
732
+ if (started) {
733
+ setImmediate(() => {
734
+ this.preloadNext();
735
+ });
736
+ }
737
+ return started;
687
738
  } catch (err) {
688
739
  this.debug(`[Player] playNext error:`, err);
689
740
  this.emit("playerError", err as Error, track);
@@ -730,12 +781,12 @@ export class Player extends EventEmitter {
730
781
  // Build resource from plugin stream
731
782
  const streamInfo = await this.pluginManager.getStream(track);
732
783
  if (!streamInfo) {
733
- throw new Error("No stream available for track: ${track.title}");
784
+ throw new Error(`No stream available for track: ${track.title}`);
734
785
  }
735
786
  ttsStream = streamInfo.stream;
736
787
  const resource = await this.createResource(streamInfo as StreamInfo, track);
737
788
  if (!resource) {
738
- throw new Error("No resource available for track: ${track.title}");
789
+ throw new Error(`No resource available for track: ${track.title}`);
739
790
  }
740
791
  ttsResource = resource;
741
792
  if (resource.volume) {
@@ -766,7 +817,7 @@ export class Player extends EventEmitter {
766
817
  declared
767
818
  : declared * 1000
768
819
  : undefined;
769
- const cap = this.options?.tts?.Max_Time_TTS ?? 60_000;
820
+ const cap = this.options?.tts?.maxTimeTts ?? 60_000;
770
821
  const idleTimeout = declaredMs ? Math.min(cap, Math.max(1_000, declaredMs + 1_500)) : cap;
771
822
  await entersState(ttsPlayer, AudioPlayerStatus.Idle, idleTimeout).catch(() => null);
772
823
 
@@ -883,7 +934,7 @@ export class Player extends EventEmitter {
883
934
  const track = this.queue.currentTrack;
884
935
  if (track) {
885
936
  this.debug(`[Player] Player resumed on track: ${track.title}`);
886
- this.emit("playerResume", track);
937
+ // this.emit("playerResume", track); //đã có trong stateChange
887
938
  }
888
939
  }
889
940
  return result;
@@ -940,13 +991,7 @@ export class Player extends EventEmitter {
940
991
  return false;
941
992
  }
942
993
 
943
- const streaminfo = await this.getStream(track);
944
- if (!streaminfo?.stream) {
945
- this.debug(`[Player] No stream to seek`);
946
- return false;
947
- }
948
-
949
- await this.refeshPlayerResource(true, position);
994
+ await this.refreshPlayerResource(true, position);
950
995
 
951
996
  return true;
952
997
  }
@@ -1058,7 +1103,7 @@ export class Player extends EventEmitter {
1058
1103
  }
1059
1104
 
1060
1105
  try {
1061
- // Try extensions first
1106
+ // Skip extension manager for saving - we want the raw stream without filters/seek applied, and extensions may not support this
1062
1107
  let streamInfo: StreamInfo | null = await this.pluginManager.getStream(track);
1063
1108
 
1064
1109
  if (!streamInfo || !streamInfo.stream) {
@@ -1280,7 +1325,6 @@ export class Player extends EventEmitter {
1280
1325
  }
1281
1326
  return track;
1282
1327
  }
1283
-
1284
1328
  /**
1285
1329
  * Get the progress bar of the current track
1286
1330
  *
@@ -1289,22 +1333,109 @@ export class Player extends EventEmitter {
1289
1333
  * @example
1290
1334
  * const progressBar = player.getProgressBar();
1291
1335
  * console.log(`Progress bar: ${progressBar}`);
1336
+ *
1337
+ * // Custom options
1338
+ * const customBar = player.getProgressBar({
1339
+ * size: 30,
1340
+ * barChar: "─",
1341
+ * progressChar: "●",
1342
+ * timeFormat: "compact" // "compact" = 1:22:12, "full" = 01:22:12
1343
+ * });
1292
1344
  */
1293
1345
  getProgressBar(options: ProgressBarOptions = {}): string {
1294
- const { size = 20, barChar = "▬", progressChar = "🔘" } = options;
1346
+ const {
1347
+ size = 20,
1348
+ barChar = "▬",
1349
+ progressChar = "🔘",
1350
+ timeFormat = "compact", // "compact" or "full"
1351
+ showPercentage = false,
1352
+ showTime = true,
1353
+ } = options;
1354
+
1295
1355
  const track = this.queue.currentTrack;
1296
1356
  const resource = this.currentResource;
1297
- if (!track || !resource) return "";
1357
+
1358
+ // Handle live stream
1359
+ if (this.isLive || !track || !resource) {
1360
+ if (this.isLive) return "🔴 LIVE";
1361
+ return "";
1362
+ }
1298
1363
 
1299
1364
  const total = track.duration > 1000 ? track.duration : track.duration * 1000;
1300
- if (!total) return this.formatTime(resource.playbackDuration);
1365
+ if (!total) return this.formatTimeCompact(resource.playbackDuration);
1301
1366
 
1302
1367
  const current = resource.playbackDuration;
1303
- const ratio = Math.min(current / total, 1);
1368
+ const ratio = Math.min(Math.max(current / total, 0), 1);
1304
1369
  const progress = Math.round(ratio * size);
1305
- const bar = barChar.repeat(progress) + progressChar + barChar.repeat(size - progress);
1306
1370
 
1307
- return `${this.formatTime(current)} | ${bar} | ${this.formatTime(total)}`;
1371
+ // Build progress bar
1372
+ let bar = "";
1373
+ if (progressChar === "none" || options.hideProgressChar) {
1374
+ // Continuous bar without separator
1375
+ const filled = barChar.repeat(progress);
1376
+ const empty = barChar.repeat(size - progress);
1377
+ bar = filled + empty;
1378
+ } else {
1379
+ // Bar with progress character
1380
+ const filled = barChar.repeat(progress);
1381
+ const empty = barChar.repeat(Math.max(0, size - progress));
1382
+ bar = filled + progressChar + empty;
1383
+ }
1384
+
1385
+ // Format time based on option
1386
+ const formatTimeFn = timeFormat === "compact" ? this.formatTimeCompact.bind(this) : this.formatTime.bind(this);
1387
+ const currentTimeStr = formatTimeFn(current);
1388
+ const totalTimeStr = formatTimeFn(total);
1389
+
1390
+ // Build result
1391
+ let result = "";
1392
+ if (showTime) {
1393
+ result = `${currentTimeStr} ${bar} ${totalTimeStr}`;
1394
+ } else {
1395
+ result = bar;
1396
+ }
1397
+
1398
+ // Add percentage if requested
1399
+ if (showPercentage) {
1400
+ const percent = Math.round(ratio * 100);
1401
+ result += ` (${percent}%)`;
1402
+ }
1403
+
1404
+ return result;
1405
+ }
1406
+
1407
+ /**
1408
+ * Format time with leading zeros (00:00 or 00:00:00)
1409
+ * @param ms - Time in milliseconds
1410
+ * @returns Formatted time string with leading zeros
1411
+ */
1412
+ formatTime(ms: number): string {
1413
+ const totalSeconds = Math.floor(ms / 1000);
1414
+ const hours = Math.floor(totalSeconds / 3600);
1415
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
1416
+ const seconds = totalSeconds % 60;
1417
+ const parts: string[] = [];
1418
+ if (hours > 0) parts.push(String(hours).padStart(2, "0"));
1419
+ parts.push(String(minutes).padStart(2, "0"));
1420
+ parts.push(String(seconds).padStart(2, "0"));
1421
+ return parts.join(":");
1422
+ }
1423
+
1424
+ /**
1425
+ * Format time without leading zeros for hours (1:22:12 or 3:45)
1426
+ * @param ms - Time in milliseconds
1427
+ * @returns Compact formatted time string
1428
+ */
1429
+ formatTimeCompact(ms: number): string {
1430
+ const totalSeconds = Math.floor(ms / 1000);
1431
+ const hours = Math.floor(totalSeconds / 3600);
1432
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
1433
+ const seconds = totalSeconds % 60;
1434
+
1435
+ if (hours > 0) {
1436
+ return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
1437
+ }
1438
+ return `${minutes}:${String(seconds).padStart(2, "0")}`;
1308
1439
  }
1309
1440
 
1310
1441
  /**
@@ -1314,47 +1445,48 @@ export class Player extends EventEmitter {
1314
1445
  * @example
1315
1446
  * const time = player.getTime();
1316
1447
  * console.log(`Time: ${time.current}`);
1448
+ * console.log(`Formatted: ${time.formatted.current}`); // "1:22:12" or "3:45"
1317
1449
  */
1318
1450
  getTime() {
1451
+ if (this.isLive)
1452
+ return {
1453
+ current: 0,
1454
+ total: 0,
1455
+ format: "LIVE",
1456
+ formatted: {
1457
+ current: "LIVE",
1458
+ total: "LIVE",
1459
+ },
1460
+ };
1461
+
1319
1462
  const resource = this.currentResource;
1320
1463
  const track = this.queue.currentTrack;
1321
- if (!track || !resource)
1464
+ if (!track || !resource) {
1322
1465
  return {
1323
1466
  current: 0,
1324
1467
  total: 0,
1325
1468
  format: "00:00",
1469
+ formatted: {
1470
+ current: "00:00",
1471
+ total: "00:00",
1472
+ },
1326
1473
  };
1474
+ }
1327
1475
 
1328
1476
  const total = track.duration > 1000 ? track.duration : track.duration * 1000;
1477
+ const current = resource.playbackDuration;
1329
1478
 
1330
1479
  return {
1331
- current: resource?.playbackDuration,
1480
+ current: current,
1332
1481
  total: total,
1333
- format: this.formatTime(resource.playbackDuration),
1482
+ format: this.formatTime(current),
1483
+ formatted: {
1484
+ current: this.formatTimeCompact(current),
1485
+ total: this.formatTimeCompact(total),
1486
+ },
1334
1487
  };
1335
1488
  }
1336
1489
 
1337
- /**
1338
- * Format the time in the format of HH:MM:SS
1339
- *
1340
- * @param {number} ms - The time in milliseconds
1341
- * @returns {string} The formatted time
1342
- * @example
1343
- * const formattedTime = player.formatTime(1000);
1344
- * console.log(`Formatted time: ${formattedTime}`);
1345
- */
1346
- formatTime(ms: number): string {
1347
- const totalSeconds = Math.floor(ms / 1000);
1348
- const hours = Math.floor(totalSeconds / 3600);
1349
- const minutes = Math.floor((totalSeconds % 3600) / 60);
1350
- const seconds = totalSeconds % 60;
1351
- const parts: string[] = [];
1352
- if (hours > 0) parts.push(String(hours).padStart(2, "0"));
1353
- parts.push(String(minutes).padStart(2, "0"));
1354
- parts.push(String(seconds).padStart(2, "0"));
1355
- return parts.join(":");
1356
- }
1357
-
1358
1490
  /**
1359
1491
  * Destroy the player
1360
1492
  *
@@ -1364,6 +1496,11 @@ export class Player extends EventEmitter {
1364
1496
  */
1365
1497
  destroy(): void {
1366
1498
  this.debug(`[Player] destroy called`);
1499
+
1500
+ if (this.manager.getPersistence()) {
1501
+ this.manager.getPersistence()?.markPlayerDestroyed(this.guildId, "player_destroy_called");
1502
+ }
1503
+
1367
1504
  if (this.leaveTimeout) {
1368
1505
  clearTimeout(this.leaveTimeout);
1369
1506
  this.leaveTimeout = null;
@@ -1412,7 +1549,7 @@ export class Player extends EventEmitter {
1412
1549
  clearTimeout(this.leaveTimeout);
1413
1550
  }
1414
1551
 
1415
- if (this.options.leaveOnEmpty && this.options.leaveTimeout) {
1552
+ if (this.options.leaveOnEnd && this.options.leaveTimeout) {
1416
1553
  this.leaveTimeout = setTimeout(() => {
1417
1554
  this.debug(`[Player] Leaving voice channel after timeoutMs`);
1418
1555
  this.destroy();
@@ -1427,14 +1564,15 @@ export class Player extends EventEmitter {
1427
1564
  * @param {number} position - Position to seek to in milliseconds
1428
1565
  * @returns {Promise<boolean>}
1429
1566
  * @example
1430
- * const refreshed = await player.refeshPlayerResource(true, 1000);
1567
+ * const refreshed = await player.refreshPlayerResource(true, 1000);
1431
1568
  * console.log(`Refreshed: ${refreshed}`);
1432
1569
  */
1433
- public async refeshPlayerResource(applyToCurrent: boolean = true, position: number = -1): Promise<boolean> {
1570
+ public async refreshPlayerResource(applyToCurrent: boolean = true, position: number = -1): Promise<boolean> {
1434
1571
  if (!applyToCurrent || !this.queue.currentTrack || !(this.isPlaying || this.isPaused)) {
1435
1572
  return false;
1436
1573
  }
1437
-
1574
+ if (this.refreshLock) return false;
1575
+ this.refreshLock = true;
1438
1576
  try {
1439
1577
  const track = this.queue.currentTrack;
1440
1578
  this.debug(`[Player] Refreshing player resource for track: ${track.title}`);
@@ -1466,7 +1604,9 @@ export class Player extends EventEmitter {
1466
1604
  }
1467
1605
  }
1468
1606
  } catch (error) {
1469
- this.debug(`[Player] Error destroying old stream in refeshPlayerResource:`, error);
1607
+ this.debug(`[Player] Error destroying old stream in refreshPlayerResource:`, error);
1608
+ } finally {
1609
+ this.refreshLock = false;
1470
1610
  }
1471
1611
 
1472
1612
  this.currentResource = resource;
@@ -1588,6 +1728,18 @@ export class Player extends EventEmitter {
1588
1728
  this.debug(`[Player] AudioPlayerStatus.AutoPaused`);
1589
1729
  } else if (newState.status === AudioPlayerStatus.Buffering) {
1590
1730
  this.debug(`[Player] AudioPlayerStatus.Buffering`);
1731
+ this.lastDuration = this.currentResource?.playbackDuration || 0;
1732
+ this.stuckTimer = setTimeout(() => {
1733
+ if (this.currentResource?.playbackDuration === this.lastDuration) {
1734
+ this.emit("trackStuck", this.currentTrack);
1735
+ this.skip();
1736
+ }
1737
+ }, 10000);
1738
+ } else {
1739
+ if (this.stuckTimer) {
1740
+ clearTimeout(this.stuckTimer);
1741
+ this.stuckTimer = null;
1742
+ }
1591
1743
  }
1592
1744
  });
1593
1745
  this.audioPlayer.on("error", (error) => {
@@ -1612,7 +1764,116 @@ export class Player extends EventEmitter {
1612
1764
  this.debug(`[Player] Removing plugin: ${name}`);
1613
1765
  return this.pluginManager.unregister(name);
1614
1766
  }
1767
+ /**
1768
+ * Save the current session of the player, including queue, current track, position, volume, loop mode, auto-play mode, and active extensions/plugins.
1769
+ *
1770
+ * @returns {PlayerSession} The saved session data
1771
+ */
1772
+ saveSession(): PlayerSession {
1773
+ return {
1774
+ guildId: this.guildId,
1775
+ currentTrack: this.currentTrack,
1776
+ position: this.currentResource?.playbackDuration || null,
1777
+ volume: this.volume,
1778
+ queue: this.queue.getTracks(),
1779
+ loopMode: this.queue.loop(),
1780
+ autoPlay: this.queue.autoPlay(),
1781
+ extensions: this.extensionManager.getAll().map((ext) => ext.name),
1782
+ plugins: this.pluginManager.getAll().map((plugin) => plugin.name),
1783
+ };
1784
+ }
1785
+ /**
1786
+ * Set persistence manager for auto-save
1787
+ */
1788
+ setPersistenceManager(manager: PersistenceManager): void {
1789
+ this.persistenceManager = manager;
1790
+ this.startAutoSaveTracking();
1791
+ }
1792
+
1793
+ private startAutoSaveTracking(): void {
1794
+ // Track state changes for auto-save
1795
+ const trackChanges = () => {
1796
+ this.scheduleAutoSave();
1797
+ };
1798
+
1799
+ this.on("trackStart", trackChanges);
1800
+ this.on("trackEnd", trackChanges);
1801
+ this.on("queueAdd", trackChanges);
1802
+ this.on("queueRemove", trackChanges);
1803
+ this.on("volumeChange", trackChanges);
1804
+
1805
+ // Save periodically
1806
+ setInterval(() => {
1807
+ this.saveIfNeeded();
1808
+ }, this.AUTO_SAVE_INTERVAL);
1809
+ }
1810
+
1811
+ private scheduleAutoSave(): void {
1812
+ if (!this.persistenceManager) return;
1813
+ this.lastSaveTime = Date.now();
1814
+ // Can implement debounced save here
1815
+ }
1816
+
1817
+ private async saveIfNeeded(): Promise<void> {
1818
+ if (!this.persistenceManager) return;
1819
+ if (Date.now() - this.lastSaveTime < this.AUTO_SAVE_INTERVAL) return;
1820
+
1821
+ await this.persistenceManager.savePlayer(this);
1822
+ this.lastSaveTime = Date.now();
1823
+ }
1824
+
1825
+ /**
1826
+ * Save current player state
1827
+ */
1828
+ async savePlayer(): Promise<boolean> {
1829
+ if (!this.persistenceManager) {
1830
+ this.debug("[Player] No persistence manager configured");
1831
+ return false;
1832
+ }
1833
+ return await this.persistenceManager.savePlayer(this);
1834
+ }
1835
+
1836
+ /**
1837
+ * Get serializable state (for manual persistence)
1838
+ */
1839
+ getSerializableState(): object {
1840
+ return {
1841
+ guildId: this.guildId,
1842
+ queue: this.queue.getTracks(),
1843
+ currentTrack: this.currentTrack,
1844
+ volume: this.volume,
1845
+ isPlaying: this.isPlaying,
1846
+ isPaused: this.isPaused,
1847
+ loopMode: this.queue.loop(),
1848
+ autoPlay: this.queue.autoPlay(),
1849
+ filters: this.filter.getFilterString(),
1850
+ timestamp: Date.now(),
1851
+ };
1852
+ }
1853
+
1854
+ /**
1855
+ * Restore from saved state
1856
+ */
1857
+ async restoreState(state: any): Promise<boolean> {
1858
+ try {
1859
+ if (state.volume) this.setVolume(state.volume);
1860
+ if (state.loopMode) this.queue.loop(state.loopMode);
1861
+ if (typeof state.autoPlay === "boolean") this.queue.autoPlay(state.autoPlay);
1862
+ if (state.filters) await this.filter.applyFilters(state.filters.split(","));
1863
+
1864
+ // Restore queue
1865
+ if (state.queue && Array.isArray(state.queue)) {
1866
+ this.queue.clear();
1867
+ this.queue.addMultiple(state.queue);
1868
+ }
1615
1869
 
1870
+ this.debug("[Player] State restored");
1871
+ return true;
1872
+ } catch (error) {
1873
+ this.debug("[Player] Failed to restore state:", error);
1874
+ return false;
1875
+ }
1876
+ }
1616
1877
  //#endregion
1617
1878
  //#region Getters
1618
1879
 
@@ -1700,5 +1961,8 @@ export class Player extends EventEmitter {
1700
1961
  return this.queue.relatedTracks();
1701
1962
  }
1702
1963
 
1964
+ get isLive(): boolean {
1965
+ return this.currentTrack?.isLive === true;
1966
+ }
1703
1967
  //#endregion
1704
1968
  }