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.
@@ -64,6 +64,7 @@ class Player extends events_1.EventEmitter {
64
64
  this.stuckTimer = null;
65
65
  this.skipLoop = false;
66
66
  this.refreshLock = false;
67
+ this.seekInProgress = false;
67
68
  this.currentSlot = {
68
69
  resource: null,
69
70
  track: null,
@@ -110,6 +111,7 @@ class Player extends events_1.EventEmitter {
110
111
  this.SEARCH_CACHE_TTL = 2 * 60 * 1000; // 2 minutes
111
112
  this.ttsPlayer = null;
112
113
  this.lastDuration = 0;
114
+ this.seekOffset = 0;
113
115
  this.debug(`[Player] Constructor called for guildId: ${guildId}`);
114
116
  this.guildId = guildId;
115
117
  this.queue = new Queue_1.Queue();
@@ -200,7 +202,12 @@ class Player extends events_1.EventEmitter {
200
202
  this.preloadManager = new PreloadManager_1.PreloadManager({
201
203
  streamManager: this.streamManager,
202
204
  debug: this.debug.bind(this),
203
- getNextTrack: () => this.queue.nextTrack,
205
+ getNextTrack: () => {
206
+ if (this.queue.loop() === "track") {
207
+ return this.queue.currentTrack;
208
+ }
209
+ return this.queue.nextTrack;
210
+ },
204
211
  getStream: (track) => this.getStream(track),
205
212
  isDestroyed: () => this.destroyed,
206
213
  isEnabled: () => this.preloadEnabled,
@@ -743,42 +750,26 @@ class Player extends events_1.EventEmitter {
743
750
  */
744
751
  async createResource(streamInfo, track, position = 0) {
745
752
  const filterString = this.filter.getFilterString();
746
- this.debug(`[Player] Creating AudioResource with filters: ${filterString || "none"}, seek: ${position}ms`);
747
- try {
748
- let stream = streamInfo.stream;
749
- // Apply filters and seek if needed
750
- if (filterString || position > 0) {
751
- stream = await this.filter.applyFiltersAndSeek(streamInfo.stream, position);
752
- streamInfo.type = voice_1.StreamType.Arbitrary;
753
- }
754
- // Create AudioResource with better error handling
755
- const resource = (0, voice_1.createAudioResource)(stream, {
753
+ this.debug(`[Player] Creating AudioResource filters: ${filterString || "none"}, seek: ${position}ms`);
754
+ // -1 = sentinel "no seek requested"
755
+ const seekArg = position > 0 ? position : -1;
756
+ if (filterString || position > 0) {
757
+ // throws on failure do NOT fall back to the already-piped stream
758
+ const processedStream = await this.filter.applyFiltersAndSeek(streamInfo.stream, seekArg);
759
+ streamInfo.type = voice_1.StreamType.Arbitrary;
760
+ return (0, voice_1.createAudioResource)(processedStream, {
756
761
  metadata: track,
757
- inputType: streamInfo.type === "webm/opus" ? voice_1.StreamType.WebmOpus
758
- : streamInfo.type === "ogg/opus" ? voice_1.StreamType.OggOpus
759
- : voice_1.StreamType.Arbitrary,
762
+ inputType: voice_1.StreamType.Arbitrary,
760
763
  inlineVolume: true,
761
764
  });
762
- return resource;
763
- }
764
- catch (error) {
765
- this.debug(`[Player] Error creating AudioResource with filters+seek:`, error);
766
- // Fallback to basic AudioResource
767
- try {
768
- const resource = (0, voice_1.createAudioResource)(streamInfo.stream, {
769
- metadata: track,
770
- inputType: streamInfo.type === "webm/opus" ? voice_1.StreamType.WebmOpus
771
- : streamInfo.type === "ogg/opus" ? voice_1.StreamType.OggOpus
772
- : voice_1.StreamType.Arbitrary,
773
- inlineVolume: true,
774
- });
775
- return resource;
776
- }
777
- catch (fallbackError) {
778
- this.debug(`[Player] Fallback AudioResource creation failed:`, fallbackError);
779
- throw fallbackError;
780
- }
781
765
  }
766
+ return (0, voice_1.createAudioResource)(streamInfo.stream, {
767
+ metadata: track,
768
+ inputType: streamInfo.type === "webm/opus" ? voice_1.StreamType.WebmOpus
769
+ : streamInfo.type === "ogg/opus" ? voice_1.StreamType.OggOpus
770
+ : voice_1.StreamType.Arbitrary,
771
+ inlineVolume: true,
772
+ });
782
773
  }
783
774
  mergeTrackPreserveRef(target, source) {
784
775
  if (source === target)
@@ -897,7 +888,6 @@ class Player extends events_1.EventEmitter {
897
888
  // Try to use preloaded resource
898
889
  if (this.preloadManager.hasValidPreload(track)) {
899
890
  this.debug(`[Player] Using preloaded stream for: ${track.title}`);
900
- this.audioPlayer.stop(true);
901
891
  const oldStreamId = this.currentSlot.streamId;
902
892
  if (oldStreamId && this.streamManager) {
903
893
  setTimeout(() => {
@@ -911,13 +901,21 @@ class Player extends events_1.EventEmitter {
911
901
  if (!currentResource) {
912
902
  return false;
913
903
  }
904
+ this.seekOffset = 0;
914
905
  const targetVolume = this.getTrackTargetVolume(track);
915
906
  if (currentResource.volume) {
916
907
  currentResource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
917
908
  }
918
909
  await this.maybeAlignToBeatBoundary();
919
- this.audioPlayer.play(currentResource);
920
- await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 10_000);
910
+ this.refreshLock = true;
911
+ try {
912
+ this.audioPlayer.stop(true);
913
+ this.audioPlayer.play(currentResource);
914
+ await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 10_000);
915
+ }
916
+ finally {
917
+ this.refreshLock = false;
918
+ }
921
919
  await this.applyCrossfadeIn(currentResource, track);
922
920
  this.preloadNextTrack().catch((err) => {
923
921
  this.debug(`[Player] Preload error:`, err);
@@ -969,16 +967,23 @@ class Player extends events_1.EventEmitter {
969
967
  this.currentSlot.streamId = streamId;
970
968
  this.currentSlot.isValid = true;
971
969
  this.currentResource = resource;
970
+ this.seekOffset = 0; // new track — reset seek baseline
972
971
  // Apply volume
973
972
  const targetVolume = this.getTrackTargetVolume(track);
974
973
  if (resource.volume) {
975
974
  resource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
976
975
  }
977
- // Play
976
+ // Play — lock refresh so Idle event doesn't spawn duplicate playNext
978
977
  await this.maybeAlignToBeatBoundary();
979
- this.audioPlayer.stop(true);
980
- this.audioPlayer.play(resource);
981
- await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 10_000);
978
+ this.refreshLock = true;
979
+ try {
980
+ this.audioPlayer.stop(true);
981
+ this.audioPlayer.play(resource);
982
+ await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 10_000);
983
+ }
984
+ finally {
985
+ this.refreshLock = false;
986
+ }
982
987
  await this.applyCrossfadeIn(resource, track);
983
988
  // Preload next (async)
984
989
  if (!this.destroyed) {
@@ -1053,6 +1058,10 @@ class Player extends events_1.EventEmitter {
1053
1058
  await new Promise((resolve) => setTimeout(resolve, this.antiStuckRetryDelayMs));
1054
1059
  }
1055
1060
  }
1061
+ else {
1062
+ this.antiStuckConsecutiveFailures = 0;
1063
+ this.skipLoop = true;
1064
+ }
1056
1065
  }
1057
1066
  catch (err) {
1058
1067
  this.debug(`[Player] playNext error:`, err);
@@ -1076,6 +1085,10 @@ class Player extends events_1.EventEmitter {
1076
1085
  await new Promise((resolve) => setTimeout(resolve, this.antiStuckRetryDelayMs));
1077
1086
  }
1078
1087
  }
1088
+ else {
1089
+ this.antiStuckConsecutiveFailures = 0;
1090
+ this.skipLoop = true;
1091
+ }
1079
1092
  continue;
1080
1093
  }
1081
1094
  }
@@ -1208,15 +1221,17 @@ class Player extends events_1.EventEmitter {
1208
1221
  * @example
1209
1222
  * await player.connect(voiceChannel);
1210
1223
  */
1211
- async connect(channel) {
1224
+ async connect(channel, options) {
1212
1225
  try {
1213
1226
  this.debug(`[Player] Connecting to voice channel: ${channel.id}`);
1214
1227
  const connection = (0, voice_1.joinVoiceChannel)({
1228
+ ...options,
1215
1229
  channelId: channel.id,
1216
1230
  guildId: channel.guildId,
1217
1231
  adapterCreator: channel.guild.voiceAdapterCreator,
1218
- selfDeaf: this.options.selfDeaf ?? true,
1219
- selfMute: this.options.selfMute ?? false,
1232
+ selfDeaf: options?.selfDeaf ?? this.options?.selfDeaf ?? true,
1233
+ selfMute: options?.selfMute ?? this.options?.selfMute ?? false,
1234
+ group: options?.group ?? this.options?.group ?? "Ziplayer",
1220
1235
  });
1221
1236
  await (0, voice_1.entersState)(connection, voice_1.VoiceConnectionStatus.Ready, 50_000);
1222
1237
  this.connection = connection;
@@ -1526,7 +1541,11 @@ class Player extends events_1.EventEmitter {
1526
1541
  this.debug(`[Player] Invalid seek position: ${position}ms (track duration: ${totalDuration}ms)`);
1527
1542
  return false;
1528
1543
  }
1529
- await this.refreshPlayerResource(true, position);
1544
+ const ok = await this.refreshPlayerResource(true, position);
1545
+ if (!ok) {
1546
+ this.debug(`[Player] Seek failed at position: ${position}ms`);
1547
+ return false;
1548
+ }
1530
1549
  return true;
1531
1550
  }
1532
1551
  /**
@@ -1697,9 +1716,9 @@ class Player extends events_1.EventEmitter {
1697
1716
  * console.log(`Loop mode: ${loopMode}`);
1698
1717
  */
1699
1718
  loop(mode) {
1700
- this.debug(`[Player] loop called with mode: ${mode}`);
1701
1719
  if (typeof mode === "number") {
1702
1720
  // Number mode: convert to text mode
1721
+ this.debug(`[Player] loop called with mode: ${mode}`);
1703
1722
  switch (mode) {
1704
1723
  case 0:
1705
1724
  return this.queue.loop("off");
@@ -1909,8 +1928,8 @@ class Player extends events_1.EventEmitter {
1909
1928
  }
1910
1929
  const total = track.duration > 1000 ? track.duration : track.duration * 1000;
1911
1930
  if (!total)
1912
- return this.formatTimeCompact(resource.playbackDuration);
1913
- const current = resource.playbackDuration;
1931
+ return this.formatTimeCompact(resource.playbackDuration + this.seekOffset);
1932
+ const current = resource.playbackDuration + this.seekOffset;
1914
1933
  const ratio = Math.min(Math.max(current / total, 0), 1);
1915
1934
  const progress = Math.round(ratio * size);
1916
1935
  // Build progress bar
@@ -2012,7 +2031,7 @@ class Player extends events_1.EventEmitter {
2012
2031
  };
2013
2032
  }
2014
2033
  const total = track.duration > 1000 ? track.duration : track.duration * 1000;
2015
- const current = resource.playbackDuration;
2034
+ const current = resource.playbackDuration + this.seekOffset;
2016
2035
  return {
2017
2036
  current: current,
2018
2037
  total: total,
@@ -2111,57 +2130,113 @@ class Player extends events_1.EventEmitter {
2111
2130
  if (!applyToCurrent || !this.queue.currentTrack || !(this.isPlaying || this.isPaused)) {
2112
2131
  return false;
2113
2132
  }
2114
- if (this.refreshLock)
2133
+ if (this.refreshLock) {
2134
+ this.debug(`[Player] refreshPlayerResource skipped — lock held`);
2115
2135
  return false;
2136
+ }
2137
+ // Lock before anything so stateChange idle sees it when stop() fires.
2116
2138
  this.refreshLock = true;
2139
+ // Clear any existing stuckTimer from the previous playback cycle so it
2140
+ // cannot fire while we are mid-refresh.
2141
+ if (this.stuckTimer) {
2142
+ clearTimeout(this.stuckTimer);
2143
+ this.stuckTimer = null;
2144
+ }
2117
2145
  try {
2118
2146
  const track = this.queue.currentTrack;
2119
2147
  this.debug(`[Player] Refreshing player resource for track: ${track.title}`);
2120
- // Get current position for seeking
2121
- const currentPosition = position > 0 ? position : this.currentResource?.playbackDuration || 0;
2122
- const streaminfo = await this.getStream(track);
2123
- if (!streaminfo?.stream) {
2124
- this.debug(`[Player] No stream to refresh`);
2125
- return false;
2126
- }
2127
- // Create AudioResource with filters and seek to current position
2128
- const resource = await this.createResource(streaminfo, track, currentPosition);
2129
- // Stop current playback and destroy old resource/stream
2130
- const wasPlaying = this.isPlaying;
2148
+ const currentPosition = position >= 0 ? position : (this.currentResource?.playbackDuration ?? 0);
2149
+ this.seekOffset = currentPosition;
2131
2150
  const wasPaused = this.isPaused;
2132
- this.audioPlayer.stop();
2133
- // Properly destroy the old resource and stream
2134
- try {
2135
- if (this.currentResource) {
2136
- const oldStream = this.currentResource._readableState?.stream || this.currentResource.stream;
2137
- if (oldStream && typeof oldStream.destroy === "function") {
2138
- oldStream.destroy();
2139
- }
2151
+ const playbackDuration = this.currentResource?.playbackDuration ?? 0;
2152
+ // Reuse is only viable for forward seeks (stream is sequential).
2153
+ const isForwardSeek = position < 0 || position >= playbackDuration;
2154
+ const currentStreamId = this.currentSlot.streamId;
2155
+ // Try to grab the live source stream for reuse.
2156
+ // getRawStream accepts "paused" streams (discordjs/voice pauses source streams on NoSubscriberBehavior); getStream would reject them.
2157
+ let reuseStream = null;
2158
+ if (isForwardSeek && currentStreamId) {
2159
+ reuseStream = this.streamManager.getRawStream(currentStreamId);
2160
+ if (reuseStream) {
2161
+ this.debug(`[Player] Will reuse source stream for seek (pos: ${currentPosition}ms)`);
2140
2162
  }
2141
2163
  }
2142
- catch (error) {
2143
- this.debug(`[Player] Error destroying old stream in refreshPlayerResource:`, error);
2164
+ // ── CRITICAL: unpipe BEFORE stop ──────────────────────────────────────
2165
+ // stop() kills discordjs/voice internal FFmpeg EPIPE on source stream.
2166
+ // unpipe() first disconnects our stream cleanly before that happens.
2167
+ if (reuseStream) {
2168
+ reuseStream.unpipe();
2144
2169
  }
2145
- finally {
2146
- this.refreshLock = false;
2170
+ // Remove StreamManager listeners.
2171
+ // forceDestroy=false when reusing so the Readable object stays alive.
2172
+ if (currentStreamId) {
2173
+ this.streamManager.unregisterStream(currentStreamId, !reuseStream);
2174
+ this.currentSlot.streamId = null;
2175
+ }
2176
+ // Stop the AudioPlayer.
2177
+ // stateChange (playing→idle) fires; refreshLock=true guards it (v4 fix).
2178
+ this.audioPlayer.stop(true);
2179
+ this.currentResource = null;
2180
+ this.currentSlot.resource = null;
2181
+ this.currentSlot.isValid = false;
2182
+ // One event-loop tick: lets deferred stream events settle.
2183
+ await new Promise((resolve) => setImmediate(resolve));
2184
+ // Verify the reuse stream survived stop().
2185
+ if (reuseStream) {
2186
+ if (reuseStream.destroyed || reuseStream.readable === false) {
2187
+ this.debug(`[Player] Source stream did not survive stop — falling back to fresh stream`);
2188
+ reuseStream = null;
2189
+ }
2190
+ }
2191
+ let streaminfo = null;
2192
+ if (reuseStream) {
2193
+ streaminfo = { stream: reuseStream, type: "arbitrary" };
2194
+ }
2195
+ else {
2196
+ // Clear caches so we don't get the dead Readable back.
2197
+ this.pluginManager.clearStreamCache();
2198
+ this.extensionManager.clearCache("stream");
2199
+ this.debug(`[Player] Fetching fresh stream${!isForwardSeek ? " (backward seek)" : " (reuse failed)"}`);
2200
+ streaminfo = await this.getStream(track);
2201
+ }
2202
+ if (!streaminfo?.stream) {
2203
+ this.debug(`[Player] No stream available for refresh`);
2204
+ return false;
2147
2205
  }
2206
+ // Build AudioResource (input-side FFmpeg seek via FilterManager).
2207
+ const resource = await this.createResource(streaminfo, track, currentPosition);
2208
+ // Register the source stream.
2209
+ const newStreamId = this.streamManager.registerStream(streaminfo.stream, track, {
2210
+ source: track.source || "stream",
2211
+ isPreload: false,
2212
+ priority: 10,
2213
+ });
2214
+ this.currentSlot.resource = resource;
2215
+ this.currentSlot.track = track;
2216
+ this.currentSlot.streamId = newStreamId;
2217
+ this.currentSlot.isValid = true;
2148
2218
  this.currentResource = resource;
2149
- // Subscribe to new resource
2219
+ // ── Set seek flag BEFORE play so the Buffering handler sees it ────────
2220
+ if (position >= 0) {
2221
+ this.seekInProgress = true;
2222
+ }
2150
2223
  if (this.connection) {
2151
2224
  this.connection.subscribe(this.audioPlayer);
2152
2225
  this.audioPlayer.play(resource);
2153
2226
  }
2154
- // Restore playing state
2155
- if (wasPaused) {
2227
+ if (wasPaused)
2156
2228
  this.audioPlayer.pause();
2157
- }
2158
- this.debug(`[Player] Successfully applied filter to current track at position ${currentPosition}ms`);
2229
+ this.debug(`[Player] Resource refreshed at position ${currentPosition}ms`);
2159
2230
  return true;
2160
2231
  }
2161
2232
  catch (error) {
2162
- this.debug(`[Player] Error applying filter to current track:`, error);
2163
- // Filter was still added to active filters, so return true
2164
- return true;
2233
+ this.debug(`[Player] refreshPlayerResource error:`, error);
2234
+ this.seekInProgress = false; // ensure flag is cleared on failure
2235
+ this.emit("playerError", error, this.queue.currentTrack ?? undefined);
2236
+ return false;
2237
+ }
2238
+ finally {
2239
+ this.refreshLock = false; // always released
2165
2240
  }
2166
2241
  }
2167
2242
  /**
@@ -2212,7 +2287,12 @@ class Player extends events_1.EventEmitter {
2212
2287
  if (this.destroyed)
2213
2288
  return;
2214
2289
  this.debug(`[Player] AudioPlayer stateChange from ${oldState.status} to ${newState.status}`);
2290
+ // ── Idle: track ended naturally ───────────────────────────────────────
2215
2291
  if (newState.status === voice_1.AudioPlayerStatus.Idle && oldState.status !== voice_1.AudioPlayerStatus.Idle) {
2292
+ if (this.refreshLock) {
2293
+ this.debug(`[Player] AudioPlayer went idle during resource refresh — skipping trackEnd/playNext`);
2294
+ return;
2295
+ }
2216
2296
  // Track ended
2217
2297
  const track = this.queue.currentTrack;
2218
2298
  if (track) {
@@ -2223,11 +2303,16 @@ class Player extends events_1.EventEmitter {
2223
2303
  }
2224
2304
  }
2225
2305
  void this.playNext();
2306
+ // ── Playing: started or resumed ───────────────────────────────────────
2226
2307
  }
2227
2308
  else if (newState.status === voice_1.AudioPlayerStatus.Playing &&
2228
2309
  (oldState.status === voice_1.AudioPlayerStatus.Idle || oldState.status === voice_1.AudioPlayerStatus.Buffering)) {
2229
2310
  // Track started
2230
2311
  this.clearLeaveTimeout();
2312
+ if (this.seekInProgress) {
2313
+ this.debug(`[Player] Seek complete — audio output started`);
2314
+ this.seekInProgress = false;
2315
+ }
2231
2316
  const track = this.queue.currentTrack;
2232
2317
  if (track) {
2233
2318
  this.debug(`[Player] Track started: ${track.title}`);
@@ -2244,6 +2329,7 @@ class Player extends events_1.EventEmitter {
2244
2329
  }
2245
2330
  }
2246
2331
  }
2332
+ // ── Paused ────────────────────────────────────────────────────────────
2247
2333
  }
2248
2334
  else if (newState.status === voice_1.AudioPlayerStatus.Paused && oldState.status !== voice_1.AudioPlayerStatus.Paused) {
2249
2335
  const track = this.queue.currentTrack;
@@ -2254,6 +2340,7 @@ class Player extends events_1.EventEmitter {
2254
2340
  fp.emit("playerPause", track);
2255
2341
  }
2256
2342
  }
2343
+ // ── Resumed from pause ────────────────────────────────────────────────
2257
2344
  }
2258
2345
  else if (newState.status !== voice_1.AudioPlayerStatus.Paused && oldState.status === voice_1.AudioPlayerStatus.Paused) {
2259
2346
  const track = this.queue.currentTrack;
@@ -2267,9 +2354,14 @@ class Player extends events_1.EventEmitter {
2267
2354
  }
2268
2355
  else if (newState.status === voice_1.AudioPlayerStatus.AutoPaused) {
2269
2356
  this.debug(`[Player] AudioPlayerStatus.AutoPaused`);
2357
+ // ── Buffering: start stuck detector ───────────────────────────────────
2270
2358
  }
2271
2359
  else if (newState.status === voice_1.AudioPlayerStatus.Buffering) {
2272
2360
  this.debug(`[Player] AudioPlayerStatus.Buffering`);
2361
+ if (this.seekInProgress) {
2362
+ this.debug(`[Player] Buffering during seek — stuckTimer suppressed`);
2363
+ return;
2364
+ }
2273
2365
  this.lastDuration = this.currentResource?.playbackDuration || 0;
2274
2366
  this.stuckTimer = setTimeout(() => {
2275
2367
  if (this.currentResource?.playbackDuration === this.lastDuration) {