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.
Files changed (41) hide show
  1. package/{AI-Guide.md → AGENTS.md} +36 -7
  2. package/README.md +113 -0
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +3 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/plugins/index.js +1 -1
  8. package/dist/plugins/index.js.map +1 -1
  9. package/dist/structures/Player.d.ts +48 -17
  10. package/dist/structures/Player.d.ts.map +1 -1
  11. package/dist/structures/Player.js +154 -377
  12. package/dist/structures/Player.js.map +1 -1
  13. package/dist/structures/PlayerManager.d.ts +35 -6
  14. package/dist/structures/PlayerManager.d.ts.map +1 -1
  15. package/dist/structures/PlayerManager.js +215 -19
  16. package/dist/structures/PlayerManager.js.map +1 -1
  17. package/dist/structures/PreloadManager.d.ts +32 -0
  18. package/dist/structures/PreloadManager.d.ts.map +1 -0
  19. package/dist/structures/PreloadManager.js +230 -0
  20. package/dist/structures/PreloadManager.js.map +1 -0
  21. package/dist/structures/Queue.d.ts +19 -0
  22. package/dist/structures/Queue.d.ts.map +1 -1
  23. package/dist/structures/Queue.js +21 -0
  24. package/dist/structures/Queue.js.map +1 -1
  25. package/dist/structures/StreamManager.d.ts +1 -0
  26. package/dist/structures/StreamManager.d.ts.map +1 -1
  27. package/dist/structures/StreamManager.js +37 -3
  28. package/dist/structures/StreamManager.js.map +1 -1
  29. package/dist/types/index.d.ts +41 -1
  30. package/dist/types/index.d.ts.map +1 -1
  31. package/dist/types/index.js +6 -0
  32. package/dist/types/index.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/index.ts +1 -0
  35. package/src/plugins/index.ts +1 -1
  36. package/src/structures/Player.ts +174 -440
  37. package/src/structures/PlayerManager.ts +253 -23
  38. package/src/structures/PreloadManager.ts +274 -0
  39. package/src/structures/Queue.ts +22 -0
  40. package/src/structures/StreamManager.ts +41 -4
  41. 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 { PlayerManagerOptions, PlayerOptions, Track, SourcePlugin, SearchResult, ManagerEvents, PlayerStats } from "../types";
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
- this.plugins.push(p as SourcePlugin);
141
+ instance = p as SourcePlugin;
122
142
  } else if (typeof p === "function") {
123
- const instance = new (p as any)();
124
- this.plugins.push(instance as SourcePlugin);
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 registered plugins without creating a Player.
819
+ * Search using PluginManager without creating a Player.
604
820
  *
605
- * @param {string} query - The query to search for
606
- * @param {string} requestedBy - The user ID who requested the search
607
- * @returns {Promise<SearchResult>} The search result
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
- // Check cache first
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.withTimeout(plugin.search(query, requestedBy), "Search operation timed out");
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
+ }
@@ -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
  */