ziplayer 0.3.1 → 0.3.3
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 → AGENTS.md} +36 -7
- package/README.md +113 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/index.js +1 -1
- package/dist/plugins/index.js.map +1 -1
- package/dist/structures/Player.d.ts +48 -17
- package/dist/structures/Player.d.ts.map +1 -1
- package/dist/structures/Player.js +154 -377
- package/dist/structures/Player.js.map +1 -1
- package/dist/structures/PlayerManager.d.ts +35 -6
- package/dist/structures/PlayerManager.d.ts.map +1 -1
- package/dist/structures/PlayerManager.js +215 -19
- package/dist/structures/PlayerManager.js.map +1 -1
- package/dist/structures/PreloadManager.d.ts +32 -0
- package/dist/structures/PreloadManager.d.ts.map +1 -0
- package/dist/structures/PreloadManager.js +230 -0
- package/dist/structures/PreloadManager.js.map +1 -0
- package/dist/structures/Queue.d.ts +19 -0
- package/dist/structures/Queue.d.ts.map +1 -1
- package/dist/structures/Queue.js +21 -0
- package/dist/structures/Queue.js.map +1 -1
- package/dist/structures/StreamManager.d.ts +1 -0
- package/dist/structures/StreamManager.d.ts.map +1 -1
- package/dist/structures/StreamManager.js +37 -3
- package/dist/structures/StreamManager.js.map +1 -1
- package/dist/types/index.d.ts +41 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +6 -0
- package/dist/types/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/plugins/index.ts +1 -1
- package/src/structures/Player.ts +174 -440
- package/src/structures/PlayerManager.ts +253 -23
- package/src/structures/PreloadManager.ts +274 -0
- package/src/structures/Queue.ts +22 -0
- package/src/structures/StreamManager.ts +41 -4
- package/src/types/index.ts +47 -1
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
import { EventEmitter } from "events";
|
|
2
2
|
import { Player } from "./Player";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
PlayerManagerOptions,
|
|
5
|
+
PlayerOptions,
|
|
6
|
+
type Track,
|
|
7
|
+
SourcePlugin,
|
|
8
|
+
SearchResult,
|
|
9
|
+
ManagerEvents,
|
|
10
|
+
PlayerStats,
|
|
11
|
+
type PlaybackMirrorOptions,
|
|
12
|
+
type TrackMiddleware,
|
|
13
|
+
normalizeTrackMiddleware,
|
|
14
|
+
} from "../types";
|
|
4
15
|
import type { BaseExtension } from "../extensions";
|
|
5
16
|
import { withTimeout } from "../utils/timeout";
|
|
17
|
+
import { PluginManager } from "../plugins";
|
|
6
18
|
|
|
7
19
|
const GLOBAL_MANAGER_KEY: symbol = Symbol.for("ziplayer.PlayerManager.instance");
|
|
8
20
|
|
|
@@ -92,12 +104,15 @@ export class PlayerManager extends EventEmitter {
|
|
|
92
104
|
}
|
|
93
105
|
|
|
94
106
|
private plugins: SourcePlugin[];
|
|
107
|
+
private pluginManager: PluginManager;
|
|
95
108
|
private extensions: any[];
|
|
96
109
|
private B_debug: boolean = false;
|
|
97
110
|
private extractorTimeout: number = 10000;
|
|
98
111
|
private autoCleanup: boolean = true;
|
|
99
112
|
private cleanupTimeout: number = 60000; // 1 minute
|
|
100
113
|
private enableSearchCache: boolean = true;
|
|
114
|
+
private trackMiddlewareFromOptions: TrackMiddleware[] = [];
|
|
115
|
+
private playbackMirrorUnsubscribes = new Map<string, () => void>();
|
|
101
116
|
|
|
102
117
|
private debug(message?: any, ...optionalParams: any[]): void {
|
|
103
118
|
if (this.listenerCount("debug") > 0) {
|
|
@@ -111,17 +126,26 @@ export class PlayerManager extends EventEmitter {
|
|
|
111
126
|
constructor(options: PlayerManagerOptions = {}) {
|
|
112
127
|
super();
|
|
113
128
|
this.plugins = [];
|
|
129
|
+
this.pluginManager = new PluginManager(null as any, this, {
|
|
130
|
+
extractorTimeout: this.extractorTimeout,
|
|
131
|
+
});
|
|
114
132
|
this.searchCache = new Map();
|
|
115
133
|
|
|
116
134
|
// Initialize plugins
|
|
117
135
|
const provided = options.plugins || [];
|
|
118
136
|
for (const p of provided as any[]) {
|
|
119
137
|
try {
|
|
138
|
+
let instance: SourcePlugin | null = null;
|
|
139
|
+
|
|
120
140
|
if (p && typeof p === "object") {
|
|
121
|
-
|
|
141
|
+
instance = p as SourcePlugin;
|
|
122
142
|
} else if (typeof p === "function") {
|
|
123
|
-
|
|
124
|
-
|
|
143
|
+
instance = new (p as any)();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (instance) {
|
|
147
|
+
this.plugins.push(instance);
|
|
148
|
+
this.pluginManager.register(instance);
|
|
125
149
|
}
|
|
126
150
|
this.debug(`Registered plugin: ${p.name || "unnamed"}`);
|
|
127
151
|
} catch (e) {
|
|
@@ -134,6 +158,7 @@ export class PlayerManager extends EventEmitter {
|
|
|
134
158
|
this.autoCleanup = options.autoCleanup ?? true;
|
|
135
159
|
this.cleanupTimeout = options.cleanupInterval ?? 60000;
|
|
136
160
|
this.enableSearchCache = options.enableSearchCache ?? true;
|
|
161
|
+
this.trackMiddlewareFromOptions = normalizeTrackMiddleware(options.trackMiddleware);
|
|
137
162
|
|
|
138
163
|
// Setup auto cleanup
|
|
139
164
|
if (this.autoCleanup) {
|
|
@@ -149,11 +174,6 @@ export class PlayerManager extends EventEmitter {
|
|
|
149
174
|
this.debug(`Initialized with ${this.plugins.length} plugins, ${this.extensions.length} extensions`);
|
|
150
175
|
}
|
|
151
176
|
|
|
152
|
-
private withTimeout<T>(promise: Promise<T>, message: string): Promise<T> {
|
|
153
|
-
const timeout = this.extractorTimeout;
|
|
154
|
-
return Promise.race([promise, new Promise<never>((_, reject) => setTimeout(() => reject(new Error(message)), timeout))]);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
177
|
private resolveGuildId(guildOrId: string | { id: string }): string {
|
|
158
178
|
if (typeof guildOrId === "string") return guildOrId;
|
|
159
179
|
if (guildOrId && typeof guildOrId === "object" && "id" in guildOrId) return guildOrId.id;
|
|
@@ -571,12 +591,208 @@ export class PlayerManager extends EventEmitter {
|
|
|
571
591
|
}
|
|
572
592
|
}
|
|
573
593
|
|
|
594
|
+
/**
|
|
595
|
+
* Like {@link broadcast} but awaits every return value (for async methods such as `play`).
|
|
596
|
+
* Uses `Promise.allSettled` — failures are captured per guild, not thrown as a whole.
|
|
597
|
+
*/
|
|
598
|
+
async broadcastAsync(action: string, ...args: any[]): Promise<PromiseSettledResult<unknown>[]> {
|
|
599
|
+
const pending: Promise<unknown>[] = [];
|
|
600
|
+
for (const player of this.players.values()) {
|
|
601
|
+
const fn = (player as any)[action];
|
|
602
|
+
if (typeof fn !== "function") continue;
|
|
603
|
+
try {
|
|
604
|
+
pending.push(Promise.resolve(fn.apply(player, args)));
|
|
605
|
+
} catch (error) {
|
|
606
|
+
pending.push(Promise.reject(error));
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return Promise.allSettled(pending);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Broadcast a player method only to the given guild ids (players must already exist).
|
|
614
|
+
*/
|
|
615
|
+
broadcastGuilds(guildIds: readonly string[], action: string, ...args: any[]): void {
|
|
616
|
+
const wanted = new Set(guildIds);
|
|
617
|
+
for (const player of this.players.values()) {
|
|
618
|
+
if (!wanted.has(player.guildId)) continue;
|
|
619
|
+
if (typeof (player as any)[action] === "function") {
|
|
620
|
+
try {
|
|
621
|
+
(player as any)[action](...args);
|
|
622
|
+
} catch (error) {
|
|
623
|
+
this.debug(`Error broadcasting ${action} to ${player.guildId}:`, error);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Global {@link TrackMiddleware} configured on this manager (applied before per-player middleware).
|
|
631
|
+
*/
|
|
632
|
+
getTrackMiddlewareChain(): TrackMiddleware[] {
|
|
633
|
+
return [...this.trackMiddlewareFromOptions];
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Mirror playback controls from a leader guild to follower guilds (each guild must already have a {@link Player} via
|
|
638
|
+
* {@link create}). Followers receive the same track on `trackStart`, and pause/resume/stop/volume from the leader when
|
|
639
|
+
* applicable.
|
|
640
|
+
*
|
|
641
|
+
* @returns Unsubscribe function — also runs when the leader player is destroyed.
|
|
642
|
+
*/
|
|
643
|
+
subscribePlaybackMirror(options: PlaybackMirrorOptions): () => void {
|
|
644
|
+
const leader = this.get(options.leaderGuildId);
|
|
645
|
+
if (!leader) {
|
|
646
|
+
throw new Error(`subscribePlaybackMirror: no player for leader guild ${options.leaderGuildId}`);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const followers = [...new Set(options.followerGuildIds)].filter((id) => id !== options.leaderGuildId);
|
|
650
|
+
const mirrorUserId = options.mirrorUserId;
|
|
651
|
+
const syncVolume = options.syncVolume ?? true;
|
|
652
|
+
const forwardMode = options.forwardMode ?? true;
|
|
653
|
+
|
|
654
|
+
const existing = this.playbackMirrorUnsubscribes.get(options.leaderGuildId);
|
|
655
|
+
existing?.();
|
|
656
|
+
|
|
657
|
+
const runFollowers = (fn: (p: Player) => void | Promise<void>) => {
|
|
658
|
+
for (const gid of followers) {
|
|
659
|
+
const fp = this.get(gid);
|
|
660
|
+
if (!fp) {
|
|
661
|
+
this.debug(`Playback mirror: no player for follower guild ${gid}`);
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
Promise.resolve(fn(fp)).catch((e) => this.debug(`Playback mirror follower error (${gid}):`, e));
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const onTrackStart = async (track: Track) => {
|
|
669
|
+
if (!forwardMode) {
|
|
670
|
+
runFollowers(async (fp) => {
|
|
671
|
+
fp.stop();
|
|
672
|
+
await fp.play(track, mirrorUserId);
|
|
673
|
+
});
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
runFollowers((fp) => {
|
|
678
|
+
if (!fp.connection || !leader.connection) {
|
|
679
|
+
this.debug(`Playback mirror forwardMode: missing connection for follower ${fp.guildId}`);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
try {
|
|
684
|
+
// sync state
|
|
685
|
+
fp.queue.clear();
|
|
686
|
+
|
|
687
|
+
// optional fake current track sync
|
|
688
|
+
if (track) {
|
|
689
|
+
fp.queue.setCurrentTrack(track);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// subscribe directly to leader player
|
|
693
|
+
fp.subscribeTo(leader);
|
|
694
|
+
fp.isPlaying = leader.isPlaying;
|
|
695
|
+
fp.isPaused = leader.isPaused;
|
|
696
|
+
|
|
697
|
+
fp.emit("trackStart", track);
|
|
698
|
+
this.debug(`Playback mirror forwardMode subscribed ${fp.guildId} -> ${leader.guildId}`);
|
|
699
|
+
} catch (e) {
|
|
700
|
+
this.debug(`Playback mirror forwardMode error (${fp.guildId}):`, e);
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
const onPause = () => {
|
|
706
|
+
runFollowers((fp) => {
|
|
707
|
+
if (forwardMode) {
|
|
708
|
+
fp.isPaused = true;
|
|
709
|
+
fp.isPlaying = false;
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
fp.pause();
|
|
714
|
+
});
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
const onResume = () => {
|
|
718
|
+
runFollowers((fp) => {
|
|
719
|
+
if (forwardMode) {
|
|
720
|
+
fp.isPaused = false;
|
|
721
|
+
fp.isPlaying = true;
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
fp.resume();
|
|
726
|
+
});
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
const onStop = () => {
|
|
730
|
+
runFollowers((fp) => {
|
|
731
|
+
if (forwardMode) {
|
|
732
|
+
try {
|
|
733
|
+
fp.connection?.subscribe(fp.audioPlayer);
|
|
734
|
+
|
|
735
|
+
fp.isPlaying = false;
|
|
736
|
+
fp.isPaused = false;
|
|
737
|
+
|
|
738
|
+
fp.audioPlayer.stop(true);
|
|
739
|
+
} catch {}
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
fp.stop();
|
|
744
|
+
});
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
const onVolume = (_oldVol: number, newVol: number) => {
|
|
748
|
+
if (!syncVolume) return;
|
|
749
|
+
runFollowers((fp) => {
|
|
750
|
+
fp.setVolume(newVol);
|
|
751
|
+
});
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
let closed = false;
|
|
755
|
+
const unsubscribe = () => {
|
|
756
|
+
if (closed) return;
|
|
757
|
+
closed = true;
|
|
758
|
+
leader.off("trackStart", onTrackStart);
|
|
759
|
+
leader.off("playerPause", onPause);
|
|
760
|
+
leader.off("playerResume", onResume);
|
|
761
|
+
leader.off("playerStop", onStop);
|
|
762
|
+
leader.off("volumeChange", onVolume);
|
|
763
|
+
leader.off("playerDestroy", onLeaderDestroy);
|
|
764
|
+
onStop();
|
|
765
|
+
this.playbackMirrorUnsubscribes.delete(options.leaderGuildId);
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
const onLeaderDestroy = () => {
|
|
769
|
+
unsubscribe();
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
leader.on("trackStart", onTrackStart);
|
|
773
|
+
leader.on("playerPause", onPause);
|
|
774
|
+
leader.on("playerResume", onResume);
|
|
775
|
+
leader.on("playerStop", onStop);
|
|
776
|
+
leader.on("volumeChange", onVolume);
|
|
777
|
+
leader.on("playerDestroy", onLeaderDestroy);
|
|
778
|
+
if (leader?.currentTrack) onTrackStart(leader.currentTrack);
|
|
779
|
+
this.playbackMirrorUnsubscribes.set(options.leaderGuildId, unsubscribe);
|
|
780
|
+
return unsubscribe;
|
|
781
|
+
}
|
|
782
|
+
|
|
574
783
|
/**
|
|
575
784
|
* Destroy all players and clean up
|
|
576
785
|
*/
|
|
577
786
|
destroy(): void {
|
|
578
787
|
this.debug(`Destroying all players`);
|
|
579
788
|
|
|
789
|
+
for (const unsub of this.playbackMirrorUnsubscribes.values()) {
|
|
790
|
+
try {
|
|
791
|
+
unsub();
|
|
792
|
+
} catch {}
|
|
793
|
+
}
|
|
794
|
+
this.playbackMirrorUnsubscribes.clear();
|
|
795
|
+
|
|
580
796
|
// Stop cleanup intervals
|
|
581
797
|
if (this.cleanupInterval) {
|
|
582
798
|
clearInterval(this.cleanupInterval);
|
|
@@ -600,30 +816,42 @@ export class PlayerManager extends EventEmitter {
|
|
|
600
816
|
}
|
|
601
817
|
|
|
602
818
|
/**
|
|
603
|
-
* Search using
|
|
819
|
+
* Search using PluginManager without creating a Player.
|
|
604
820
|
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
821
|
+
* Uses the same search pipeline as Player.search():
|
|
822
|
+
* - cache
|
|
823
|
+
* - plugin deduplication
|
|
824
|
+
* - plugin scoring/evaluation
|
|
825
|
+
* - fallback handling
|
|
826
|
+
*
|
|
827
|
+
* @param {string} query
|
|
828
|
+
* @param {string} requestedBy
|
|
829
|
+
* @returns {Promise<SearchResult>}
|
|
608
830
|
*/
|
|
609
831
|
async search(query: string, requestedBy: string): Promise<SearchResult> {
|
|
610
832
|
this.debug(`Search called with query: ${query}, requestedBy: ${requestedBy}`);
|
|
611
833
|
|
|
612
|
-
//
|
|
834
|
+
// Cache
|
|
613
835
|
const cached = this.getCachedSearch(query);
|
|
614
836
|
if (cached) {
|
|
615
837
|
return cached;
|
|
616
838
|
}
|
|
617
839
|
|
|
618
|
-
const plugin = this.plugins.find((p) => p.canHandle(query));
|
|
619
|
-
if (!plugin) {
|
|
620
|
-
this.debug(`No plugin found to handle: ${query}`);
|
|
621
|
-
throw new Error(`No plugin found to handle: ${query}`);
|
|
622
|
-
}
|
|
623
|
-
|
|
624
840
|
try {
|
|
625
|
-
const result = await this.
|
|
841
|
+
const result = await this.pluginManager.search(query, requestedBy);
|
|
842
|
+
|
|
843
|
+
if (!result || !Array.isArray(result.tracks) || result.tracks.length === 0) {
|
|
844
|
+
throw new Error(`No results found for: ${query}`);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
this.debug(`Plugin search returned ${result.tracks.length} tracks (score: ${result.score?.score ?? "unknown"}%)`);
|
|
848
|
+
|
|
849
|
+
if (result.score) {
|
|
850
|
+
this.debug(`Search evaluation - ${result.score.reason}`);
|
|
851
|
+
}
|
|
852
|
+
|
|
626
853
|
this.setCachedSearch(query, result);
|
|
854
|
+
|
|
627
855
|
return result;
|
|
628
856
|
} catch (error) {
|
|
629
857
|
this.debug(`Search error:`, error);
|
|
@@ -647,9 +875,10 @@ export class PlayerManager extends EventEmitter {
|
|
|
647
875
|
*/
|
|
648
876
|
registerPlugin(plugin: SourcePlugin): void {
|
|
649
877
|
this.plugins.push(plugin);
|
|
878
|
+
this.pluginManager.register(plugin);
|
|
879
|
+
|
|
650
880
|
this.debug(`Registered plugin: ${plugin.name}`);
|
|
651
881
|
|
|
652
|
-
// Register plugin with all existing players
|
|
653
882
|
for (const player of this.players.values()) {
|
|
654
883
|
player.addPlugin(plugin);
|
|
655
884
|
}
|
|
@@ -666,9 +895,10 @@ export class PlayerManager extends EventEmitter {
|
|
|
666
895
|
if (index === -1) return false;
|
|
667
896
|
|
|
668
897
|
this.plugins.splice(index, 1);
|
|
898
|
+
this.pluginManager.unregister(name);
|
|
899
|
+
|
|
669
900
|
this.debug(`Unregistered plugin: ${name}`);
|
|
670
901
|
|
|
671
|
-
// Note: Cannot easily remove plugins from existing players
|
|
672
902
|
return true;
|
|
673
903
|
}
|
|
674
904
|
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { createAudioResource, AudioResource } from "@discordjs/voice";
|
|
2
|
+
import type { Track, StreamInfo, StreamSlot } from "../types";
|
|
3
|
+
import type { StreamManager } from "./StreamManager";
|
|
4
|
+
|
|
5
|
+
interface PreloadManagerDeps {
|
|
6
|
+
streamManager: StreamManager;
|
|
7
|
+
debug: (message?: any, ...optionalParams: any[]) => void;
|
|
8
|
+
getNextTrack: () => Track | null;
|
|
9
|
+
getStream: (track: Track) => Promise<StreamInfo | null>;
|
|
10
|
+
isDestroyed: () => boolean;
|
|
11
|
+
isEnabled: () => boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class PreloadManager {
|
|
15
|
+
private readonly streamManager: StreamManager;
|
|
16
|
+
private readonly debugLog: (message?: any, ...optionalParams: any[]) => void;
|
|
17
|
+
private readonly getNextTrack: () => Track | null;
|
|
18
|
+
private readonly getStream: (track: Track) => Promise<StreamInfo | null>;
|
|
19
|
+
private readonly isDestroyed: () => boolean;
|
|
20
|
+
private readonly isEnabled: () => boolean;
|
|
21
|
+
|
|
22
|
+
private preloadLock = false;
|
|
23
|
+
private readonly preloadSlot: StreamSlot = {
|
|
24
|
+
resource: null,
|
|
25
|
+
track: null,
|
|
26
|
+
streamId: null,
|
|
27
|
+
abortController: null,
|
|
28
|
+
isValid: false,
|
|
29
|
+
isLoading: false,
|
|
30
|
+
loadPromise: null,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
constructor(deps: PreloadManagerDeps) {
|
|
34
|
+
this.streamManager = deps.streamManager;
|
|
35
|
+
this.debugLog = deps.debug;
|
|
36
|
+
this.getNextTrack = deps.getNextTrack;
|
|
37
|
+
this.getStream = deps.getStream;
|
|
38
|
+
this.isDestroyed = deps.isDestroyed;
|
|
39
|
+
this.isEnabled = deps.isEnabled;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public hasValidPreload(track: Track): boolean {
|
|
43
|
+
return !!(
|
|
44
|
+
this.preloadSlot.isValid &&
|
|
45
|
+
this.preloadSlot.track?.id === track.id &&
|
|
46
|
+
this.preloadSlot.resource &&
|
|
47
|
+
this.preloadSlot.resource.playStream?.readable !== false
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public promoteToCurrent(track: Track, currentSlot: StreamSlot): AudioResource | null {
|
|
52
|
+
const promotedResource = this.preloadSlot.resource;
|
|
53
|
+
const promotedStreamId = this.preloadSlot.streamId;
|
|
54
|
+
if (!promotedResource) return null;
|
|
55
|
+
|
|
56
|
+
currentSlot.resource = promotedResource;
|
|
57
|
+
currentSlot.track = track;
|
|
58
|
+
currentSlot.streamId = promotedStreamId;
|
|
59
|
+
currentSlot.abortController = null;
|
|
60
|
+
currentSlot.isValid = true;
|
|
61
|
+
currentSlot.isLoading = false;
|
|
62
|
+
currentSlot.loadPromise = null;
|
|
63
|
+
|
|
64
|
+
this.preloadSlot.resource = null;
|
|
65
|
+
this.preloadSlot.track = null;
|
|
66
|
+
this.preloadSlot.streamId = null;
|
|
67
|
+
this.preloadSlot.abortController = null;
|
|
68
|
+
this.preloadSlot.isValid = false;
|
|
69
|
+
this.preloadSlot.isLoading = false;
|
|
70
|
+
this.preloadSlot.loadPromise = null;
|
|
71
|
+
|
|
72
|
+
return promotedResource;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public async preloadNextTrack(): Promise<void> {
|
|
76
|
+
if (this.isDestroyed()) return;
|
|
77
|
+
if (!this.isEnabled()) {
|
|
78
|
+
this.debugLog(`[Preload] Disabled by options/runtime profile`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (this.preloadLock) {
|
|
83
|
+
this.debugLog(`[Preload] Already preloading, skipping`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const nextTrack = this.getNextTrack();
|
|
88
|
+
if (!nextTrack) {
|
|
89
|
+
this.debugLog(`[Preload] No next track to preload`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (this.preloadSlot.isValid && this.preloadSlot.track?.id === nextTrack.id && this.preloadSlot.resource) {
|
|
94
|
+
this.debugLog(`[Preload] Already have valid preload for: ${nextTrack.title}`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (this.preloadSlot.isLoading && this.preloadSlot.track?.id === nextTrack.id) {
|
|
99
|
+
this.debugLog(`[Preload] Currently loading same track, waiting...`);
|
|
100
|
+
if (this.preloadSlot.loadPromise) {
|
|
101
|
+
await this.preloadSlot.loadPromise;
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (this.preloadSlot.isValid && this.preloadSlot.track?.id !== nextTrack.id) {
|
|
107
|
+
this.debugLog(`[Preload] Cancelling old preload for different track: ${this.preloadSlot.track?.title}`);
|
|
108
|
+
await this.safeCancelPreload();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this.preloadLock = true;
|
|
112
|
+
const abortController = new AbortController();
|
|
113
|
+
this.preloadSlot.track = nextTrack;
|
|
114
|
+
this.preloadSlot.abortController = abortController;
|
|
115
|
+
this.preloadSlot.isLoading = true;
|
|
116
|
+
|
|
117
|
+
const loadPromise = this.executePreload(nextTrack, abortController);
|
|
118
|
+
this.preloadSlot.loadPromise = loadPromise;
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
await loadPromise;
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (err instanceof Error && err.message === "PRELOAD_CANCELLED") {
|
|
124
|
+
this.debugLog(`[Preload] Cancelled for ${nextTrack.title}`);
|
|
125
|
+
} else {
|
|
126
|
+
this.debugLog(`[Preload] Failed for ${nextTrack.title}:`, err);
|
|
127
|
+
}
|
|
128
|
+
this.clearPreloadSlot();
|
|
129
|
+
} finally {
|
|
130
|
+
this.preloadLock = false;
|
|
131
|
+
this.preloadSlot.isLoading = false;
|
|
132
|
+
this.preloadSlot.loadPromise = null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
public async safeCancelPreload(): Promise<void> {
|
|
137
|
+
if (!this.preloadSlot.abortController && !this.preloadSlot.resource) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
this.debugLog(`[Preload] Safely cancelling preload for: ${this.preloadSlot.track?.title || "unknown"}`);
|
|
142
|
+
|
|
143
|
+
if (this.preloadSlot.abortController) {
|
|
144
|
+
this.preloadSlot.abortController.abort();
|
|
145
|
+
this.preloadSlot.abortController = null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (this.preloadSlot.streamId) {
|
|
149
|
+
this.streamManager.unregisterStream(this.preloadSlot.streamId, true);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (this.preloadSlot.resource) {
|
|
153
|
+
try {
|
|
154
|
+
const stream = this.preloadSlot.resource.playStream;
|
|
155
|
+
if (stream && typeof stream.destroy === "function" && !stream.destroyed) {
|
|
156
|
+
stream.destroy();
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
// ignore
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
this.clearPreloadSlot();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
public cancelPreload(): void {
|
|
167
|
+
if (this.preloadSlot.abortController) {
|
|
168
|
+
this.debugLog(`[Preload] Cancelling preload for: ${this.preloadSlot.track?.title}`);
|
|
169
|
+
this.preloadSlot.abortController.abort();
|
|
170
|
+
}
|
|
171
|
+
if (this.preloadSlot.streamId) {
|
|
172
|
+
this.streamManager.unregisterStream(this.preloadSlot.streamId, true);
|
|
173
|
+
}
|
|
174
|
+
this.clearPreloadSlot();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
public clearPreloadSlot(): void {
|
|
178
|
+
if (this.preloadSlot.resource) {
|
|
179
|
+
try {
|
|
180
|
+
const stream = this.preloadSlot.resource.playStream;
|
|
181
|
+
if (stream && typeof stream.destroy === "function" && !stream.destroyed) {
|
|
182
|
+
stream.destroy();
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
// ignore
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (this.preloadSlot.streamId) {
|
|
190
|
+
this.streamManager.unregisterStream(this.preloadSlot.streamId, true);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
this.preloadSlot.resource = null;
|
|
194
|
+
this.preloadSlot.track = null;
|
|
195
|
+
this.preloadSlot.streamId = null;
|
|
196
|
+
this.preloadSlot.abortController = null;
|
|
197
|
+
this.preloadSlot.isValid = false;
|
|
198
|
+
this.preloadSlot.isLoading = false;
|
|
199
|
+
this.preloadSlot.loadPromise = null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private async executePreload(track: Track, abortController: AbortController): Promise<void> {
|
|
203
|
+
if (this.isDestroyed()) throw new Error("PLAYER_DESTROYED");
|
|
204
|
+
this.debugLog(`[Preload] Starting preload for: ${track.title}`);
|
|
205
|
+
|
|
206
|
+
if (abortController.signal.aborted) {
|
|
207
|
+
throw new Error("PRELOAD_CANCELLED");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (this.getNextTrack()?.id !== track.id) {
|
|
211
|
+
this.debugLog(`[Preload] Track changed, cancelling`);
|
|
212
|
+
throw new Error("PRELOAD_CANCELLED");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const streamInfo = await this.getStreamWithCancel(track, abortController.signal);
|
|
216
|
+
if (abortController.signal.aborted) {
|
|
217
|
+
throw new Error("PRELOAD_CANCELLED");
|
|
218
|
+
}
|
|
219
|
+
if (this.getNextTrack()?.id !== track.id) {
|
|
220
|
+
this.debugLog(`[Preload] Track changed after stream fetch`);
|
|
221
|
+
throw new Error("PRELOAD_CANCELLED");
|
|
222
|
+
}
|
|
223
|
+
if (!streamInfo?.stream) {
|
|
224
|
+
throw new Error(`No stream available`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const streamId = this.streamManager.registerStream(streamInfo.stream, track, {
|
|
228
|
+
source: track.source || "preload",
|
|
229
|
+
isPreload: true,
|
|
230
|
+
priority: 5,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const resource = createAudioResource(streamInfo.stream, {
|
|
234
|
+
inlineVolume: true,
|
|
235
|
+
metadata: { ...track, preloaded: true },
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (!resource.playStream || resource.playStream.readable === false) {
|
|
239
|
+
throw new Error("Resource not readable");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
this.preloadSlot.resource = resource;
|
|
243
|
+
this.preloadSlot.streamId = streamId;
|
|
244
|
+
this.preloadSlot.isValid = true;
|
|
245
|
+
this.preloadSlot.track = track;
|
|
246
|
+
|
|
247
|
+
this.debugLog(`[Preload] Successfully preloaded: ${track.title} (Stream ID: ${streamId})`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private async getStreamWithCancel(track: Track, signal: AbortSignal): Promise<StreamInfo | null> {
|
|
251
|
+
if (this.isDestroyed()) throw new Error("PLAYER_DESTROYED");
|
|
252
|
+
const abortPromise = new Promise<never>((_, reject) => {
|
|
253
|
+
if (signal.aborted) {
|
|
254
|
+
reject(new Error("PRELOAD_CANCELLED"));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const handler = () => {
|
|
258
|
+
signal.removeEventListener("abort", handler);
|
|
259
|
+
reject(new Error("PRELOAD_CANCELLED"));
|
|
260
|
+
};
|
|
261
|
+
signal.addEventListener("abort", handler);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const existingStream = this.streamManager.getStreamByTrack(track.id || track.title);
|
|
265
|
+
if (existingStream && !existingStream.destroyed && existingStream.readable !== false) {
|
|
266
|
+
this.debugLog(`[Stream] Using existing stream for preload: ${track.title}`);
|
|
267
|
+
return { stream: existingStream, type: "arbitrary" };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const streamPromise = this.getStream(track);
|
|
271
|
+
const result = await Promise.race([streamPromise, abortPromise]);
|
|
272
|
+
return result as StreamInfo | null;
|
|
273
|
+
}
|
|
274
|
+
}
|
package/src/structures/Queue.ts
CHANGED
|
@@ -400,6 +400,28 @@ export class Queue {
|
|
|
400
400
|
return this.current;
|
|
401
401
|
}
|
|
402
402
|
|
|
403
|
+
/**
|
|
404
|
+
* Force set current playing track.
|
|
405
|
+
*
|
|
406
|
+
* Mainly used internally for playback synchronization,
|
|
407
|
+
* playback mirroring, restoring player state,
|
|
408
|
+
* or forward-mode shared audio sessions.
|
|
409
|
+
*
|
|
410
|
+
* This does NOT modify queue order/history automatically.
|
|
411
|
+
* It only updates the current active track reference.
|
|
412
|
+
*
|
|
413
|
+
* @param {Track | null} track - Track to set as current
|
|
414
|
+
*
|
|
415
|
+
* @example
|
|
416
|
+
* queue.setCurrentTrack(track);
|
|
417
|
+
*
|
|
418
|
+
* @example
|
|
419
|
+
* queue.setCurrentTrack(null);
|
|
420
|
+
*/
|
|
421
|
+
setCurrentTrack(track: Track | null): void {
|
|
422
|
+
this.current = track;
|
|
423
|
+
}
|
|
424
|
+
|
|
403
425
|
/**
|
|
404
426
|
* Get the previous tracks
|
|
405
427
|
*/
|