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
@@ -16,32 +16,33 @@ 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
- PreloadState,
35
- 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,
36
37
  } from "../types";
37
38
  import type { PlayerManager } from "./PlayerManager";
38
39
 
39
40
  import { Queue } from "./Queue";
40
41
  import { PluginManager } from "../plugins";
41
42
  import { ExtensionManager } from "../extensions";
42
- import { withTimeout } from "../utils/timeout";
43
43
  import { FilterManager } from "./FilterManager";
44
44
  import { StreamManager } from "./StreamManager";
45
+ import { PreloadManager } from "./PreloadManager";
45
46
 
46
47
  export declare interface Player {
47
48
  on<K extends keyof PlayerEvents>(event: K, listener: (...args: PlayerEvents[K]) => void): this;
@@ -96,6 +97,8 @@ export class Player extends EventEmitter {
96
97
  public pluginManager: PluginManager;
97
98
  public extensionManager: ExtensionManager;
98
99
  public streamManager: StreamManager;
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();
@@ -108,17 +111,7 @@ export class Player extends EventEmitter {
108
111
  private skipLoop = false;
109
112
  private filter!: FilterManager;
110
113
  private refreshLock = false;
111
- //preloaded resource
112
114
 
113
- private preloadState: PreloadState = {
114
- resource: null,
115
- track: null,
116
- abortController: null,
117
- timeoutId: null,
118
- isValid: false,
119
- isBeingUsed: false,
120
- };
121
- private isPreloading = false;
122
115
  private currentSlot: StreamSlot = {
123
116
  resource: null,
124
117
  track: null,
@@ -129,16 +122,6 @@ export class Player extends EventEmitter {
129
122
  loadPromise: null,
130
123
  };
131
124
 
132
- private preloadSlot: StreamSlot = {
133
- resource: null,
134
- track: null,
135
- streamId: null,
136
- abortController: null,
137
- isValid: false,
138
- isLoading: false,
139
- loadPromise: null,
140
- };
141
- private preloadLock = false;
142
125
  private preloadEnabled = true;
143
126
  private crossfadeEnabled = true;
144
127
  private crossfadeDurationMs = 500;
@@ -173,6 +156,8 @@ export class Player extends EventEmitter {
173
156
  private loudnessMaxBoostDb = 8;
174
157
  private loudnessMaxCutDb = 10;
175
158
  private loudnessLimiterCeiling = 0.95;
159
+ private destroyed = false;
160
+ private readonly trackMiddlewareChain: TrackMiddleware[];
176
161
 
177
162
  // Cache for search results to avoid duplicate calls
178
163
  private searchCache: LRUCache<string, SearchResult>;
@@ -223,7 +208,7 @@ export class Player extends EventEmitter {
223
208
  const crossfadeOptions = this.options.crossfade || {};
224
209
  const crossfadeAutoEnable = crossfadeOptions.autoEnable ?? true;
225
210
  const crossfadeAutoDisable = crossfadeOptions.autoDisableInLowPerformance ?? true;
226
- this.crossfadeDurationMs = Math.max(0, crossfadeOptions.durationMs ?? 5000);
211
+ this.crossfadeDurationMs = Math.max(0, crossfadeOptions.durationMs ?? 500);
227
212
 
228
213
  if (typeof crossfadeOptions.enabled === "boolean") {
229
214
  this.crossfadeEnabled = crossfadeOptions.enabled;
@@ -266,18 +251,29 @@ export class Player extends EventEmitter {
266
251
  this.debug(
267
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}`,
268
253
  );
254
+
255
+ this.trackMiddlewareChain = [...this.manager.getTrackMiddlewareChain(), ...normalizeTrackMiddleware(options.trackMiddleware)];
256
+
269
257
  this.filter = new FilterManager(this, this.manager);
270
258
  this.extensionManager = new ExtensionManager(this, this.manager);
271
259
  this.pluginManager = new PluginManager(this, this.manager, {
272
260
  extractorTimeout: this.options.extractorTimeout,
273
261
  });
274
262
  this.streamManager = new StreamManager({
275
- maxConcurrentStreams: 20,
263
+ maxConcurrentStreams: 2,
276
264
  streamTimeout: 5 * 60 * 1000,
277
265
  maxListenersPerStream: 15,
278
266
  enableMetrics: true,
279
267
  autoDestroy: true,
280
268
  });
269
+ this.preloadManager = new PreloadManager({
270
+ streamManager: this.streamManager,
271
+ debug: this.debug.bind(this),
272
+ getNextTrack: () => this.queue.nextTrack,
273
+ getStream: (track) => this.getStream(track),
274
+ isDestroyed: () => this.destroyed,
275
+ isEnabled: () => this.preloadEnabled,
276
+ });
281
277
  this.volume = this.options.volume || 100;
282
278
  this.userdata = this.options.userdata;
283
279
  this.searchCache = new LRUCache<string, SearchResult>({
@@ -601,6 +597,10 @@ export class Player extends EventEmitter {
601
597
  this.emit("queueAdd", tracksToAdd[0]);
602
598
  }
603
599
 
600
+ if (this.isPlaying && !this.destroyed) {
601
+ void this.preloadNextTrack().catch((err) => this.debug("[Player] Preload after queue add error:", err));
602
+ }
603
+
604
604
  const started = !this.isPlaying ? await this.playNext() : true;
605
605
 
606
606
  await this.extensionManager.afterPlayHooks({
@@ -632,311 +632,22 @@ export class Player extends EventEmitter {
632
632
  * Main preload method - only one at a time
633
633
  */
634
634
  private async preloadNextTrack(): Promise<void> {
635
- if (!this.preloadEnabled) {
636
- this.debug(`[Preload] Disabled by options/runtime profile`);
637
- return;
638
- }
639
-
640
- // Prevent concurrent preloads
641
- if (this.preloadLock) {
642
- this.debug(`[Preload] Already preloading, skipping`);
643
- return;
644
- }
645
-
646
- const nextTrack = this.queue.nextTrack;
647
- if (!nextTrack) {
648
- this.debug(`[Preload] No next track to preload`);
649
- return;
650
- }
651
-
652
- // Check if already preloaded correctly
653
- if (this.preloadSlot.isValid && this.preloadSlot.track?.id === nextTrack.id && this.preloadSlot.resource) {
654
- this.debug(`[Preload] Already have valid preload for: ${nextTrack.title}`);
655
- return;
656
- }
657
-
658
- // Check if currently loading the same track
659
- if (this.preloadSlot.isLoading && this.preloadSlot.track?.id === nextTrack.id) {
660
- this.debug(`[Preload] Currently loading same track, waiting...`);
661
- if (this.preloadSlot.loadPromise) {
662
- await this.preloadSlot.loadPromise;
663
- }
664
- return;
665
- }
666
-
667
- // Cancel old preload if different track
668
- if (this.preloadSlot.isValid && this.preloadSlot.track?.id !== nextTrack.id) {
669
- this.debug(`[Preload] Cancelling old preload for different track: ${this.preloadSlot.track?.title}`);
670
- await this.safeCancelPreload();
671
- }
672
-
673
- this.preloadLock = true;
674
-
675
- // Create new abort controller
676
- const abortController = new AbortController();
677
-
678
- // Setup preload slot
679
- this.preloadSlot.track = nextTrack;
680
- this.preloadSlot.abortController = abortController;
681
- this.preloadSlot.isLoading = true;
682
-
683
- // Create load promise
684
- const loadPromise = this.executePreload(nextTrack, abortController);
685
- this.preloadSlot.loadPromise = loadPromise;
686
-
687
- try {
688
- await loadPromise;
689
- } catch (err) {
690
- if (err instanceof Error && err.message === "PRELOAD_CANCELLED") {
691
- this.debug(`[Preload] Cancelled for ${nextTrack.title}`);
692
- } else {
693
- this.debug(`[Preload] Failed for ${nextTrack.title}:`, err);
694
- }
695
- this.clearSlot(this.preloadSlot);
696
- } finally {
697
- this.preloadLock = false;
698
- this.preloadSlot.isLoading = false;
699
- this.preloadSlot.loadPromise = null;
700
- }
701
- }
702
-
703
- /**
704
- * Execute actual preload
705
- */
706
- private async executePreload(track: Track, abortController: AbortController): Promise<void> {
707
- this.debug(`[Preload] Starting preload for: ${track.title}`);
708
-
709
- // Check for cancellation
710
- if (abortController.signal.aborted) {
711
- throw new Error("PRELOAD_CANCELLED");
712
- }
713
-
714
- // Check if track still relevant
715
- if (this.queue.nextTrack?.id !== track.id) {
716
- this.debug(`[Preload] Track changed, cancelling`);
717
- throw new Error("PRELOAD_CANCELLED");
718
- }
719
-
720
- try {
721
- // Get stream with abort support - NO TIMEOUT
722
- const streamInfo = await this.getStreamWithCancel(track, abortController.signal);
723
-
724
- // Check cancellation
725
- if (abortController.signal.aborted) {
726
- throw new Error("PRELOAD_CANCELLED");
727
- }
728
-
729
- // Check track relevance again
730
- if (this.queue.nextTrack?.id !== track.id) {
731
- this.debug(`[Preload] Track changed after stream fetch`);
732
- throw new Error("PRELOAD_CANCELLED");
733
- }
734
-
735
- if (!streamInfo?.stream) {
736
- throw new Error(`No stream available`);
737
- }
738
-
739
- // Register with StreamManager as preload
740
- const streamId = this.streamManager.registerStream(streamInfo.stream, track, {
741
- source: track.source || "preload",
742
- isPreload: true,
743
- priority: 5,
744
- });
745
-
746
- // Create resource
747
- const resource = createAudioResource(streamInfo.stream, {
748
- inlineVolume: true,
749
- metadata: { ...track, preloaded: true },
750
- });
751
-
752
- // Verify resource is valid
753
- if (!resource.playStream || resource.playStream.readable === false) {
754
- throw new Error("Resource not readable");
755
- }
756
-
757
- // Update preload slot
758
- this.preloadSlot.resource = resource;
759
- this.preloadSlot.streamId = streamId;
760
- this.preloadSlot.isValid = true;
761
- this.preloadSlot.track = track;
762
-
763
- this.debug(`[Preload] Successfully preloaded: ${track.title} (Stream ID: ${streamId})`);
764
- } catch (err) {
765
- if (err instanceof Error && err.message === "PRELOAD_CANCELLED") {
766
- throw err;
767
- }
768
- this.debug(`[Preload] Error during preload:`, err);
769
- throw err;
770
- }
635
+ await this.preloadManager.preloadNextTrack();
771
636
  }
772
637
 
773
638
  /**
774
639
  * Safe cancel preload - doesn't throw
775
640
  */
776
641
  private async safeCancelPreload(): Promise<void> {
777
- if (!this.preloadSlot.abortController && !this.preloadSlot.resource) {
778
- return;
779
- }
780
-
781
- this.debug(`[Preload] Safely cancelling preload for: ${this.preloadSlot.track?.title || "unknown"}`);
782
-
783
- // Abort the operation
784
- if (this.preloadSlot.abortController) {
785
- this.preloadSlot.abortController.abort();
786
- this.preloadSlot.abortController = null;
787
- }
788
-
789
- // Clean up stream
790
- if (this.preloadSlot.streamId && this.streamManager) {
791
- this.streamManager.unregisterStream(this.preloadSlot.streamId, true);
792
- }
793
-
794
- // Clean up resource
795
- if (this.preloadSlot.resource) {
796
- try {
797
- const stream = this.preloadSlot.resource.playStream;
798
- if (stream && typeof stream.destroy === "function" && !stream.destroyed) {
799
- stream.destroy();
800
- }
801
- } catch (err) {
802
- // Ignore destroy errors
803
- }
804
- }
805
-
806
- // Clear slot
807
- this.clearSlot(this.preloadSlot);
642
+ await this.preloadManager.safeCancelPreload();
808
643
  }
809
644
 
810
- /**
811
- * Get stream with proper cancellation
812
- */
813
- private async getStreamWithCancel(track: Track, signal: AbortSignal): Promise<StreamInfo | null> {
814
- // Create abort promise
815
- const abortPromise = new Promise<never>((_, reject) => {
816
- if (signal.aborted) {
817
- reject(new Error("PRELOAD_CANCELLED"));
818
- return;
819
- }
820
- const handler = () => {
821
- signal.removeEventListener("abort", handler);
822
- reject(new Error("PRELOAD_CANCELLED"));
823
- };
824
- signal.addEventListener("abort", handler);
825
- });
826
-
827
- try {
828
- // Check if stream already exists and is valid
829
- const existingStream = this.streamManager.getStreamByTrack(track.id || track.title);
830
- if (existingStream && !existingStream.destroyed && existingStream.readable !== false) {
831
- this.debug(`[Stream] Using existing stream for preload: ${track.title}`);
832
- return { stream: existingStream, type: "arbitrary" };
833
- }
834
-
835
- // Race between stream fetch and abort
836
- const streamPromise = this.getStream(track);
837
- const result = await Promise.race([streamPromise, abortPromise]);
838
- return result as StreamInfo | null;
839
- } catch (err) {
840
- if (err instanceof Error && err.message === "PRELOAD_CANCELLED") {
841
- throw err;
842
- }
843
- throw err;
844
- }
845
- }
645
+ // Preload stream fetch/cancel flow has been moved to PreloadManager.
846
646
  /**
847
647
  * Preload next track with proper error handling and cleanup
848
648
  */
849
649
  async preloadNext(): Promise<void> {
850
- if (!this.preloadEnabled) {
851
- this.debug(`[Preload] Disabled by options/runtime profile`);
852
- return;
853
- }
854
-
855
- this.cancelPreload();
856
-
857
- const next = this.queue.nextTrack;
858
- if (!next || this.isPreloading) {
859
- this.debug(`[Preload] Skipped - ${!next ? "no next track" : "already preloading"}`);
860
- return;
861
- }
862
-
863
- this.isPreloading = true;
864
-
865
- // Create new AbortController
866
- const abortController = new AbortController();
867
- const timeoutId = setTimeout(() => {
868
- // this.debug(`[Preload] Timeout for track: ${next.title}`);
869
- // abortController.abort();
870
- }, 30000);
871
-
872
- this.preloadState.abortController = abortController;
873
- this.preloadState.timeoutId = timeoutId;
874
-
875
- try {
876
- this.debug(`[Preload] Starting preload for: ${next.title}`);
877
-
878
- // Check if already aborted
879
- if (abortController.signal.aborted) {
880
- throw new Error("Preload aborted before start");
881
- }
882
-
883
- // Check if this track is still the next one
884
- if (this.queue.nextTrack?.id !== next.id) {
885
- this.debug(`[Preload] Track changed, cancelling preload`);
886
- return;
887
- }
888
-
889
- const streamInfo = await this.getStreamWithCancel(next, abortController.signal);
890
-
891
- // Double check
892
- if (abortController.signal.aborted) {
893
- throw new Error("Preload aborted after stream fetch");
894
- }
895
-
896
- if (this.queue.nextTrack?.id !== next.id) {
897
- this.debug(`[Preload] Track changed after stream fetch`);
898
- return;
899
- }
900
-
901
- if (!streamInfo?.stream) {
902
- throw new Error(`No stream available`);
903
- }
904
-
905
- // Register with StreamManager
906
- const streamId = this.streamManager.registerStream(streamInfo.stream, next, {
907
- source: next.source || "preload",
908
- isPreload: true,
909
- priority: 8,
910
- });
911
-
912
- // Create resource
913
- const resource = createAudioResource(streamInfo.stream, {
914
- inlineVolume: true,
915
- metadata: { ...next, preloaded: true },
916
- });
917
-
918
- // Store preload state
919
- this.preloadState = {
920
- resource,
921
- track: next,
922
- abortController,
923
- timeoutId,
924
- isValid: true,
925
- isBeingUsed: false,
926
- streamId,
927
- };
928
-
929
- this.debug(`[Preload] Successfully preloaded: ${next.title} (Stream ID: ${streamId})`);
930
- } catch (err) {
931
- if (err instanceof Error && err.message.includes("aborted")) {
932
- this.debug(`[Preload] Cancelled for ${next.title}`);
933
- } else {
934
- this.debug(`[Preload] Failed for ${next?.title}:`, err);
935
- }
936
- this.cancelPreload();
937
- } finally {
938
- this.isPreloading = false;
939
- }
650
+ await this.preloadManager.preloadNextTrack();
940
651
  }
941
652
 
942
653
  private async fadeResourceVolume(resource: AudioResource, from: number, to: number, durationMs: number): Promise<void> {
@@ -1074,7 +785,7 @@ export class Player extends EventEmitter {
1074
785
  }
1075
786
 
1076
787
  try {
1077
- if (this.antiStuckReusePreloadFirst && this.preloadSlot.isValid && this.preloadSlot.track?.id === track.id) {
788
+ if (this.antiStuckReusePreloadFirst && this.preloadManager.hasValidPreload(track)) {
1078
789
  const startedFromPreload = await this.startTrack(track);
1079
790
  if (startedFromPreload) {
1080
791
  this.antiStuckConsecutiveFailures = 0;
@@ -1106,63 +817,11 @@ export class Player extends EventEmitter {
1106
817
  return false;
1107
818
  }
1108
819
 
1109
- /**
1110
- * Clear preloaded resource with proper cleanup
1111
- */
1112
- private clearPreload(): void {
1113
- // Abort ongoing preload
1114
- if (this.preloadState.abortController) {
1115
- this.preloadState.abortController.abort();
1116
- this.preloadState.abortController = null;
1117
- }
1118
-
1119
- // Clean up stream
1120
- const stream = (this.preloadState as any).stream;
1121
- if (stream && typeof stream.destroy === "function") {
1122
- try {
1123
- stream.destroy();
1124
- } catch (err) {
1125
- this.debug(`[Preload] Error destroying stream:`, err);
1126
- }
1127
- }
1128
-
1129
- // Clean up resource
1130
- if (this.preloadState.resource) {
1131
- try {
1132
- const playStream = this.preloadState.resource.playStream;
1133
- if (playStream && typeof playStream.destroy === "function") {
1134
- playStream.destroy();
1135
- }
1136
- } catch (err) {
1137
- this.debug(`[Preload] Error destroying resource:`, err);
1138
- }
1139
- }
1140
-
1141
- this.preloadState = {
1142
- resource: null,
1143
- track: null,
1144
- abortController: null,
1145
- timeoutId: null,
1146
- isValid: false,
1147
- isBeingUsed: false,
1148
- streamId: undefined,
1149
- };
1150
- }
1151
-
1152
820
  /**
1153
821
  * Cancel preload (when skipping or stopping)
1154
822
  */
1155
823
  private cancelPreload(): void {
1156
- if (this.preloadSlot.abortController) {
1157
- this.debug(`[Preload] Cancelling preload for: ${this.preloadSlot.track?.title}`);
1158
- this.preloadSlot.abortController.abort();
1159
- }
1160
-
1161
- if (this.preloadSlot.streamId && this.streamManager) {
1162
- this.streamManager.unregisterStream(this.preloadSlot.streamId, true);
1163
- }
1164
-
1165
- this.clearSlot(this.preloadSlot);
824
+ this.preloadManager.cancelPreload();
1166
825
  }
1167
826
 
1168
827
  /**
@@ -1198,27 +857,8 @@ export class Player extends EventEmitter {
1198
857
  * Promote preload slot to current slot without destroying promoted stream.
1199
858
  */
1200
859
  private promotePreloadToCurrent(track: Track): void {
1201
- const promotedResource = this.preloadSlot.resource;
1202
- const promotedStreamId = this.preloadSlot.streamId;
1203
-
1204
- // Move ownership to current slot.
1205
- this.currentSlot.resource = promotedResource;
1206
- this.currentSlot.track = track;
1207
- this.currentSlot.streamId = promotedStreamId;
1208
- this.currentSlot.abortController = null;
1209
- this.currentSlot.isValid = !!promotedResource;
1210
- this.currentSlot.isLoading = false;
1211
- this.currentSlot.loadPromise = null;
1212
- this.currentResource = promotedResource;
1213
-
1214
- // Reset preload slot only (do not destroy promoted resource/stream).
1215
- this.preloadSlot.resource = null;
1216
- this.preloadSlot.track = null;
1217
- this.preloadSlot.streamId = null;
1218
- this.preloadSlot.abortController = null;
1219
- this.preloadSlot.isValid = false;
1220
- this.preloadSlot.isLoading = false;
1221
- this.preloadSlot.loadPromise = null;
860
+ const promoted = this.preloadManager.promoteToCurrent(track, this.currentSlot);
861
+ this.currentResource = promoted;
1222
862
  }
1223
863
 
1224
864
  /**
@@ -1273,7 +913,36 @@ export class Player extends EventEmitter {
1273
913
  }
1274
914
  }
1275
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
+
1276
941
  private async getStream(track: Track): Promise<StreamInfo | null> {
942
+ if (this.destroyed) {
943
+ throw new Error("PLAYER_DESTROYED");
944
+ }
945
+ await this.applyTrackMiddleware(track);
1277
946
  const trackId = track.id || track.url || track.title;
1278
947
  const existingStream = this.streamManager.getStreamByTrack(trackId);
1279
948
 
@@ -1283,18 +952,24 @@ export class Player extends EventEmitter {
1283
952
  }
1284
953
 
1285
954
  let stream = await this.extensionManager.provideStream(track);
955
+ if (this.destroyed) {
956
+ if (stream?.stream && typeof stream.stream.destroy === "function" && !stream.stream.destroyed) {
957
+ stream.stream.destroy();
958
+ }
959
+ throw new Error("PLAYER_DESTROYED");
960
+ }
1286
961
  if (stream?.stream) {
1287
- // Register with StreamManager
1288
- const streamId = this.streamManager.registerStream(stream.stream, track, {
1289
- source: "extension",
1290
- isPreload: false,
1291
- priority: 10,
1292
- });
1293
- this.debug(`[Stream] Extension stream registered with ID: ${streamId}`);
962
+ this.debug(`[Stream] Extension provided stream for: ${track.title}`);
1294
963
  return stream;
1295
964
  }
1296
965
 
1297
966
  stream = await this.pluginManager.getStream(track);
967
+ if (this.destroyed) {
968
+ if (stream?.stream && typeof stream.stream.destroy === "function" && !stream.stream.destroyed) {
969
+ stream.stream.destroy();
970
+ }
971
+ throw new Error("PLAYER_DESTROYED");
972
+ }
1298
973
  if (stream?.stream) {
1299
974
  const existingAgain = this.streamManager.getStreamByTrack(trackId);
1300
975
  if (existingAgain && !existingAgain.destroyed) {
@@ -1302,12 +977,7 @@ export class Player extends EventEmitter {
1302
977
  return { stream: existingAgain, type: "arbitrary" };
1303
978
  }
1304
979
  // Register with StreamManager
1305
- const streamId = this.streamManager.registerStream(stream.stream, track, {
1306
- source: track.source || "plugin",
1307
- isPreload: false,
1308
- priority: 5,
1309
- });
1310
- this.debug(`[Stream] Plugin stream registered with ID: ${streamId}`);
980
+ this.debug(`[Stream] Plugin provided stream for: ${track.title}`);
1311
981
  return stream;
1312
982
  }
1313
983
 
@@ -1327,14 +997,10 @@ export class Player extends EventEmitter {
1327
997
  * Start playing a specific track immediately, replacing the current resource.
1328
998
  */
1329
999
  private async startTrack(track: Track): Promise<boolean> {
1000
+ if (this.destroyed) return false;
1330
1001
  try {
1331
1002
  // Try to use preloaded resource
1332
- if (
1333
- this.preloadSlot.isValid &&
1334
- this.preloadSlot.track?.id === track.id &&
1335
- this.preloadSlot.resource &&
1336
- this.preloadSlot.resource.playStream?.readable !== false
1337
- ) {
1003
+ if (this.preloadManager.hasValidPreload(track)) {
1338
1004
  this.debug(`[Player] Using preloaded stream for: ${track.title}`);
1339
1005
 
1340
1006
  // Stop current playback
@@ -1391,13 +1057,10 @@ export class Player extends EventEmitter {
1391
1057
  * Swap preload slot to current slot
1392
1058
  */
1393
1059
  private async swapToCurrent(track: Track): Promise<boolean> {
1394
- // Store preload resource
1395
- const newResource = this.preloadSlot.resource;
1396
- const oldStreamId = this.currentSlot.streamId;
1397
-
1398
- if (!newResource) {
1060
+ if (!this.preloadManager.hasValidPreload(track)) {
1399
1061
  return false;
1400
1062
  }
1063
+ const oldStreamId = this.currentSlot.streamId;
1401
1064
 
1402
1065
  // Stop current playback
1403
1066
  this.audioPlayer.stop(true);
@@ -1449,6 +1112,7 @@ export class Player extends EventEmitter {
1449
1112
  * Load fresh stream when no preload available
1450
1113
  */
1451
1114
  private async loadFreshStream(track: Track): Promise<boolean> {
1115
+ if (this.destroyed) return false;
1452
1116
  // Cancel preload to free resources
1453
1117
  await this.safeCancelPreload();
1454
1118
 
@@ -1495,9 +1159,11 @@ export class Player extends EventEmitter {
1495
1159
  await this.applyCrossfadeIn(resource, track);
1496
1160
 
1497
1161
  // Preload next (async)
1498
- this.preloadNextTrack().catch((err) => {
1499
- this.debug(`[Player] Preload error:`, err);
1500
- });
1162
+ if (!this.destroyed) {
1163
+ this.preloadNextTrack().catch((err) => {
1164
+ this.debug(`[Player] Preload error:`, err);
1165
+ });
1166
+ }
1501
1167
 
1502
1168
  return true;
1503
1169
  } catch (error) {
@@ -1510,6 +1176,7 @@ export class Player extends EventEmitter {
1510
1176
  * Play the next track in the queue, handling errors and edge cases gracefully
1511
1177
  */
1512
1178
  private async playNext(): Promise<boolean> {
1179
+ if (this.destroyed) return false;
1513
1180
  this.debug("[Player] playNext called");
1514
1181
 
1515
1182
  // Don't cancel preload here unless absolutely necessary
@@ -1524,6 +1191,15 @@ export class Player extends EventEmitter {
1524
1191
  const willnext = this.queue.willNextTrack();
1525
1192
  if (willnext) {
1526
1193
  this.queue.addMultiple([willnext]);
1194
+ void this.preloadNextTrack().catch((err) => this.debug("[Player] Preload autoplay error:", err));
1195
+ continue;
1196
+ }
1197
+
1198
+ await this.generateWillNext().catch((err) => this.debug("[Player] generateWillNext autoplay fallback error:", err));
1199
+ const generatedNext = this.queue.willNextTrack();
1200
+ if (generatedNext) {
1201
+ this.queue.add(generatedNext);
1202
+ void this.preloadNextTrack().catch((err) => this.debug("[Player] Preload autoplay generated error:", err));
1527
1203
  continue;
1528
1204
  }
1529
1205
  }
@@ -1620,6 +1296,8 @@ export class Player extends EventEmitter {
1620
1296
  if (!this.connection) throw new Error("No voice connection for TTS");
1621
1297
  const ttsPlayer = this.ensureTTSPlayer();
1622
1298
 
1299
+ await this.applyTrackMiddleware(track);
1300
+
1623
1301
  // Build resource from plugin stream
1624
1302
  const streamInfo = await this.pluginManager.getStream(track);
1625
1303
  if (!streamInfo) {
@@ -1744,6 +1422,56 @@ export class Player extends EventEmitter {
1744
1422
  }
1745
1423
  }
1746
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
+
1747
1475
  /**
1748
1476
  * Pause the current track
1749
1477
  *
@@ -1947,6 +1675,8 @@ export class Player extends EventEmitter {
1947
1675
  }
1948
1676
 
1949
1677
  try {
1678
+ await this.applyTrackMiddleware(track);
1679
+
1950
1680
  // Skip extension manager for saving - we want the raw stream without filters/seek applied, and extensions may not support this
1951
1681
  let streamInfo: StreamInfo | null = await this.pluginManager.getStream(track);
1952
1682
 
@@ -2340,6 +2070,8 @@ export class Player extends EventEmitter {
2340
2070
  */
2341
2071
  destroy(): void {
2342
2072
  this.debug(`[Player] destroy called`);
2073
+ if (this.destroyed) return;
2074
+ this.destroyed = true;
2343
2075
 
2344
2076
  if (this.leaveTimeout) {
2345
2077
  clearTimeout(this.leaveTimeout);
@@ -2350,11 +2082,11 @@ export class Player extends EventEmitter {
2350
2082
  this.destroyCurrentStream();
2351
2083
 
2352
2084
  this.clearSlot(this.currentSlot);
2353
- this.clearSlot(this.preloadSlot);
2085
+ this.preloadManager.clearPreloadSlot();
2354
2086
 
2355
2087
  this.audioPlayer.removeAllListeners();
2356
2088
  this.audioPlayer.stop(true);
2357
- this.clearPreload();
2089
+ this.preloadManager.cancelPreload();
2358
2090
 
2359
2091
  if (this.ttsPlayer) {
2360
2092
  try {
@@ -2530,6 +2262,7 @@ export class Player extends EventEmitter {
2530
2262
 
2531
2263
  private setupEventListeners(): void {
2532
2264
  this.audioPlayer.on("stateChange", (oldState, newState) => {
2265
+ if (this.destroyed) return;
2533
2266
  this.debug(`[Player] AudioPlayer stateChange from ${oldState.status} to ${newState.status}`);
2534
2267
  if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) {
2535
2268
  // Track ended
@@ -2538,7 +2271,7 @@ export class Player extends EventEmitter {
2538
2271
  this.debug(`[Player] Track ended: ${track.title}`);
2539
2272
  this.emit("trackEnd", track);
2540
2273
  }
2541
- this.playNext();
2274
+ void this.playNext();
2542
2275
  } else if (
2543
2276
  newState.status === AudioPlayerStatus.Playing &&
2544
2277
  (oldState.status === AudioPlayerStatus.Idle || oldState.status === AudioPlayerStatus.Buffering)
@@ -2596,18 +2329,19 @@ export class Player extends EventEmitter {
2596
2329
  }
2597
2330
  });
2598
2331
  this.audioPlayer.on("error", (error) => {
2332
+ if (this.destroyed) return;
2599
2333
  this.debug(`[Player] AudioPlayer error:`, error);
2600
2334
  this.emit("playerError", error, this.queue.currentTrack || undefined);
2601
2335
  const track = this.queue.currentTrack;
2602
2336
  if (track && this.antiStuckEnabled) {
2603
2337
  void this.attemptTrackRecovery(track, error).then((recovered) => {
2604
2338
  if (!recovered) {
2605
- this.playNext();
2339
+ void this.playNext();
2606
2340
  }
2607
2341
  });
2608
2342
  return;
2609
2343
  }
2610
- this.playNext();
2344
+ void this.playNext();
2611
2345
  });
2612
2346
 
2613
2347
  this.audioPlayer.on("debug", (...args) => {