ziplayer 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,7 +31,6 @@ import type {
31
31
  ExtensionPlayRequest,
32
32
  ExtensionPlayResponse,
33
33
  ExtensionAfterPlayPayload,
34
- PreloadState,
35
34
  StreamSlot,
36
35
  } from "../types";
37
36
  import type { PlayerManager } from "./PlayerManager";
@@ -39,9 +38,9 @@ import type { PlayerManager } from "./PlayerManager";
39
38
  import { Queue } from "./Queue";
40
39
  import { PluginManager } from "../plugins";
41
40
  import { ExtensionManager } from "../extensions";
42
- import { withTimeout } from "../utils/timeout";
43
41
  import { FilterManager } from "./FilterManager";
44
42
  import { StreamManager } from "./StreamManager";
43
+ import { PreloadManager } from "./PreloadManager";
45
44
 
46
45
  export declare interface Player {
47
46
  on<K extends keyof PlayerEvents>(event: K, listener: (...args: PlayerEvents[K]) => void): this;
@@ -96,6 +95,7 @@ export class Player extends EventEmitter {
96
95
  public pluginManager: PluginManager;
97
96
  public extensionManager: ExtensionManager;
98
97
  public streamManager: StreamManager;
98
+ public preloadManager: PreloadManager;
99
99
 
100
100
  public userdata?: Record<string, any>;
101
101
  public _lastActivity: number = Date.now();
@@ -108,17 +108,7 @@ export class Player extends EventEmitter {
108
108
  private skipLoop = false;
109
109
  private filter!: FilterManager;
110
110
  private refreshLock = false;
111
- //preloaded resource
112
111
 
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
112
  private currentSlot: StreamSlot = {
123
113
  resource: null,
124
114
  track: null,
@@ -129,16 +119,6 @@ export class Player extends EventEmitter {
129
119
  loadPromise: null,
130
120
  };
131
121
 
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
122
  private preloadEnabled = true;
143
123
  private crossfadeEnabled = true;
144
124
  private crossfadeDurationMs = 500;
@@ -173,6 +153,7 @@ export class Player extends EventEmitter {
173
153
  private loudnessMaxBoostDb = 8;
174
154
  private loudnessMaxCutDb = 10;
175
155
  private loudnessLimiterCeiling = 0.95;
156
+ private destroyed = false;
176
157
 
177
158
  // Cache for search results to avoid duplicate calls
178
159
  private searchCache: LRUCache<string, SearchResult>;
@@ -223,7 +204,7 @@ export class Player extends EventEmitter {
223
204
  const crossfadeOptions = this.options.crossfade || {};
224
205
  const crossfadeAutoEnable = crossfadeOptions.autoEnable ?? true;
225
206
  const crossfadeAutoDisable = crossfadeOptions.autoDisableInLowPerformance ?? true;
226
- this.crossfadeDurationMs = Math.max(0, crossfadeOptions.durationMs ?? 5000);
207
+ this.crossfadeDurationMs = Math.max(0, crossfadeOptions.durationMs ?? 500);
227
208
 
228
209
  if (typeof crossfadeOptions.enabled === "boolean") {
229
210
  this.crossfadeEnabled = crossfadeOptions.enabled;
@@ -272,12 +253,20 @@ export class Player extends EventEmitter {
272
253
  extractorTimeout: this.options.extractorTimeout,
273
254
  });
274
255
  this.streamManager = new StreamManager({
275
- maxConcurrentStreams: 20,
256
+ maxConcurrentStreams: 2,
276
257
  streamTimeout: 5 * 60 * 1000,
277
258
  maxListenersPerStream: 15,
278
259
  enableMetrics: true,
279
260
  autoDestroy: true,
280
261
  });
262
+ this.preloadManager = new PreloadManager({
263
+ streamManager: this.streamManager,
264
+ debug: this.debug.bind(this),
265
+ getNextTrack: () => this.queue.nextTrack,
266
+ getStream: (track) => this.getStream(track),
267
+ isDestroyed: () => this.destroyed,
268
+ isEnabled: () => this.preloadEnabled,
269
+ });
281
270
  this.volume = this.options.volume || 100;
282
271
  this.userdata = this.options.userdata;
283
272
  this.searchCache = new LRUCache<string, SearchResult>({
@@ -601,6 +590,10 @@ export class Player extends EventEmitter {
601
590
  this.emit("queueAdd", tracksToAdd[0]);
602
591
  }
603
592
 
593
+ if (this.isPlaying && !this.destroyed) {
594
+ void this.preloadNextTrack().catch((err) => this.debug("[Player] Preload after queue add error:", err));
595
+ }
596
+
604
597
  const started = !this.isPlaying ? await this.playNext() : true;
605
598
 
606
599
  await this.extensionManager.afterPlayHooks({
@@ -632,311 +625,22 @@ export class Player extends EventEmitter {
632
625
  * Main preload method - only one at a time
633
626
  */
634
627
  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
- }
628
+ await this.preloadManager.preloadNextTrack();
771
629
  }
772
630
 
773
631
  /**
774
632
  * Safe cancel preload - doesn't throw
775
633
  */
776
634
  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);
635
+ await this.preloadManager.safeCancelPreload();
808
636
  }
809
637
 
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
- }
638
+ // Preload stream fetch/cancel flow has been moved to PreloadManager.
846
639
  /**
847
640
  * Preload next track with proper error handling and cleanup
848
641
  */
849
642
  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
- }
643
+ await this.preloadManager.preloadNextTrack();
940
644
  }
941
645
 
942
646
  private async fadeResourceVolume(resource: AudioResource, from: number, to: number, durationMs: number): Promise<void> {
@@ -1074,7 +778,7 @@ export class Player extends EventEmitter {
1074
778
  }
1075
779
 
1076
780
  try {
1077
- if (this.antiStuckReusePreloadFirst && this.preloadSlot.isValid && this.preloadSlot.track?.id === track.id) {
781
+ if (this.antiStuckReusePreloadFirst && this.preloadManager.hasValidPreload(track)) {
1078
782
  const startedFromPreload = await this.startTrack(track);
1079
783
  if (startedFromPreload) {
1080
784
  this.antiStuckConsecutiveFailures = 0;
@@ -1106,63 +810,11 @@ export class Player extends EventEmitter {
1106
810
  return false;
1107
811
  }
1108
812
 
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
813
  /**
1153
814
  * Cancel preload (when skipping or stopping)
1154
815
  */
1155
816
  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);
817
+ this.preloadManager.cancelPreload();
1166
818
  }
1167
819
 
1168
820
  /**
@@ -1198,27 +850,8 @@ export class Player extends EventEmitter {
1198
850
  * Promote preload slot to current slot without destroying promoted stream.
1199
851
  */
1200
852
  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;
853
+ const promoted = this.preloadManager.promoteToCurrent(track, this.currentSlot);
854
+ this.currentResource = promoted;
1222
855
  }
1223
856
 
1224
857
  /**
@@ -1274,6 +907,9 @@ export class Player extends EventEmitter {
1274
907
  }
1275
908
 
1276
909
  private async getStream(track: Track): Promise<StreamInfo | null> {
910
+ if (this.destroyed) {
911
+ throw new Error("PLAYER_DESTROYED");
912
+ }
1277
913
  const trackId = track.id || track.url || track.title;
1278
914
  const existingStream = this.streamManager.getStreamByTrack(trackId);
1279
915
 
@@ -1283,18 +919,24 @@ export class Player extends EventEmitter {
1283
919
  }
1284
920
 
1285
921
  let stream = await this.extensionManager.provideStream(track);
922
+ if (this.destroyed) {
923
+ if (stream?.stream && typeof stream.stream.destroy === "function" && !stream.stream.destroyed) {
924
+ stream.stream.destroy();
925
+ }
926
+ throw new Error("PLAYER_DESTROYED");
927
+ }
1286
928
  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}`);
929
+ this.debug(`[Stream] Extension provided stream for: ${track.title}`);
1294
930
  return stream;
1295
931
  }
1296
932
 
1297
933
  stream = await this.pluginManager.getStream(track);
934
+ if (this.destroyed) {
935
+ if (stream?.stream && typeof stream.stream.destroy === "function" && !stream.stream.destroyed) {
936
+ stream.stream.destroy();
937
+ }
938
+ throw new Error("PLAYER_DESTROYED");
939
+ }
1298
940
  if (stream?.stream) {
1299
941
  const existingAgain = this.streamManager.getStreamByTrack(trackId);
1300
942
  if (existingAgain && !existingAgain.destroyed) {
@@ -1302,12 +944,7 @@ export class Player extends EventEmitter {
1302
944
  return { stream: existingAgain, type: "arbitrary" };
1303
945
  }
1304
946
  // 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}`);
947
+ this.debug(`[Stream] Plugin provided stream for: ${track.title}`);
1311
948
  return stream;
1312
949
  }
1313
950
 
@@ -1327,14 +964,10 @@ export class Player extends EventEmitter {
1327
964
  * Start playing a specific track immediately, replacing the current resource.
1328
965
  */
1329
966
  private async startTrack(track: Track): Promise<boolean> {
967
+ if (this.destroyed) return false;
1330
968
  try {
1331
969
  // 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
- ) {
970
+ if (this.preloadManager.hasValidPreload(track)) {
1338
971
  this.debug(`[Player] Using preloaded stream for: ${track.title}`);
1339
972
 
1340
973
  // Stop current playback
@@ -1391,13 +1024,10 @@ export class Player extends EventEmitter {
1391
1024
  * Swap preload slot to current slot
1392
1025
  */
1393
1026
  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) {
1027
+ if (!this.preloadManager.hasValidPreload(track)) {
1399
1028
  return false;
1400
1029
  }
1030
+ const oldStreamId = this.currentSlot.streamId;
1401
1031
 
1402
1032
  // Stop current playback
1403
1033
  this.audioPlayer.stop(true);
@@ -1449,6 +1079,7 @@ export class Player extends EventEmitter {
1449
1079
  * Load fresh stream when no preload available
1450
1080
  */
1451
1081
  private async loadFreshStream(track: Track): Promise<boolean> {
1082
+ if (this.destroyed) return false;
1452
1083
  // Cancel preload to free resources
1453
1084
  await this.safeCancelPreload();
1454
1085
 
@@ -1495,9 +1126,11 @@ export class Player extends EventEmitter {
1495
1126
  await this.applyCrossfadeIn(resource, track);
1496
1127
 
1497
1128
  // Preload next (async)
1498
- this.preloadNextTrack().catch((err) => {
1499
- this.debug(`[Player] Preload error:`, err);
1500
- });
1129
+ if (!this.destroyed) {
1130
+ this.preloadNextTrack().catch((err) => {
1131
+ this.debug(`[Player] Preload error:`, err);
1132
+ });
1133
+ }
1501
1134
 
1502
1135
  return true;
1503
1136
  } catch (error) {
@@ -1510,6 +1143,7 @@ export class Player extends EventEmitter {
1510
1143
  * Play the next track in the queue, handling errors and edge cases gracefully
1511
1144
  */
1512
1145
  private async playNext(): Promise<boolean> {
1146
+ if (this.destroyed) return false;
1513
1147
  this.debug("[Player] playNext called");
1514
1148
 
1515
1149
  // Don't cancel preload here unless absolutely necessary
@@ -1524,6 +1158,15 @@ export class Player extends EventEmitter {
1524
1158
  const willnext = this.queue.willNextTrack();
1525
1159
  if (willnext) {
1526
1160
  this.queue.addMultiple([willnext]);
1161
+ void this.preloadNextTrack().catch((err) => this.debug("[Player] Preload autoplay error:", err));
1162
+ continue;
1163
+ }
1164
+
1165
+ await this.generateWillNext().catch((err) => this.debug("[Player] generateWillNext autoplay fallback error:", err));
1166
+ const generatedNext = this.queue.willNextTrack();
1167
+ if (generatedNext) {
1168
+ this.queue.add(generatedNext);
1169
+ void this.preloadNextTrack().catch((err) => this.debug("[Player] Preload autoplay generated error:", err));
1527
1170
  continue;
1528
1171
  }
1529
1172
  }
@@ -2340,6 +1983,8 @@ export class Player extends EventEmitter {
2340
1983
  */
2341
1984
  destroy(): void {
2342
1985
  this.debug(`[Player] destroy called`);
1986
+ if (this.destroyed) return;
1987
+ this.destroyed = true;
2343
1988
 
2344
1989
  if (this.leaveTimeout) {
2345
1990
  clearTimeout(this.leaveTimeout);
@@ -2350,11 +1995,11 @@ export class Player extends EventEmitter {
2350
1995
  this.destroyCurrentStream();
2351
1996
 
2352
1997
  this.clearSlot(this.currentSlot);
2353
- this.clearSlot(this.preloadSlot);
1998
+ this.preloadManager.clearPreloadSlot();
2354
1999
 
2355
2000
  this.audioPlayer.removeAllListeners();
2356
2001
  this.audioPlayer.stop(true);
2357
- this.clearPreload();
2002
+ this.preloadManager.cancelPreload();
2358
2003
 
2359
2004
  if (this.ttsPlayer) {
2360
2005
  try {
@@ -2530,6 +2175,7 @@ export class Player extends EventEmitter {
2530
2175
 
2531
2176
  private setupEventListeners(): void {
2532
2177
  this.audioPlayer.on("stateChange", (oldState, newState) => {
2178
+ if (this.destroyed) return;
2533
2179
  this.debug(`[Player] AudioPlayer stateChange from ${oldState.status} to ${newState.status}`);
2534
2180
  if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) {
2535
2181
  // Track ended
@@ -2538,7 +2184,7 @@ export class Player extends EventEmitter {
2538
2184
  this.debug(`[Player] Track ended: ${track.title}`);
2539
2185
  this.emit("trackEnd", track);
2540
2186
  }
2541
- this.playNext();
2187
+ void this.playNext();
2542
2188
  } else if (
2543
2189
  newState.status === AudioPlayerStatus.Playing &&
2544
2190
  (oldState.status === AudioPlayerStatus.Idle || oldState.status === AudioPlayerStatus.Buffering)
@@ -2596,18 +2242,19 @@ export class Player extends EventEmitter {
2596
2242
  }
2597
2243
  });
2598
2244
  this.audioPlayer.on("error", (error) => {
2245
+ if (this.destroyed) return;
2599
2246
  this.debug(`[Player] AudioPlayer error:`, error);
2600
2247
  this.emit("playerError", error, this.queue.currentTrack || undefined);
2601
2248
  const track = this.queue.currentTrack;
2602
2249
  if (track && this.antiStuckEnabled) {
2603
2250
  void this.attemptTrackRecovery(track, error).then((recovered) => {
2604
2251
  if (!recovered) {
2605
- this.playNext();
2252
+ void this.playNext();
2606
2253
  }
2607
2254
  });
2608
2255
  return;
2609
2256
  }
2610
- this.playNext();
2257
+ void this.playNext();
2611
2258
  });
2612
2259
 
2613
2260
  this.audioPlayer.on("debug", (...args) => {