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.
- package/AI-Guide.md +407 -756
- package/README.md +275 -10
- 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 +95 -0
- package/dist/persistence/PersistenceManager.d.ts.map +1 -0
- package/dist/persistence/PersistenceManager.js +968 -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 +65 -14
- package/dist/structures/Player.d.ts.map +1 -1
- package/dist/structures/Player.js +330 -88
- package/dist/structures/Player.js.map +1 -1
- package/dist/structures/PlayerManager.d.ts +127 -91
- package/dist/structures/PlayerManager.d.ts.map +1 -1
- package/dist/structures/PlayerManager.js +437 -124
- 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 +46 -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 +74 -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 +1073 -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 +358 -94
- package/src/structures/PlayerManager.ts +535 -129
- package/src/structures/Queue.ts +300 -55
- package/src/types/index.ts +52 -10
- package/src/types/persistence.ts +83 -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;
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
335
|
-
const
|
|
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
|
|
348
|
-
cacheAge:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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?.
|
|
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
|
-
|
|
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
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
1480
|
+
current: current,
|
|
1332
1481
|
total: total,
|
|
1333
|
-
format: this.formatTime(
|
|
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.
|
|
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.
|
|
1567
|
+
* const refreshed = await player.refreshPlayerResource(true, 1000);
|
|
1431
1568
|
* console.log(`Refreshed: ${refreshed}`);
|
|
1432
1569
|
*/
|
|
1433
|
-
public async
|
|
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
|
|
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
|
}
|