ziplayer 0.3.6 → 0.3.8

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 (34) hide show
  1. package/dist/plugins/index.d.ts +1 -8
  2. package/dist/plugins/index.d.ts.map +1 -1
  3. package/dist/plugins/index.js +59 -107
  4. package/dist/plugins/index.js.map +1 -1
  5. package/dist/structures/FilterManager.d.ts +9 -24
  6. package/dist/structures/FilterManager.d.ts.map +1 -1
  7. package/dist/structures/FilterManager.js +182 -93
  8. package/dist/structures/FilterManager.js.map +1 -1
  9. package/dist/structures/Player.d.ts +8 -1
  10. package/dist/structures/Player.d.ts.map +1 -1
  11. package/dist/structures/Player.js +233 -133
  12. package/dist/structures/Player.js.map +1 -1
  13. package/dist/structures/PreloadManager.d.ts +1 -0
  14. package/dist/structures/PreloadManager.d.ts.map +1 -1
  15. package/dist/structures/PreloadManager.js +26 -6
  16. package/dist/structures/PreloadManager.js.map +1 -1
  17. package/dist/structures/Queue.d.ts.map +1 -1
  18. package/dist/structures/Queue.js +4 -0
  19. package/dist/structures/Queue.js.map +1 -1
  20. package/dist/structures/StreamManager.d.ts +8 -0
  21. package/dist/structures/StreamManager.d.ts.map +1 -1
  22. package/dist/structures/StreamManager.js +23 -0
  23. package/dist/structures/StreamManager.js.map +1 -1
  24. package/dist/types/index.d.ts +1 -0
  25. package/dist/types/index.d.ts.map +1 -1
  26. package/dist/types/index.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/plugins/index.ts +70 -120
  29. package/src/structures/FilterManager.ts +439 -303
  30. package/src/structures/Player.ts +268 -140
  31. package/src/structures/PreloadManager.ts +293 -274
  32. package/src/structures/Queue.ts +5 -0
  33. package/src/structures/StreamManager.ts +585 -563
  34. package/src/types/index.ts +1 -0
