ziplayer 0.3.0 → 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,30 +944,30 @@ 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
 
951
+ if (!this.pluginManager.hasStreamCandidate(track)) {
952
+ throw new Error(`UNRECOVERABLE_NO_PLUGIN:${track.title}`);
953
+ }
954
+
1314
955
  throw new Error(`No stream available for track: ${track.title}`);
1315
956
  }
1316
957
 
958
+ private isUnrecoverableStreamError(error: unknown): boolean {
959
+ if (!(error instanceof Error)) return false;
960
+ return error.message.startsWith("UNRECOVERABLE_NO_PLUGIN:");
961
+ }
962
+
1317
963
  /**
1318
964
  * Start playing a specific track immediately, replacing the current resource.
1319
965
  */
1320
966
  private async startTrack(track: Track): Promise<boolean> {
967
+ if (this.destroyed) return false;
1321
968
  try {
1322
969
  // Try to use preloaded resource
1323
- if (
1324
- this.preloadSlot.isValid &&
1325
- this.preloadSlot.track?.id === track.id &&
1326
- this.preloadSlot.resource &&
1327
- this.preloadSlot.resource.playStream?.readable !== false
1328
- ) {
970
+ if (this.preloadManager.hasValidPreload(track)) {
1329
971
  this.debug(`[Player] Using preloaded stream for: ${track.title}`);
1330
972
 
1331
973
  // Stop current playback
@@ -1382,13 +1024,10 @@ export class Player extends EventEmitter {
1382
1024
  * Swap preload slot to current slot
1383
1025
  */
1384
1026
  private async swapToCurrent(track: Track): Promise<boolean> {
1385
- // Store preload resource
1386
- const newResource = this.preloadSlot.resource;
1387
- const oldStreamId = this.currentSlot.streamId;
1388
-
1389
- if (!newResource) {
1027
+ if (!this.preloadManager.hasValidPreload(track)) {
1390
1028
  return false;
1391
1029
  }
1030
+ const oldStreamId = this.currentSlot.streamId;
1392
1031
 
1393
1032
  // Stop current playback
1394
1033
  this.audioPlayer.stop(true);
@@ -1440,6 +1079,7 @@ export class Player extends EventEmitter {
1440
1079
  * Load fresh stream when no preload available
1441
1080
  */
1442
1081
  private async loadFreshStream(track: Track): Promise<boolean> {
1082
+ if (this.destroyed) return false;
1443
1083
  // Cancel preload to free resources
1444
1084
  await this.safeCancelPreload();
1445
1085
 
@@ -1486,9 +1126,11 @@ export class Player extends EventEmitter {
1486
1126
  await this.applyCrossfadeIn(resource, track);
1487
1127
 
1488
1128
  // Preload next (async)
1489
- this.preloadNextTrack().catch((err) => {
1490
- this.debug(`[Player] Preload error:`, err);
1491
- });
1129
+ if (!this.destroyed) {
1130
+ this.preloadNextTrack().catch((err) => {
1131
+ this.debug(`[Player] Preload error:`, err);
1132
+ });
1133
+ }
1492
1134
 
1493
1135
  return true;
1494
1136
  } catch (error) {
@@ -1501,6 +1143,7 @@ export class Player extends EventEmitter {
1501
1143
  * Play the next track in the queue, handling errors and edge cases gracefully
1502
1144
  */
1503
1145
  private async playNext(): Promise<boolean> {
1146
+ if (this.destroyed) return false;
1504
1147
  this.debug("[Player] playNext called");
1505
1148
 
1506
1149
  // Don't cancel preload here unless absolutely necessary
@@ -1515,6 +1158,15 @@ export class Player extends EventEmitter {
1515
1158
  const willnext = this.queue.willNextTrack();
1516
1159
  if (willnext) {
1517
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));
1518
1170
  continue;
1519
1171
  }
1520
1172
  }
@@ -1557,6 +1209,10 @@ export class Player extends EventEmitter {
1557
1209
  } catch (err) {
1558
1210
  this.debug(`[Player] playNext error:`, err);
1559
1211
  this.emit("playerError", err as Error, track);
1212
+ if (this.isUnrecoverableStreamError(err)) {
1213
+ this.debug(`[Player] Skipping unrecoverable track (no plugin): ${track.title}`);
1214
+ continue;
1215
+ }
1560
1216
  const recovered = await this.attemptTrackRecovery(track, err);
1561
1217
  if (recovered) {
1562
1218
  return true;
@@ -2327,6 +1983,8 @@ export class Player extends EventEmitter {
2327
1983
  */
2328
1984
  destroy(): void {
2329
1985
  this.debug(`[Player] destroy called`);
1986
+ if (this.destroyed) return;
1987
+ this.destroyed = true;
2330
1988
 
2331
1989
  if (this.leaveTimeout) {
2332
1990
  clearTimeout(this.leaveTimeout);
@@ -2337,11 +1995,11 @@ export class Player extends EventEmitter {
2337
1995
  this.destroyCurrentStream();
2338
1996
 
2339
1997
  this.clearSlot(this.currentSlot);
2340
- this.clearSlot(this.preloadSlot);
1998
+ this.preloadManager.clearPreloadSlot();
2341
1999
 
2342
2000
  this.audioPlayer.removeAllListeners();
2343
2001
  this.audioPlayer.stop(true);
2344
- this.clearPreload();
2002
+ this.preloadManager.cancelPreload();
2345
2003
 
2346
2004
  if (this.ttsPlayer) {
2347
2005
  try {
@@ -2517,6 +2175,7 @@ export class Player extends EventEmitter {
2517
2175
 
2518
2176
  private setupEventListeners(): void {
2519
2177
  this.audioPlayer.on("stateChange", (oldState, newState) => {
2178
+ if (this.destroyed) return;
2520
2179
  this.debug(`[Player] AudioPlayer stateChange from ${oldState.status} to ${newState.status}`);
2521
2180
  if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) {
2522
2181
  // Track ended
@@ -2525,7 +2184,7 @@ export class Player extends EventEmitter {
2525
2184
  this.debug(`[Player] Track ended: ${track.title}`);
2526
2185
  this.emit("trackEnd", track);
2527
2186
  }
2528
- this.playNext();
2187
+ void this.playNext();
2529
2188
  } else if (
2530
2189
  newState.status === AudioPlayerStatus.Playing &&
2531
2190
  (oldState.status === AudioPlayerStatus.Idle || oldState.status === AudioPlayerStatus.Buffering)
@@ -2583,18 +2242,19 @@ export class Player extends EventEmitter {
2583
2242
  }
2584
2243
  });
2585
2244
  this.audioPlayer.on("error", (error) => {
2245
+ if (this.destroyed) return;
2586
2246
  this.debug(`[Player] AudioPlayer error:`, error);
2587
2247
  this.emit("playerError", error, this.queue.currentTrack || undefined);
2588
2248
  const track = this.queue.currentTrack;
2589
2249
  if (track && this.antiStuckEnabled) {
2590
2250
  void this.attemptTrackRecovery(track, error).then((recovered) => {
2591
2251
  if (!recovered) {
2592
- this.playNext();
2252
+ void this.playNext();
2593
2253
  }
2594
2254
  });
2595
2255
  return;
2596
2256
  }
2597
- this.playNext();
2257
+ void this.playNext();
2598
2258
  });
2599
2259
 
2600
2260
  this.audioPlayer.on("debug", (...args) => {