ziplayer 0.3.5 → 0.3.7

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.
@@ -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();
@@ -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,
@@ -882,46 +889,31 @@ export class Player extends EventEmitter {
882
889
  */
883
890
  private async createResource(streamInfo: StreamInfo, track: Track, position: number = 0): Promise<AudioResource> {
884
891
  const filterString = this.filter.getFilterString();
892
+ this.debug(`[Player] Creating AudioResource — filters: ${filterString || "none"}, seek: ${position}ms`);
885
893
 
886
- this.debug(`[Player] Creating AudioResource with filters: ${filterString || "none"}, seek: ${position}ms`);
894
+ // -1 = sentinel "no seek requested"
895
+ const seekArg = position > 0 ? position : -1;
887
896
 
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
- }
897
+ if (filterString || position > 0) {
898
+ // throws on failure — do NOT fall back to the already-piped stream
899
+ const processedStream = await this.filter.applyFiltersAndSeek(streamInfo.stream, seekArg);
900
+ streamInfo.type = StreamType.Arbitrary as any;
895
901
 
896
- // Create AudioResource with better error handling
897
- const resource = createAudioResource(stream, {
902
+ return createAudioResource(processedStream, {
898
903
  metadata: track,
899
- inputType:
900
- streamInfo.type === "webm/opus" ? StreamType.WebmOpus
901
- : streamInfo.type === "ogg/opus" ? StreamType.OggOpus
902
- : StreamType.Arbitrary,
904
+ inputType: StreamType.Arbitrary,
903
905
  inlineVolume: true,
904
906
  });
905
-
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
- }
924
907
  }
908
+
909
+ return createAudioResource(streamInfo.stream, {
910
+ metadata: track,
911
+ inputType:
912
+ streamInfo.type === "webm/opus" ? StreamType.WebmOpus
913
+ : streamInfo.type === "ogg/opus" ? StreamType.OggOpus
914
+ : StreamType.Arbitrary,
915
+ inlineVolume: true,
916
+ });
925
917
  }
926
918
 
927
919
  private mergeTrackPreserveRef(target: Track, source: Track): void {
@@ -1056,8 +1048,6 @@ export class Player extends EventEmitter {
1056
1048
  if (this.preloadManager.hasValidPreload(track)) {
1057
1049
  this.debug(`[Player] Using preloaded stream for: ${track.title}`);
1058
1050
 
1059
- this.audioPlayer.stop(true);
1060
-
1061
1051
  const oldStreamId = this.currentSlot.streamId;
1062
1052
  if (oldStreamId && this.streamManager) {
1063
1053
  setTimeout(() => {
@@ -1072,6 +1062,7 @@ export class Player extends EventEmitter {
1072
1062
  if (!currentResource) {
1073
1063
  return false;
1074
1064
  }
1065
+ this.seekOffset = 0;
1075
1066
  const targetVolume = this.getTrackTargetVolume(track);
1076
1067
 
1077
1068
  if (currentResource.volume) {
@@ -1079,8 +1070,14 @@ export class Player extends EventEmitter {
1079
1070
  }
1080
1071
 
1081
1072
  await this.maybeAlignToBeatBoundary();
1082
- this.audioPlayer.play(currentResource);
1083
- await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 10_000);
1073
+ this.refreshLock = true;
1074
+ try {
1075
+ this.audioPlayer.stop(true);
1076
+ this.audioPlayer.play(currentResource);
1077
+ await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 10_000);
1078
+ } finally {
1079
+ this.refreshLock = false;
1080
+ }
1084
1081
  await this.applyCrossfadeIn(currentResource, track);
1085
1082
 
1086
1083
  this.preloadNextTrack().catch((err) => {
@@ -1142,6 +1139,7 @@ export class Player extends EventEmitter {
1142
1139
  this.currentSlot.streamId = streamId;
1143
1140
  this.currentSlot.isValid = true;
1144
1141
  this.currentResource = resource;
1142
+ this.seekOffset = 0; // new track — reset seek baseline
1145
1143
 
1146
1144
  // Apply volume
1147
1145
  const targetVolume = this.getTrackTargetVolume(track);
@@ -1149,11 +1147,16 @@ export class Player extends EventEmitter {
1149
1147
  resource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
1150
1148
  }
1151
1149
 
1152
- // Play
1150
+ // Play — lock refresh so Idle event doesn't spawn duplicate playNext
1153
1151
  await this.maybeAlignToBeatBoundary();
1154
- this.audioPlayer.stop(true);
1155
- this.audioPlayer.play(resource);
1156
- await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 10_000);
1152
+ this.refreshLock = true;
1153
+ try {
1154
+ this.audioPlayer.stop(true);
1155
+ this.audioPlayer.play(resource);
1156
+ await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 10_000);
1157
+ } finally {
1158
+ this.refreshLock = false;
1159
+ }
1157
1160
  await this.applyCrossfadeIn(resource, track);
1158
1161
 
1159
1162
  // Preload next (async)
@@ -1239,6 +1242,9 @@ export class Player extends EventEmitter {
1239
1242
  if (this.antiStuckRetryDelayMs > 0) {
1240
1243
  await new Promise((resolve) => setTimeout(resolve, this.antiStuckRetryDelayMs));
1241
1244
  }
1245
+ } else {
1246
+ this.antiStuckConsecutiveFailures = 0;
1247
+ this.skipLoop = true;
1242
1248
  }
1243
1249
  } catch (err) {
1244
1250
  this.debug(`[Player] playNext error:`, err);
@@ -1263,6 +1269,9 @@ export class Player extends EventEmitter {
1263
1269
  if (this.antiStuckRetryDelayMs > 0) {
1264
1270
  await new Promise((resolve) => setTimeout(resolve, this.antiStuckRetryDelayMs));
1265
1271
  }
1272
+ } else {
1273
+ this.antiStuckConsecutiveFailures = 0;
1274
+ this.skipLoop = true;
1266
1275
  }
1267
1276
  continue;
1268
1277
  }
@@ -1410,15 +1419,21 @@ export class Player extends EventEmitter {
1410
1419
  * @example
1411
1420
  * await player.connect(voiceChannel);
1412
1421
  */
1413
- async connect(channel: VoiceChannel): Promise<VoiceConnection> {
1422
+ async connect(
1423
+ channel: VoiceChannel,
1424
+ options: { group: string; selfDeaf: boolean; selfMute: boolean },
1425
+ ): Promise<VoiceConnection> {
1414
1426
  try {
1415
1427
  this.debug(`[Player] Connecting to voice channel: ${channel.id}`);
1428
+
1416
1429
  const connection = joinVoiceChannel({
1430
+ ...options,
1417
1431
  channelId: channel.id,
1418
1432
  guildId: channel.guildId,
1419
1433
  adapterCreator: channel.guild.voiceAdapterCreator as any,
1420
- selfDeaf: this.options.selfDeaf ?? true,
1421
- selfMute: this.options.selfMute ?? false,
1434
+ selfDeaf: options?.selfDeaf ?? this.options?.selfDeaf ?? true,
1435
+ selfMute: options?.selfMute ?? this.options?.selfMute ?? false,
1436
+ group: options?.group ?? this.options?.group ?? "Ziplayer",
1422
1437
  });
1423
1438
 
1424
1439
  await entersState(connection, VoiceConnectionStatus.Ready, 50_000);
@@ -1772,8 +1787,11 @@ export class Player extends EventEmitter {
1772
1787
  return false;
1773
1788
  }
1774
1789
 
1775
- await this.refreshPlayerResource(true, position);
1776
-
1790
+ const ok = await this.refreshPlayerResource(true, position);
1791
+ if (!ok) {
1792
+ this.debug(`[Player] Seek failed at position: ${position}ms`);
1793
+ return false;
1794
+ }
1777
1795
  return true;
1778
1796
  }
1779
1797
 
@@ -1956,10 +1974,10 @@ export class Player extends EventEmitter {
1956
1974
  * console.log(`Loop mode: ${loopMode}`);
1957
1975
  */
1958
1976
  loop(mode?: LoopMode | number): LoopMode {
1959
- this.debug(`[Player] loop called with mode: ${mode}`);
1960
-
1961
1977
  if (typeof mode === "number") {
1962
1978
  // Number mode: convert to text mode
1979
+ this.debug(`[Player] loop called with mode: ${mode}`);
1980
+
1963
1981
  switch (mode) {
1964
1982
  case 0:
1965
1983
  return this.queue.loop("off");
@@ -2186,9 +2204,9 @@ export class Player extends EventEmitter {
2186
2204
  }
2187
2205
 
2188
2206
  const total = track.duration > 1000 ? track.duration : track.duration * 1000;
2189
- if (!total) return this.formatTimeCompact(resource.playbackDuration);
2207
+ if (!total) return this.formatTimeCompact(resource.playbackDuration + this.seekOffset);
2190
2208
 
2191
- const current = resource.playbackDuration;
2209
+ const current = resource.playbackDuration + this.seekOffset;
2192
2210
  const ratio = Math.min(Math.max(current / total, 0), 1);
2193
2211
  const progress = Math.round(ratio * size);
2194
2212
 
@@ -2298,7 +2316,7 @@ export class Player extends EventEmitter {
2298
2316
  }
2299
2317
 
2300
2318
  const total = track.duration > 1000 ? track.duration : track.duration * 1000;
2301
- const current = resource.playbackDuration;
2319
+ const current = resource.playbackDuration + this.seekOffset;
2302
2320
 
2303
2321
  return {
2304
2322
  current: current,
@@ -2411,63 +2429,128 @@ export class Player extends EventEmitter {
2411
2429
  if (!applyToCurrent || !this.queue.currentTrack || !(this.isPlaying || this.isPaused)) {
2412
2430
  return false;
2413
2431
  }
2414
- if (this.refreshLock) return false;
2432
+ if (this.refreshLock) {
2433
+ this.debug(`[Player] refreshPlayerResource skipped — lock held`);
2434
+ return false;
2435
+ }
2436
+
2437
+ // Lock before anything so stateChange idle sees it when stop() fires.
2415
2438
  this.refreshLock = true;
2439
+
2440
+ // Clear any existing stuckTimer from the previous playback cycle so it
2441
+ // cannot fire while we are mid-refresh.
2442
+ if (this.stuckTimer) {
2443
+ clearTimeout(this.stuckTimer);
2444
+ this.stuckTimer = null;
2445
+ }
2446
+
2416
2447
  try {
2417
2448
  const track = this.queue.currentTrack;
2418
2449
  this.debug(`[Player] Refreshing player resource for track: ${track.title}`);
2419
2450
 
2420
- // Get current position for seeking
2421
- const currentPosition = position > 0 ? position : this.currentResource?.playbackDuration || 0;
2451
+ const currentPosition = position >= 0 ? position : (this.currentResource?.playbackDuration ?? 0);
2452
+ this.seekOffset = currentPosition;
2453
+ const wasPaused = this.isPaused;
2454
+ const playbackDuration = this.currentResource?.playbackDuration ?? 0;
2455
+
2456
+ // Reuse is only viable for forward seeks (stream is sequential).
2457
+ const isForwardSeek = position < 0 || position >= playbackDuration;
2458
+ const currentStreamId = this.currentSlot.streamId;
2459
+
2460
+ // Try to grab the live source stream for reuse.
2461
+ // getRawStream accepts "paused" streams (discordjs/voice pauses source streams on NoSubscriberBehavior); getStream would reject them.
2462
+ let reuseStream: Readable | null = null;
2463
+ if (isForwardSeek && currentStreamId) {
2464
+ reuseStream = this.streamManager.getRawStream(currentStreamId);
2465
+ if (reuseStream) {
2466
+ this.debug(`[Player] Will reuse source stream for seek (pos: ${currentPosition}ms)`);
2467
+ }
2468
+ }
2422
2469
 
2423
- const streaminfo = await this.getStream(track);
2424
- if (!streaminfo?.stream) {
2425
- this.debug(`[Player] No stream to refresh`);
2426
- return false;
2470
+ // ── CRITICAL: unpipe BEFORE stop ──────────────────────────────────────
2471
+ // stop() kills discordjs/voice internal FFmpeg → EPIPE on source stream.
2472
+ // unpipe() first disconnects our stream cleanly before that happens.
2473
+ if (reuseStream) {
2474
+ reuseStream.unpipe();
2427
2475
  }
2428
2476
 
2429
- // Create AudioResource with filters and seek to current position
2430
- const resource = await this.createResource(streaminfo, track, currentPosition);
2477
+ // Remove StreamManager listeners.
2478
+ // forceDestroy=false when reusing so the Readable object stays alive.
2479
+ if (currentStreamId) {
2480
+ this.streamManager.unregisterStream(currentStreamId, !reuseStream);
2481
+ this.currentSlot.streamId = null;
2482
+ }
2431
2483
 
2432
- // Stop current playback and destroy old resource/stream
2433
- const wasPlaying = this.isPlaying;
2434
- const wasPaused = this.isPaused;
2484
+ // Stop the AudioPlayer.
2485
+ // stateChange (playing→idle) fires; refreshLock=true guards it (v4 fix).
2486
+ this.audioPlayer.stop(true);
2487
+ this.currentResource = null;
2488
+ this.currentSlot.resource = null;
2489
+ this.currentSlot.isValid = false;
2490
+
2491
+ // One event-loop tick: lets deferred stream events settle.
2492
+ await new Promise<void>((resolve) => setImmediate(resolve));
2493
+
2494
+ // Verify the reuse stream survived stop().
2495
+ if (reuseStream) {
2496
+ if (reuseStream.destroyed || (reuseStream as any).readable === false) {
2497
+ this.debug(`[Player] Source stream did not survive stop — falling back to fresh stream`);
2498
+ reuseStream = null;
2499
+ }
2500
+ }
2435
2501
 
2436
- this.audioPlayer.stop();
2502
+ let streaminfo: StreamInfo | null = null;
2437
2503
 
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;
2504
+ if (reuseStream) {
2505
+ streaminfo = { stream: reuseStream, type: "arbitrary" };
2506
+ } else {
2507
+ // Clear caches so we don't get the dead Readable back.
2508
+ this.pluginManager.clearStreamCache();
2509
+ this.extensionManager.clearCache("stream");
2510
+ this.debug(`[Player] Fetching fresh stream${!isForwardSeek ? " (backward seek)" : " (reuse failed)"}`);
2511
+ streaminfo = await this.getStream(track);
2512
+ }
2513
+
2514
+ if (!streaminfo?.stream) {
2515
+ this.debug(`[Player] No stream available for refresh`);
2516
+ return false;
2450
2517
  }
2451
2518
 
2519
+ // Build AudioResource (input-side FFmpeg seek via FilterManager).
2520
+ const resource = await this.createResource(streaminfo, track, currentPosition);
2521
+
2522
+ // Register the source stream.
2523
+ const newStreamId = this.streamManager.registerStream(streaminfo.stream, track, {
2524
+ source: track.source || "stream",
2525
+ isPreload: false,
2526
+ priority: 10,
2527
+ });
2528
+ this.currentSlot.resource = resource;
2529
+ this.currentSlot.track = track;
2530
+ this.currentSlot.streamId = newStreamId;
2531
+ this.currentSlot.isValid = true;
2452
2532
  this.currentResource = resource;
2453
2533
 
2454
- // Subscribe to new resource
2534
+ // ── Set seek flag BEFORE play so the Buffering handler sees it ────────
2535
+ if (position >= 0) {
2536
+ this.seekInProgress = true;
2537
+ }
2538
+
2455
2539
  if (this.connection) {
2456
2540
  this.connection.subscribe(this.audioPlayer);
2457
2541
  this.audioPlayer.play(resource);
2458
2542
  }
2543
+ if (wasPaused) this.audioPlayer.pause();
2459
2544
 
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`);
2545
+ this.debug(`[Player] Resource refreshed at position ${currentPosition}ms`);
2466
2546
  return true;
2467
2547
  } 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;
2548
+ this.debug(`[Player] refreshPlayerResource error:`, error);
2549
+ this.seekInProgress = false; // ensure flag is cleared on failure
2550
+ this.emit("playerError", error as Error, this.queue.currentTrack ?? undefined);
2551
+ return false;
2552
+ } finally {
2553
+ this.refreshLock = false; // always released
2471
2554
  }
2472
2555
  }
2473
2556
 
@@ -2523,7 +2606,13 @@ export class Player extends EventEmitter {
2523
2606
  this.audioPlayer.on("stateChange", (oldState, newState) => {
2524
2607
  if (this.destroyed) return;
2525
2608
  this.debug(`[Player] AudioPlayer stateChange from ${oldState.status} to ${newState.status}`);
2609
+
2610
+ // ── Idle: track ended naturally ───────────────────────────────────────
2526
2611
  if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) {
2612
+ if (this.refreshLock) {
2613
+ this.debug(`[Player] AudioPlayer went idle during resource refresh — skipping trackEnd/playNext`);
2614
+ return;
2615
+ }
2527
2616
  // Track ended
2528
2617
  const track = this.queue.currentTrack;
2529
2618
  if (track) {
@@ -2534,12 +2623,19 @@ export class Player extends EventEmitter {
2534
2623
  }
2535
2624
  }
2536
2625
  void this.playNext();
2626
+ // ── Playing: started or resumed ───────────────────────────────────────
2537
2627
  } else if (
2538
2628
  newState.status === AudioPlayerStatus.Playing &&
2539
2629
  (oldState.status === AudioPlayerStatus.Idle || oldState.status === AudioPlayerStatus.Buffering)
2540
2630
  ) {
2541
2631
  // Track started
2542
2632
  this.clearLeaveTimeout();
2633
+
2634
+ if (this.seekInProgress) {
2635
+ this.debug(`[Player] Seek complete — audio output started`);
2636
+ this.seekInProgress = false;
2637
+ }
2638
+
2543
2639
  const track = this.queue.currentTrack;
2544
2640
  if (track) {
2545
2641
  this.debug(`[Player] Track started: ${track.title}`);
@@ -2556,6 +2652,7 @@ export class Player extends EventEmitter {
2556
2652
  }
2557
2653
  }
2558
2654
  }
2655
+ // ── Paused ────────────────────────────────────────────────────────────
2559
2656
  } else if (newState.status === AudioPlayerStatus.Paused && oldState.status !== AudioPlayerStatus.Paused) {
2560
2657
  const track = this.queue.currentTrack;
2561
2658
  if (track) {
@@ -2565,6 +2662,7 @@ export class Player extends EventEmitter {
2565
2662
  fp.emit("playerPause", track);
2566
2663
  }
2567
2664
  }
2665
+ // ── Resumed from pause ────────────────────────────────────────────────
2568
2666
  } else if (newState.status !== AudioPlayerStatus.Paused && oldState.status === AudioPlayerStatus.Paused) {
2569
2667
  const track = this.queue.currentTrack;
2570
2668
  if (track) {
@@ -2576,8 +2674,15 @@ export class Player extends EventEmitter {
2576
2674
  }
2577
2675
  } else if (newState.status === AudioPlayerStatus.AutoPaused) {
2578
2676
  this.debug(`[Player] AudioPlayerStatus.AutoPaused`);
2677
+ // ── Buffering: start stuck detector ───────────────────────────────────
2579
2678
  } else if (newState.status === AudioPlayerStatus.Buffering) {
2580
2679
  this.debug(`[Player] AudioPlayerStatus.Buffering`);
2680
+
2681
+ if (this.seekInProgress) {
2682
+ this.debug(`[Player] Buffering during seek — stuckTimer suppressed`);
2683
+ return;
2684
+ }
2685
+
2581
2686
  this.lastDuration = this.currentResource?.playbackDuration || 0;
2582
2687
  this.stuckTimer = setTimeout(() => {
2583
2688
  if (this.currentResource?.playbackDuration === this.lastDuration) {
@@ -231,6 +231,11 @@ export class Queue {
231
231
  this.current = this.tracks.shift() || null;
232
232
  }
233
233
 
234
+ // Skip bypassed track loop but no other track exists → restore current from history
235
+ if (!this.current && this._loop === "track" && ignoreLoop && this.history.length > 0) {
236
+ this.current = this.history.pop() || null;
237
+ }
238
+
234
239
  return this.current;
235
240
  }
236
241