ziplayer 0.3.2 → 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.
@@ -14,6 +14,12 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.normalizeTrackMiddleware = normalizeTrackMiddleware;
18
+ function normalizeTrackMiddleware(input) {
19
+ if (!input)
20
+ return [];
21
+ return Array.isArray(input) ? input : [input];
22
+ }
17
23
  __exportStar(require("./fillter"), exports);
18
24
  __exportStar(require("./plugin"), exports);
19
25
  __exportStar(require("./extension"), exports);
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAiiBA,4CAA0B;AAC1B,2CAAyB;AACzB,8CAA4B"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AA4JA,4DAGC;AAHD,SAAgB,wBAAwB,CAAC,KAA2C;IACnF,IAAI,CAAC,KAAK;QAAE,OAAO,EAAE,CAAC;IACtB,OAAO,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;AAC/C,CAAC;AAgbD,4CAA0B;AAC1B,2CAAyB;AACzB,8CAA4B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ziplayer",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "A modular Discord voice player with plugin system",
5
5
  "keywords": [
6
6
  "ZiPlayer",
@@ -848,7 +848,7 @@ export class PluginManager {
848
848
  return [];
849
849
  }
850
850
 
851
- const history = this.player.queue.previousTracks;
851
+ const history = this.player?.queue?.previousTracks || [];
852
852
  const historyUrls = new Set(history.map((t) => t.url));
853
853
  const currentTrackUrl = track.url;
854
854
 
@@ -16,22 +16,24 @@ import {
16
16
  import { Readable } from "stream";
17
17
  import { LRUCache } from "lru-cache";
18
18
  import type { BaseExtension } from "../extensions";
19
- import type {
20
- Track,
21
- PlayerOptions,
22
- PlayerEvents,
23
- SourcePlugin,
24
- SearchResult,
25
- ProgressBarOptions,
26
- LoopMode,
27
- StreamInfo,
28
- SaveOptions,
29
- VoiceChannel,
30
- PlayerSession,
31
- ExtensionPlayRequest,
32
- ExtensionPlayResponse,
33
- ExtensionAfterPlayPayload,
34
- StreamSlot,
19
+ import {
20
+ normalizeTrackMiddleware,
21
+ type Track,
22
+ type PlayerOptions,
23
+ type PlayerEvents,
24
+ type SourcePlugin,
25
+ type SearchResult,
26
+ type ProgressBarOptions,
27
+ type LoopMode,
28
+ type StreamInfo,
29
+ type SaveOptions,
30
+ type VoiceChannel,
31
+ type PlayerSession,
32
+ type ExtensionPlayRequest,
33
+ type ExtensionPlayResponse,
34
+ type ExtensionAfterPlayPayload,
35
+ type StreamSlot,
36
+ type TrackMiddleware,
35
37
  } from "../types";
36
38
  import type { PlayerManager } from "./PlayerManager";
37
39
 
@@ -96,6 +98,7 @@ export class Player extends EventEmitter {
96
98
  public extensionManager: ExtensionManager;
97
99
  public streamManager: StreamManager;
98
100
  public preloadManager: PreloadManager;
101
+ public forwardMode: Boolean = false;
99
102
 
100
103
  public userdata?: Record<string, any>;
101
104
  public _lastActivity: number = Date.now();
@@ -154,6 +157,7 @@ export class Player extends EventEmitter {
154
157
  private loudnessMaxCutDb = 10;
155
158
  private loudnessLimiterCeiling = 0.95;
156
159
  private destroyed = false;
160
+ private readonly trackMiddlewareChain: TrackMiddleware[];
157
161
 
158
162
  // Cache for search results to avoid duplicate calls
159
163
  private searchCache: LRUCache<string, SearchResult>;
@@ -247,6 +251,9 @@ export class Player extends EventEmitter {
247
251
  this.debug(
248
252
  `[Player] Runtime options resolved: lowPerformance=${this.lowPerformanceMode}, preload=${this.preloadEnabled}, crossfade=${this.crossfadeEnabled} (${this.crossfadeDurationMs}ms), smartTransition=${this.smartTransitionEnabled}, antiStuck=${this.antiStuckEnabled}, loudnessNormalization=${this.loudnessNormalizationEnabled}`,
249
253
  );
254
+
255
+ this.trackMiddlewareChain = [...this.manager.getTrackMiddlewareChain(), ...normalizeTrackMiddleware(options.trackMiddleware)];
256
+
250
257
  this.filter = new FilterManager(this, this.manager);
251
258
  this.extensionManager = new ExtensionManager(this, this.manager);
252
259
  this.pluginManager = new PluginManager(this, this.manager, {
@@ -906,10 +913,36 @@ export class Player extends EventEmitter {
906
913
  }
907
914
  }
908
915
 
916
+ private mergeTrackPreserveRef(target: Track, source: Track): void {
917
+ if (source === target) return;
918
+ const mergedMeta = {
919
+ ...(target.metadata || {}),
920
+ ...(source.metadata || {}),
921
+ };
922
+ Object.assign(target, source);
923
+ target.metadata = mergedMeta;
924
+ }
925
+
926
+ private async applyTrackMiddleware(track: Track): Promise<void> {
927
+ if (this.trackMiddlewareChain.length === 0) return;
928
+ const ctx = { player: this, manager: this.manager };
929
+ for (const mw of this.trackMiddlewareChain) {
930
+ try {
931
+ const out = await mw(track, ctx);
932
+ if (out != null && out !== track) {
933
+ this.mergeTrackPreserveRef(track, out);
934
+ }
935
+ } catch (err) {
936
+ this.debug(`[TrackMiddleware] Error:`, err);
937
+ }
938
+ }
939
+ }
940
+
909
941
  private async getStream(track: Track): Promise<StreamInfo | null> {
910
942
  if (this.destroyed) {
911
943
  throw new Error("PLAYER_DESTROYED");
912
944
  }
945
+ await this.applyTrackMiddleware(track);
913
946
  const trackId = track.id || track.url || track.title;
914
947
  const existingStream = this.streamManager.getStreamByTrack(trackId);
915
948
 
@@ -1263,6 +1296,8 @@ export class Player extends EventEmitter {
1263
1296
  if (!this.connection) throw new Error("No voice connection for TTS");
1264
1297
  const ttsPlayer = this.ensureTTSPlayer();
1265
1298
 
1299
+ await this.applyTrackMiddleware(track);
1300
+
1266
1301
  // Build resource from plugin stream
1267
1302
  const streamInfo = await this.pluginManager.getStream(track);
1268
1303
  if (!streamInfo) {
@@ -1387,6 +1422,56 @@ export class Player extends EventEmitter {
1387
1422
  }
1388
1423
  }
1389
1424
 
1425
+ /**
1426
+ * Subscribe this player's voice connection
1427
+ * to another player's audio stream.
1428
+ *
1429
+ * This is primarily used for:
1430
+ * - playback mirroring
1431
+ * - radio/broadcast systems
1432
+ * - multi-guild synchronized playback
1433
+ * - forwardMode shared streaming
1434
+ *
1435
+ * Instead of creating a separate audio stream,
1436
+ * this player will directly receive audio packets
1437
+ * from the target player's AudioPlayer instance.
1438
+ *
1439
+ * Benefits:
1440
+ * - drastically lower CPU usage
1441
+ * - only one ffmpeg/extractor stream
1442
+ * - lower bandwidth and memory usage
1443
+ * - perfect sync across guilds
1444
+ *
1445
+ * Important:
1446
+ * - both players must already have active voice connections
1447
+ * - this does NOT transfer queue ownership
1448
+ * - this does NOT clone playback state automatically
1449
+ *
1450
+ * @param {Player} player - Source player to subscribe to
1451
+ *
1452
+ * @returns {boolean}
1453
+ * Returns true if subscription succeeded,
1454
+ * otherwise false.
1455
+ *
1456
+ * @example
1457
+ * follower.subscribeTo(leader);
1458
+ *
1459
+ * @example
1460
+ * if (!player.subscribeTo(leader)) {
1461
+ * console.log("Failed to subscribe");
1462
+ * }
1463
+ */
1464
+ public subscribeTo(player: Player): boolean {
1465
+ if (!this.connection) return false;
1466
+
1467
+ this.connection.subscribe(player.audioPlayer);
1468
+
1469
+ this.isPlaying = player.isPlaying;
1470
+ this.isPaused = player.isPaused;
1471
+ this.forwardMode = true;
1472
+ return true;
1473
+ }
1474
+
1390
1475
  /**
1391
1476
  * Pause the current track
1392
1477
  *
@@ -1590,6 +1675,8 @@ export class Player extends EventEmitter {
1590
1675
  }
1591
1676
 
1592
1677
  try {
1678
+ await this.applyTrackMiddleware(track);
1679
+
1593
1680
  // Skip extension manager for saving - we want the raw stream without filters/seek applied, and extensions may not support this
1594
1681
  let streamInfo: StreamInfo | null = await this.pluginManager.getStream(track);
1595
1682
 
@@ -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
 
@@ -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
  */
@@ -122,6 +122,43 @@ export interface StreamInfo {
122
122
  metadata?: Record<string, any>;
123
123
  }
124
124
 
125
+ /** Passed to each {@link TrackMiddleware} run (before stream resolution). */
126
+ export interface TrackMiddlewareContext {
127
+ player: Player;
128
+ manager: PlayerManager;
129
+ }
130
+
131
+ /**
132
+ * Runs immediately before stream extraction (`Player.getStream`): after enqueue, before extension `provideStream` and plugins.
133
+ * Prefer mutating `track` in place (especially `metadata`). If you return another object, its fields are merged into the original
134
+ * `track` reference so queue/current-track pointers stay valid.
135
+ */
136
+ export type TrackMiddleware = (track: Track, context: TrackMiddlewareContext) => void | Track | Promise<void | Track>;
137
+
138
+ /** Options for {@link PlayerManager.subscribePlaybackMirror}. */
139
+ export interface PlaybackMirrorOptions {
140
+ leaderGuildId: string;
141
+ followerGuildIds: string[];
142
+ /** User id passed to follower `play()` (often the bot application id). */
143
+ mirrorUserId: string;
144
+ /** When true (default), follower `setVolume` tracks the leader. */
145
+ syncVolume?: boolean;
146
+ /**
147
+ * When enabled, follower connections subscribe directly
148
+ * to leader.audioPlayer instead of creating their own streams.
149
+ *
150
+ * Greatly reduces bandwidth/CPU usage.
151
+ *
152
+ * Default: true
153
+ */
154
+ forwardMode?: boolean;
155
+ }
156
+
157
+ export function normalizeTrackMiddleware(input?: TrackMiddleware | TrackMiddleware[]): TrackMiddleware[] {
158
+ if (!input) return [];
159
+ return Array.isArray(input) ? input : [input];
160
+ }
161
+
125
162
  /**
126
163
  * Configuration options for creating a new player instance.
127
164
  *
@@ -302,6 +339,11 @@ export interface PlayerOptions {
302
339
  */
303
340
  limiterCeiling?: number;
304
341
  };
342
+ /**
343
+ * Chain of middleware applied to every track immediately before stream extraction (after queueing).
344
+ * Merged after {@link PlayerManagerOptions.trackMiddleware} from the manager.
345
+ */
346
+ trackMiddleware?: TrackMiddleware | TrackMiddleware[];
305
347
  }
306
348
 
307
349
  export interface PlayerManagerOptions {
@@ -312,6 +354,10 @@ export interface PlayerManagerOptions {
312
354
  cleanupInterval?: number; // Cleanup interval in ms
313
355
  enableSearchCache?: boolean; // Enable search result caching
314
356
  enableStatsCollection?: boolean; // Enable stats collection events
357
+ /**
358
+ * Global track middleware for every {@link Player} created from this manager (before per-player middleware).
359
+ */
360
+ trackMiddleware?: TrackMiddleware | TrackMiddleware[];
315
361
  }
316
362
 
317
363
  /**