@@ -118,6 +118,7 @@ export class Player extends EventEmitter {
118
118
 
119
119
  private skipLoop = false;
120
120
  private refreshLock = false;
121
+ private seekInProgress = false;
121
122
  private remoteHandle: StreamInfo["handle"];
122
123
 
123
124
  private currentSlot: StreamSlot = {
@@ -171,6 +172,7 @@ export class Player extends EventEmitter {
171
172
  private readonly SEARCH_CACHE_TTL = 2 * 60 * 1000; // 2 minutes
172
173
  private ttsPlayer: DiscordAudioPlayer | null = null;
173
174
  private lastDuration: number = 0;
175
+ private seekOffset: number = 0;
174
176
 
175
177
  constructor(guildId: string, options: PlayerOptions = {}, manager: PlayerManager) {
176
178
  super();
@@ -267,7 +269,7 @@ export class Player extends EventEmitter {
267
269
  extractorTimeout: this.options.extractorTimeout,
268
270
  });
269
271
  this.streamManager = new StreamManager({
270
- maxConcurrentStreams: 2,
272
+ maxConcurrentStreams: 4,
271
273
  streamTimeout: 5 * 60 * 1000,
272
274
  maxListenersPerStream: 15,
273
275
  enableMetrics: true,
@@ -276,7 +278,12 @@ export class Player extends EventEmitter {
276
278
  this.preloadManager = new PreloadManager({
277
279
  streamManager: this.streamManager,
278
280
  debug: this.debug.bind(this),
279
- getNextTrack: () => this.queue.nextTrack,
281
+ getNextTrack: () => {
282
+ if (this.queue.loop() === "track") {
283
+ return this.queue.currentTrack;
284
+ }
285
+ return this.queue.nextTrack;
286
+ },
280
287
  getStream: (track) => this.getStream(track),
281
288
  isDestroyed: () => this.destroyed,
282
289
  isEnabled: () => this.preloadEnabled,
@@ -880,48 +887,41 @@ export class Player extends EventEmitter {
880
887
  * @param {number} position - Position in milliseconds to seek to (0 = no seek)
881
888
  * @returns {Promise<AudioResource>} The AudioResource with filters and seek applied
882
889
  */
883
- private async createResource(streamInfo: StreamInfo, track: Track, position: number = 0): Promise<AudioResource> {
890
+ private async createResource(
891
+ streamInfo: StreamInfo,
892
+ track: Track,
893
+ position: number = 0,
894
+ ): Promise<{ resource: AudioResource; processedStream: import("stream").Readable | null }> {
884
895
  const filterString = this.filter.getFilterString();
896
+ this.debug(`[Player] Creating AudioResource — filters: ${filterString || "none"}, seek: ${position}ms`);
885
897
 
886
- this.debug(`[Player] Creating AudioResource with filters: ${filterString || "none"}, seek: ${position}ms`);
898
+ this.filter.setSourceStreamType(streamInfo.type);
887
899
 
888
- try {
889
- let stream: Readable = streamInfo.stream;
890
- // Apply filters and seek if needed
891
- if (filterString || position > 0) {
892
- stream = await this.filter.applyFiltersAndSeek(streamInfo.stream, position);
893
- streamInfo.type = StreamType.Arbitrary;
894
- }
900
+ const seekArg = position > 0 ? position : -1;
895
901
 
896
- // Create AudioResource with better error handling
897
- const resource = createAudioResource(stream, {
902
+ if (filterString || position > 0) {
903
+ const processedStream = await this.filter.applyFiltersAndSeek(streamInfo.stream, seekArg);
904
+
905
+ // rawStream. Always use Arbitrary so discordjs/voice doesn't re-encode.
906
+ const resource = createAudioResource(processedStream, {
898
907
  metadata: track,
899
- inputType:
900
- streamInfo.type === "webm/opus" ? StreamType.WebmOpus
901
- : streamInfo.type === "ogg/opus" ? StreamType.OggOpus
902
- : StreamType.Arbitrary,
908
+ inputType: StreamType.Arbitrary,
903
909
  inlineVolume: true,
904
910
  });
905
911
 
906
- return resource;
907
- } catch (error) {
908
- this.debug(`[Player] Error creating AudioResource with filters+seek:`, error);
909
- // Fallback to basic AudioResource
910
- try {
911
- const resource = createAudioResource(streamInfo.stream, {
912
- metadata: track,
913
- inputType:
914
- streamInfo.type === "webm/opus" ? StreamType.WebmOpus
915
- : streamInfo.type === "ogg/opus" ? StreamType.OggOpus
916
- : StreamType.Arbitrary,
917
- inlineVolume: true,
918
- });
919
- return resource;
920
- } catch (fallbackError) {
921
- this.debug(`[Player] Fallback AudioResource creation failed:`, fallbackError);
922
- throw fallbackError;
923
- }
912
+ return { resource, processedStream };
924
913
  }
914
+
915
+ const resource = createAudioResource(streamInfo.stream, {
916
+ metadata: track,
917
+ inputType:
918
+ streamInfo.type === "webm/opus" ? StreamType.WebmOpus
919
+ : streamInfo.type === "ogg/opus" ? StreamType.OggOpus
920
+ : StreamType.Arbitrary,
921
+ inlineVolume: true,
922
+ });
923
+
924
+ return { resource, processedStream: null };
925
925
  }
926
926
 
927
927
  private mergeTrackPreserveRef(target: Track, source: Track): void {
@@ -1035,9 +1035,16 @@ export class Player extends EventEmitter {
1035
1035
  private async startTrack(track: Track): Promise<boolean> {
1036
1036
  if (this.destroyed) return false;
1037
1037
 
1038
- // First, get stream info (this will handle remote detection)
1039
- let streamInfo: StreamInfo | null = null;
1038
+ // Check preload BEFORE calling getStream so we never fetch a
1039
+ // stream we're about to throw away. The original code called getStream()
1040
+ // unconditionally at the top, then used the preload if available — leaking
1041
+ // the just-fetched stream and running middleware twice.
1042
+ if (this.preloadManager.hasValidPreload(track)) {
1043
+ return await this.startFromPreload(track);
1044
+ }
1040
1045
 
1046
+ // Only fetch a stream when there is no usable preload.
1047
+ let streamInfo: StreamInfo | null = null;
1041
1048
  try {
1042
1049
  streamInfo = await this.getStream(track);
1043
1050
  } catch (error) {
@@ -1045,73 +1052,70 @@ export class Player extends EventEmitter {
1045
1052
  throw error;
1046
1053
  }
1047
1054
 
1048
- // Handle remote playback
1055
+ // Remote playback
1049
1056
  if (streamInfo?.remote && streamInfo.handle) {
1050
1057
  return await this.playRemote(track, streamInfo);
1051
1058
  }
1052
1059
 
1053
- // Handle native playback
1054
- try {
1055
- // Try to use preloaded resource
1056
- if (this.preloadManager.hasValidPreload(track)) {
1057
- this.debug(`[Player] Using preloaded stream for: ${track.title}`);
1060
+ // Native playback — pass the already-fetched streamInfo to avoid a second fetch
1061
+ return await this.loadFreshStream(track, streamInfo);
1062
+ }
1063
+ private async startFromPreload(track: Track): Promise<boolean> {
1064
+ if (this.destroyed) return false;
1058
1065
 
1059
- this.audioPlayer.stop(true);
1066
+ this.debug(`[Player] Using preloaded stream for: ${track.title}`);
1060
1067
 
1061
- const oldStreamId = this.currentSlot.streamId;
1062
- if (oldStreamId && this.streamManager) {
1063
- setTimeout(() => {
1064
- if (this.currentSlot.streamId === oldStreamId) {
1065
- this.streamManager.unregisterStream(oldStreamId, true);
1066
- }
1067
- }, 3000);
1068
- }
1068
+ const oldStreamId = this.currentSlot.streamId;
1069
1069
 
1070
- this.promotePreloadToCurrent(track);
1071
- const currentResource = this.currentSlot.resource;
1072
- if (!currentResource) {
1073
- return false;
1074
- }
1075
- const targetVolume = this.getTrackTargetVolume(track);
1070
+ this.promotePreloadToCurrent(track);
1076
1071
 
1077
- if (currentResource.volume) {
1078
- currentResource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
1079
- }
1072
+ if (oldStreamId && oldStreamId !== this.currentSlot.streamId) {
1073
+ this.streamManager.unregisterStream(oldStreamId, true);
1074
+ this.debug(`[Player] Released old stream ${oldStreamId} after preload promotion`);
1075
+ }
1080
1076
 
1081
- await this.maybeAlignToBeatBoundary();
1082
- this.audioPlayer.play(currentResource);
1083
- await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 10_000);
1084
- await this.applyCrossfadeIn(currentResource, track);
1077
+ const currentResource = this.currentSlot.resource;
1078
+ if (!currentResource) return false;
1085
1079
 
1086
- this.preloadNextTrack().catch((err) => {
1087
- this.debug(`[Player] Preload error:`, err);
1088
- });
1080
+ this.seekOffset = 0;
1081
+ const targetVolume = this.getTrackTargetVolume(track);
1089
1082
 
1090
- return true;
1091
- }
1083
+ if (currentResource.volume) {
1084
+ currentResource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
1085
+ }
1092
1086
 
1093
- this.debug(`[Player] No preload available, loading fresh: ${track.title}`);
1094
- return await this.loadFreshStream(track);
1095
- } catch (error) {
1096
- this.debug(`[Player] startTrack error:`, error);
1097
- this.emit("playerError", error as Error, track);
1098
- return false;
1087
+ await this.maybeAlignToBeatBoundary();
1088
+ this.refreshLock = true;
1089
+ try {
1090
+ this.audioPlayer.stop(true);
1091
+ this.audioPlayer.play(currentResource);
1092
+ await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 10_000);
1093
+ } finally {
1094
+ this.refreshLock = false;
1099
1095
  }
1096
+
1097
+ await this.applyCrossfadeIn(currentResource, track);
1098
+
1099
+ this.preloadNextTrack().catch((err: any) => {
1100
+ this.debug(`[Player] Preload error:`, err);
1101
+ });
1102
+
1103
+ return true;
1100
1104
  }
1101
1105
 
1102
1106
  /**
1103
1107
  * Load fresh stream when no preload available
1104
1108
  */
1105
- private async loadFreshStream(track: Track): Promise<boolean> {
1109
+ private async loadFreshStream(track: Track, preloadedStreamInfo?: StreamInfo | null): Promise<boolean> {
1106
1110
  if (this.destroyed) return false;
1107
1111
 
1108
- // Cancel preload to free resources
1109
1112
  await this.safeCancelPreload();
1110
1113
 
1111
1114
  try {
1112
- const streamInfo = await this.getStream(track);
1115
+ // use caller-supplied streamInfo when available so we don't
1116
+ // call getStream() a second time and run middleware twice.
1117
+ const streamInfo = preloadedStreamInfo ?? (await this.getStream(track));
1113
1118
 
1114
- // Handle remote playback
1115
1119
  if (streamInfo?.remote && streamInfo.handle) {
1116
1120
  return await this.playRemote(track, streamInfo);
1117
1121
  }
@@ -1120,45 +1124,64 @@ export class Player extends EventEmitter {
1120
1124
  throw new Error(`No stream available`);
1121
1125
  }
1122
1126
 
1123
- // Register with StreamManager
1124
- const streamId = this.streamManager.registerStream(streamInfo.stream, track, {
1127
+ // Register the RAW source stream — this is what we can reuse on seek
1128
+ const rawStreamId = this.streamManager.registerStream(streamInfo.stream, track, {
1125
1129
  source: track.source || "stream",
1126
1130
  isPreload: false,
1127
1131
  isRemote: !!streamInfo?.remote,
1128
1132
  priority: 10,
1129
1133
  });
1130
1134
 
1131
- // Create resource
1132
- const resource = await this.createResource(streamInfo, track, 0);
1133
-
1134
- // Clean up old current
1135
- if (this.currentSlot.streamId && this.currentSlot.streamId !== streamId) {
1135
+ // createResource now returns both the AudioResource
1136
+ // AND the processedStream (ffmpeg stdout) when filters/seek are involved.
1137
+ const { resource, processedStream } = await this.createResource(streamInfo, track, 0);
1138
+
1139
+ // when a processedStream exists, register it too so its
1140
+ // lifecycle is tracked. Store its id separately in currentSlot so
1141
+ // destroyCurrentStream() and refreshPlayerResource() clean the right object.
1142
+ let playStreamId = rawStreamId;
1143
+ if (processedStream && processedStream !== streamInfo.stream) {
1144
+ playStreamId = this.streamManager.registerStream(processedStream, track, {
1145
+ source: track.source || "stream-processed",
1146
+ isPreload: false,
1147
+ priority: 10,
1148
+ });
1149
+ this.debug(`[Player] Registered processedStream ${playStreamId} (rawStream: ${rawStreamId})`);
1150
+ }
1151
+ if (this.currentSlot.streamId && this.currentSlot.streamId !== rawStreamId) {
1136
1152
  this.streamManager.unregisterStream(this.currentSlot.streamId, true);
1137
1153
  }
1154
+ if ((this.currentSlot as any).processedStreamId && (this.currentSlot as any).processedStreamId !== playStreamId) {
1155
+ this.streamManager.unregisterStream((this.currentSlot as any).processedStreamId, true);
1156
+ }
1138
1157
 
1139
- // Set current slot
1140
1158
  this.currentSlot.resource = resource;
1141
1159
  this.currentSlot.track = track;
1142
- this.currentSlot.streamId = streamId;
1160
+ this.currentSlot.streamId = rawStreamId;
1161
+ (this.currentSlot as any).processedStreamId = processedStream ? playStreamId : null;
1143
1162
  this.currentSlot.isValid = true;
1144
1163
  this.currentResource = resource;
1164
+ this.seekOffset = 0;
1145
1165
 
1146
- // Apply volume
1147
1166
  const targetVolume = this.getTrackTargetVolume(track);
1148
1167
  if (resource.volume) {
1149
1168
  resource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
1150
1169
  }
1151
1170
 
1152
- // Play
1153
1171
  await this.maybeAlignToBeatBoundary();
1154
- this.audioPlayer.stop(true);
1155
- this.audioPlayer.play(resource);
1156
- await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 10_000);
1172
+ this.refreshLock = true;
1173
+ try {
1174
+ this.audioPlayer.stop(true);
1175
+ this.audioPlayer.play(resource);
1176
+ await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 10_000);
1177
+ } finally {
1178
+ this.refreshLock = false;
1179
+ }
1180
+
1157
1181
  await this.applyCrossfadeIn(resource, track);
1158
1182
 
1159
- // Preload next (async)
1160
1183
  if (!this.destroyed) {
1161
- this.preloadNextTrack().catch((err) => {
1184
+ this.preloadNextTrack().catch((err: any) => {
1162
1185
  this.debug(`[Player] Preload error:`, err);
1163
1186
  });
1164
1187
  }
@@ -1239,6 +1262,9 @@ export class Player extends EventEmitter {
1239
1262
  if (this.antiStuckRetryDelayMs > 0) {
1240
1263
  await new Promise((resolve) => setTimeout(resolve, this.antiStuckRetryDelayMs));
1241
1264
  }
1265
+ } else {
1266
+ this.antiStuckConsecutiveFailures = 0;
1267
+ this.skipLoop = true;
1242
1268
  }
1243
1269
  } catch (err) {
1244
1270
  this.debug(`[Player] playNext error:`, err);
@@ -1263,6 +1289,9 @@ export class Player extends EventEmitter {
1263
1289
  if (this.antiStuckRetryDelayMs > 0) {
1264
1290
  await new Promise((resolve) => setTimeout(resolve, this.antiStuckRetryDelayMs));
1265
1291
  }
1292
+ } else {
1293
+ this.antiStuckConsecutiveFailures = 0;
1294
+ this.skipLoop = true;
1266
1295
  }
1267
1296
  continue;
1268
1297
  }
@@ -1338,7 +1367,7 @@ export class Player extends EventEmitter {
1338
1367
  throw new Error(`No stream available for track: ${track.title}`);
1339
1368
  }
1340
1369
  ttsStream = streamInfo.stream;
1341
- const resource = await this.createResource(streamInfo as StreamInfo, track);
1370
+ const { resource, processedStream } = await this.createResource(streamInfo as StreamInfo, track);
1342
1371
  if (!resource) {
1343
1372
  throw new Error(`No resource available for track: ${track.title}`);
1344
1373
  }
@@ -1410,15 +1439,21 @@ export class Player extends EventEmitter {
1410
1439
  * @example
1411
1440
  * await player.connect(voiceChannel);
1412
1441
  */
1413
- async connect(channel: VoiceChannel): Promise<VoiceConnection> {
1442
+ async connect(
1443
+ channel: VoiceChannel,
1444
+ options: { group: string; selfDeaf: boolean; selfMute: boolean },
1445
+ ): Promise<VoiceConnection> {
1414
1446
  try {
1415
1447
  this.debug(`[Player] Connecting to voice channel: ${channel.id}`);
1448
+
1416
1449
  const connection = joinVoiceChannel({
1450
+ ...options,
1417
1451
  channelId: channel.id,
1418
1452
  guildId: channel.guildId,
1419
1453
  adapterCreator: channel.guild.voiceAdapterCreator as any,
1420
- selfDeaf: this.options.selfDeaf ?? true,
1421
- selfMute: this.options.selfMute ?? false,
1454
+ selfDeaf: options?.selfDeaf ?? this.options?.selfDeaf ?? true,
1455
+ selfMute: options?.selfMute ?? this.options?.selfMute ?? false,
1456
+ group: options?.group ?? this.options?.group ?? "Ziplayer",
1422
1457
  });
1423
1458
 
1424
1459
  await entersState(connection, VoiceConnectionStatus.Ready, 50_000);
@@ -1772,8 +1807,11 @@ export class Player extends EventEmitter {
1772
1807
  return false;
1773
1808
  }
1774
1809
 
1775
- await this.refreshPlayerResource(true, position);
1776
-
1810
+ const ok = await this.refreshPlayerResource(true, position);
1811
+ if (!ok) {
1812
+ this.debug(`[Player] Seek failed at position: ${position}ms`);
1813
+ return false;
1814
+ }
1777
1815
  return true;
1778
1816
  }
1779
1817
 
@@ -1956,10 +1994,10 @@ export class Player extends EventEmitter {
1956
1994
  * console.log(`Loop mode: ${loopMode}`);
1957
1995
  */
1958
1996
  loop(mode?: LoopMode | number): LoopMode {
1959
- this.debug(`[Player] loop called with mode: ${mode}`);
1960
-
1961
1997
  if (typeof mode === "number") {
1962
1998
  // Number mode: convert to text mode
1999
+ this.debug(`[Player] loop called with mode: ${mode}`);
2000
+
1963
2001
  switch (mode) {
1964
2002
  case 0:
1965
2003
  return this.queue.loop("off");
@@ -2186,9 +2224,9 @@ export class Player extends EventEmitter {
2186
2224
  }
2187
2225
 
2188
2226
  const total = track.duration > 1000 ? track.duration : track.duration * 1000;
2189
- if (!total) return this.formatTimeCompact(resource.playbackDuration);
2227
+ if (!total) return this.formatTimeCompact(resource.playbackDuration + this.seekOffset);
2190
2228
 
2191
- const current = resource.playbackDuration;
2229
+ const current = resource.playbackDuration + this.seekOffset;
2192
2230
  const ratio = Math.min(Math.max(current / total, 0), 1);
2193
2231
  const progress = Math.round(ratio * size);
2194
2232
 
@@ -2298,7 +2336,7 @@ export class Player extends EventEmitter {
2298
2336
  }
2299
2337
 
2300
2338
  const total = track.duration > 1000 ? track.duration : track.duration * 1000;
2301
- const current = resource.playbackDuration;
2339
+ const current = resource.playbackDuration + this.seekOffset;
2302
2340
 
2303
2341
  return {
2304
2342
  current: current,
@@ -2411,63 +2449,131 @@ export class Player extends EventEmitter {
2411
2449
  if (!applyToCurrent || !this.queue.currentTrack || !(this.isPlaying || this.isPaused)) {
2412
2450
  return false;
2413
2451
  }
2414
- if (this.refreshLock) return false;
2452
+ if (this.refreshLock) {
2453
+ this.debug(`[Player] refreshPlayerResource skipped — lock held`);
2454
+ return false;
2455
+ }
2456
+
2415
2457
  this.refreshLock = true;
2458
+
2459
+ if (this.stuckTimer) {
2460
+ clearTimeout(this.stuckTimer);
2461
+ this.stuckTimer = null;
2462
+ }
2463
+
2416
2464
  try {
2417
2465
  const track = this.queue.currentTrack;
2418
2466
  this.debug(`[Player] Refreshing player resource for track: ${track.title}`);
2419
2467
 
2420
- // Get current position for seeking
2421
- const currentPosition = position > 0 ? position : this.currentResource?.playbackDuration || 0;
2468
+ const currentPosition = position >= 0 ? position : (this.currentResource?.playbackDuration ?? 0);
2469
+ this.seekOffset = currentPosition;
2470
+ const wasPaused = this.isPaused;
2471
+ const playbackDuration = this.currentResource?.playbackDuration ?? 0;
2472
+
2473
+ const isForwardSeek = position < 0 || position >= playbackDuration;
2474
+ const currentStreamId = this.currentSlot.streamId;
2475
+
2476
+ // Try to grab the raw source stream for reuse (forward seeks only)
2477
+ let reuseStream: import("stream").Readable | null = null;
2478
+ if (isForwardSeek && currentStreamId) {
2479
+ reuseStream = this.streamManager.getRawStream(currentStreamId);
2480
+ if (reuseStream) {
2481
+ this.debug(`[Player] Will reuse source stream for seek (pos: ${currentPosition}ms)`);
2482
+ }
2483
+ }
2484
+
2485
+ if (reuseStream) {
2486
+ reuseStream.unpipe();
2487
+ }
2488
+
2489
+ // Clean up processedStream first (it's what AudioResource reads)
2490
+ const processedStreamId = (this.currentSlot as any).processedStreamId;
2491
+ if (processedStreamId && processedStreamId !== currentStreamId) {
2492
+ this.streamManager.unregisterStream(processedStreamId, true);
2493
+ (this.currentSlot as any).processedStreamId = null;
2494
+ }
2495
+
2496
+ if (currentStreamId) {
2497
+ this.streamManager.unregisterStream(currentStreamId, !reuseStream);
2498
+ this.currentSlot.streamId = null;
2499
+ }
2500
+
2501
+ this.audioPlayer.stop(true);
2502
+ this.currentResource = null;
2503
+ this.currentSlot.resource = null;
2504
+ this.currentSlot.isValid = false;
2505
+
2506
+ await new Promise<void>((resolve) => setImmediate(resolve));
2507
+
2508
+ if (reuseStream) {
2509
+ if (reuseStream.destroyed || (reuseStream as any).readable === false) {
2510
+ this.debug(`[Player] Source stream did not survive stop — falling back to fresh stream`);
2511
+ reuseStream = null;
2512
+ }
2513
+ }
2514
+
2515
+ let streaminfo: StreamInfo | null = null;
2516
+
2517
+ if (reuseStream) {
2518
+ streaminfo = { stream: reuseStream, type: "arbitrary" };
2519
+ } else {
2520
+ this.pluginManager.clearStreamCache();
2521
+ this.extensionManager.clearCache("stream");
2522
+ this.debug(`[Player] Fetching fresh stream${!isForwardSeek ? " (backward seek)" : " (reuse failed)"}`);
2523
+ streaminfo = await this.getStream(track);
2524
+ }
2422
2525
 
2423
- const streaminfo = await this.getStream(track);
2424
2526
  if (!streaminfo?.stream) {
2425
- this.debug(`[Player] No stream to refresh`);
2527
+ this.debug(`[Player] No stream available for refresh`);
2426
2528
  return false;
2427
2529
  }
2428
2530
 
2429
- // Create AudioResource with filters and seek to current position
2430
- const resource = await this.createResource(streaminfo, track, currentPosition);
2531
+ const createPosition = reuseStream ? -1 : currentPosition;
2431
2532
 
2432
- // Stop current playback and destroy old resource/stream
2433
- const wasPlaying = this.isPlaying;
2434
- const wasPaused = this.isPaused;
2533
+ const { resource, processedStream } = await this.createResource(streaminfo, track, createPosition);
2435
2534
 
2436
- this.audioPlayer.stop();
2535
+ // Register raw source stream
2536
+ const newStreamId = this.streamManager.registerStream(streaminfo.stream, track, {
2537
+ source: track.source || "stream",
2538
+ isPreload: false,
2539
+ priority: 10,
2540
+ });
2437
2541
 
2438
- // Properly destroy the old resource and stream
2439
- try {
2440
- if (this.currentResource) {
2441
- const oldStream = (this.currentResource as any)._readableState?.stream || (this.currentResource as any).stream;
2442
- if (oldStream && typeof oldStream.destroy === "function") {
2443
- oldStream.destroy();
2444
- }
2445
- }
2446
- } catch (error) {
2447
- this.debug(`[Player] Error destroying old stream in refreshPlayerResource:`, error);
2448
- } finally {
2449
- this.refreshLock = false;
2542
+ let newProcessedStreamId: string | null = null;
2543
+ if (processedStream && processedStream !== streaminfo.stream) {
2544
+ newProcessedStreamId = this.streamManager.registerStream(processedStream, track, {
2545
+ source: track.source || "stream-processed",
2546
+ isPreload: false,
2547
+ priority: 10,
2548
+ });
2450
2549
  }
2451
2550
 
2551
+ this.currentSlot.resource = resource;
2552
+ this.currentSlot.track = track;
2553
+ this.currentSlot.streamId = newStreamId;
2554
+ (this.currentSlot as any).processedStreamId = newProcessedStreamId;
2555
+ this.currentSlot.isValid = true;
2452
2556
  this.currentResource = resource;
2453
2557
 
2454
- // Subscribe to new resource
2558
+ if (position >= 0) {
2559
+ this.seekInProgress = true;
2560
+ }
2561
+
2455
2562
  if (this.connection) {
2456
2563
  this.connection.subscribe(this.audioPlayer);
2457
2564
  this.audioPlayer.play(resource);
2458
2565
  }
2566
+ if (wasPaused) this.audioPlayer.pause();
2459
2567
 
2460
- // Restore playing state
2461
- if (wasPaused) {
2462
- this.audioPlayer.pause();
2463
- }
2464
-
2465
- this.debug(`[Player] Successfully applied filter to current track at position ${currentPosition}ms`);
2568
+ this.debug(`[Player] Resource refreshed at position ${currentPosition}ms`);
2466
2569
  return true;
2467
2570
  } catch (error) {
2468
- this.debug(`[Player] Error applying filter to current track:`, error);
2469
- // Filter was still added to active filters, so return true
2470
- return true;
2571
+ this.debug(`[Player] refreshPlayerResource error:`, error);
2572
+ this.seekInProgress = false;
2573
+ this.emit("playerError", error as Error, this.queue.currentTrack ?? undefined);
2574
+ return false;
2575
+ } finally {
2576
+ this.refreshLock = false;
2471
2577
  }
2472
2578
  }
2473
2579
 
@@ -2523,7 +2629,13 @@ export class Player extends EventEmitter {
2523
2629
  this.audioPlayer.on("stateChange", (oldState, newState) => {
2524
2630
  if (this.destroyed) return;
2525
2631
  this.debug(`[Player] AudioPlayer stateChange from ${oldState.status} to ${newState.status}`);
2632
+
2633
+ // ── Idle: track ended naturally ───────────────────────────────────────
2526
2634
  if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) {
2635
+ if (this.refreshLock) {
2636
+ this.debug(`[Player] AudioPlayer went idle during resource refresh — skipping trackEnd/playNext`);
2637
+ return;
2638
+ }
2527
2639
  // Track ended
2528
2640
  const track = this.queue.currentTrack;
2529
2641
  if (track) {
@@ -2534,12 +2646,19 @@ export class Player extends EventEmitter {
2534
2646
  }
2535
2647
  }
2536
2648
  void this.playNext();
2649
+ // ── Playing: started or resumed ───────────────────────────────────────
2537
2650
  } else if (
2538
2651
  newState.status === AudioPlayerStatus.Playing &&
2539
2652
  (oldState.status === AudioPlayerStatus.Idle || oldState.status === AudioPlayerStatus.Buffering)
2540
2653
  ) {
2541
2654
  // Track started
2542
2655
  this.clearLeaveTimeout();
2656
+
2657
+ if (this.seekInProgress) {
2658
+ this.debug(`[Player] Seek complete — audio output started`);
2659
+ this.seekInProgress = false;
2660
+ }
2661
+
2543
2662
  const track = this.queue.currentTrack;
2544
2663
  if (track) {
2545
2664
  this.debug(`[Player] Track started: ${track.title}`);
@@ -2556,6 +2675,7 @@ export class Player extends EventEmitter {
2556
2675
  }
2557
2676
  }
2558
2677
  }
2678
+ // ── Paused ────────────────────────────────────────────────────────────
2559
2679
  } else if (newState.status === AudioPlayerStatus.Paused && oldState.status !== AudioPlayerStatus.Paused) {
2560
2680
  const track = this.queue.currentTrack;
2561
2681
  if (track) {
@@ -2565,6 +2685,7 @@ export class Player extends EventEmitter {
2565
2685
  fp.emit("playerPause", track);
2566
2686
  }
2567
2687
  }
2688
+ // ── Resumed from pause ────────────────────────────────────────────────
2568
2689
  } else if (newState.status !== AudioPlayerStatus.Paused && oldState.status === AudioPlayerStatus.Paused) {
2569
2690
  const track = this.queue.currentTrack;
2570
2691
  if (track) {
@@ -2576,8 +2697,15 @@ export class Player extends EventEmitter {
2576
2697
  }
2577
2698
  } else if (newState.status === AudioPlayerStatus.AutoPaused) {
2578
2699
  this.debug(`[Player] AudioPlayerStatus.AutoPaused`);
2700
+ // ── Buffering: start stuck detector ───────────────────────────────────
2579
2701
  } else if (newState.status === AudioPlayerStatus.Buffering) {
2580
2702
  this.debug(`[Player] AudioPlayerStatus.Buffering`);
2703
+
2704
+ if (this.seekInProgress) {
2705
+ this.debug(`[Player] Buffering during seek — stuckTimer suppressed`);
2706
+ return;
2707
+ }
2708
+
2581
2709
  this.lastDuration = this.currentResource?.playbackDuration || 0;
2582
2710
  this.stuckTimer = setTimeout(() => {
2583
2711
  if (this.currentResource?.playbackDuration === this.lastDuration) {