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.
- package/AI-Guide.md +407 -756
- package/README.md +265 -11
- package/dist/extensions/BaseExtension.d.ts +1 -0
- package/dist/extensions/BaseExtension.d.ts.map +1 -1
- package/dist/extensions/BaseExtension.js.map +1 -1
- package/dist/extensions/index.d.ts +38 -3
- package/dist/extensions/index.d.ts.map +1 -1
- package/dist/extensions/index.js +259 -41
- package/dist/extensions/index.js.map +1 -1
- package/dist/persistence/PersistenceManager.d.ts +61 -0
- package/dist/persistence/PersistenceManager.d.ts.map +1 -0
- package/dist/persistence/PersistenceManager.js +551 -0
- package/dist/persistence/PersistenceManager.js.map +1 -0
- package/dist/plugins/BasePlugin.js +1 -1
- package/dist/plugins/BasePlugin.js.map +1 -1
- package/dist/plugins/index.d.ts +19 -4
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +204 -113
- package/dist/plugins/index.js.map +1 -1
- package/dist/structures/FilterManager.js +3 -3
- package/dist/structures/FilterManager.js.map +1 -1
- package/dist/structures/Player.d.ts +64 -14
- package/dist/structures/Player.d.ts.map +1 -1
- package/dist/structures/Player.js +326 -88
- package/dist/structures/Player.js.map +1 -1
- package/dist/structures/PlayerManager.d.ts +125 -91
- package/dist/structures/PlayerManager.d.ts.map +1 -1
- package/dist/structures/PlayerManager.js +406 -111
- package/dist/structures/PlayerManager.js.map +1 -1
- package/dist/structures/Queue.d.ts +136 -31
- package/dist/structures/Queue.d.ts.map +1 -1
- package/dist/structures/Queue.js +265 -46
- package/dist/structures/Queue.js.map +1 -1
- package/dist/types/index.d.ts +39 -6
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/persistence.d.ts +55 -0
- package/dist/types/persistence.d.ts.map +1 -0
- package/dist/types/persistence.js +3 -0
- package/dist/types/persistence.js.map +1 -0
- package/package.json +3 -2
- package/src/extensions/BaseExtension.ts +1 -0
- package/src/extensions/index.ts +320 -37
- package/src/persistence/PersistenceManager.ts +572 -0
- package/src/plugins/BasePlugin.ts +1 -1
- package/src/plugins/index.ts +248 -133
- package/src/structures/FilterManager.ts +3 -3
- package/src/structures/Player.ts +352 -94
- package/src/structures/PlayerManager.ts +488 -116
- package/src/structures/Queue.ts +300 -55
- package/src/types/index.ts +43 -10
- package/src/types/persistence.ts +65 -0
- package/src/types/plugin.ts +1 -1
package/src/structures/Player.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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
|
|
335
|
-
const
|
|
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
|
|
348
|
-
cacheAge:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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?.
|
|
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
|
-
|
|
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
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
1479
|
+
current: current,
|
|
1332
1480
|
total: total,
|
|
1333
|
-
format: this.formatTime(
|
|
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.
|
|
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.
|
|
1561
|
+
* const refreshed = await player.refreshPlayerResource(true, 1000);
|
|
1431
1562
|
* console.log(`Refreshed: ${refreshed}`);
|
|
1432
1563
|
*/
|
|
1433
|
-
public async
|
|
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
|
|
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
|
}
|