ziplayer 0.2.7-dev.0 → 0.2.7-dev.1

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