ziplayer 0.3.2 → 0.3.4

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 (39) hide show
  1. package/{AI-Guide.md → AGENTS.md} +717 -624
  2. package/README.md +658 -526
  3. package/dist/extensions/BaseExtension.d.ts +10 -1
  4. package/dist/extensions/BaseExtension.d.ts.map +1 -1
  5. package/dist/extensions/BaseExtension.js +27 -1
  6. package/dist/extensions/BaseExtension.js.map +1 -1
  7. package/dist/extensions/index.d.ts.map +1 -1
  8. package/dist/extensions/index.js +24 -6
  9. package/dist/extensions/index.js.map +1 -1
  10. package/dist/plugins/index.d.ts.map +1 -1
  11. package/dist/plugins/index.js +105 -51
  12. package/dist/plugins/index.js.map +1 -1
  13. package/dist/structures/Player.d.ts +90 -15
  14. package/dist/structures/Player.d.ts.map +1 -1
  15. package/dist/structures/Player.js +487 -81
  16. package/dist/structures/Player.js.map +1 -1
  17. package/dist/structures/PlayerManager.d.ts +70 -6
  18. package/dist/structures/PlayerManager.d.ts.map +1 -1
  19. package/dist/structures/PlayerManager.js +184 -19
  20. package/dist/structures/PlayerManager.js.map +1 -1
  21. package/dist/structures/Queue.d.ts +19 -0
  22. package/dist/structures/Queue.d.ts.map +1 -1
  23. package/dist/structures/Queue.js +21 -0
  24. package/dist/structures/Queue.js.map +1 -1
  25. package/dist/types/extension.d.ts +3 -0
  26. package/dist/types/extension.d.ts.map +1 -1
  27. package/dist/types/index.d.ts +69 -2
  28. package/dist/types/index.d.ts.map +1 -1
  29. package/dist/types/index.js +13 -0
  30. package/dist/types/index.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/extensions/BaseExtension.ts +31 -1
  33. package/src/extensions/index.ts +30 -7
  34. package/src/plugins/index.ts +137 -54
  35. package/src/structures/Player.ts +2937 -2457
  36. package/src/structures/PlayerManager.ts +916 -725
  37. package/src/structures/Queue.ts +621 -599
  38. package/src/types/extension.ts +3 -0
  39. package/src/types/index.ts +80 -2
@@ -4,6 +4,7 @@ exports.Player = void 0;
4
4
  const events_1 = require("events");
5
5
  const voice_1 = require("@discordjs/voice");
6
6
  const lru_cache_1 = require("lru-cache");
7
+ const types_1 = require("../types");
7
8
  const Queue_1 = require("./Queue");
8
9
  const plugins_1 = require("../plugins");
9
10
  const extensions_1 = require("../extensions");
@@ -51,11 +52,14 @@ class Player extends events_1.EventEmitter {
51
52
  super();
52
53
  this.connection = null;
53
54
  this.volume = 100;
54
- this.isPlaying = false;
55
- this.isPaused = false;
56
55
  this._lastActivity = Date.now();
57
- this.leaveTimeout = null;
56
+ this._remotePaused = false;
58
57
  this.currentResource = null;
58
+ this.destroyed = false;
59
+ this.playbackMode = types_1.PlaybackMode.NATIVE;
60
+ this.forwardFollowers = new Set();
61
+ this.forwardLeader = null;
62
+ this.leaveTimeout = null;
59
63
  this.volumeInterval = null;
60
64
  this.stuckTimer = null;
61
65
  this.skipLoop = false;
@@ -103,7 +107,6 @@ class Player extends events_1.EventEmitter {
103
107
  this.loudnessMaxBoostDb = 8;
104
108
  this.loudnessMaxCutDb = 10;
105
109
  this.loudnessLimiterCeiling = 0.95;
106
- this.destroyed = false;
107
110
  this.SEARCH_CACHE_TTL = 2 * 60 * 1000; // 2 minutes
108
111
  this.ttsPlayer = null;
109
112
  this.lastDuration = 0;
@@ -181,6 +184,7 @@ class Player extends events_1.EventEmitter {
181
184
  this.loudnessMaxCutDb = Math.max(0, loudnessOptions.maxCutDb ?? 10);
182
185
  this.loudnessLimiterCeiling = Math.min(1, Math.max(0.1, loudnessOptions.limiterCeiling ?? 0.95));
183
186
  this.debug(`[Player] Runtime options resolved: lowPerformance=${this.lowPerformanceMode}, preload=${this.preloadEnabled}, crossfade=${this.crossfadeEnabled} (${this.crossfadeDurationMs}ms), smartTransition=${this.smartTransitionEnabled}, antiStuck=${this.antiStuckEnabled}, loudnessNormalization=${this.loudnessNormalizationEnabled}`);
187
+ this.trackMiddlewareChain = [...this.manager.getTrackMiddlewareChain(), ...(0, types_1.normalizeTrackMiddleware)(options.trackMiddleware)];
184
188
  this.filter = new FilterManager_1.FilterManager(this, this.manager);
185
189
  this.extensionManager = new extensions_1.ExtensionManager(this, this.manager);
186
190
  this.pluginManager = new plugins_1.PluginManager(this, this.manager, {
@@ -375,6 +379,10 @@ class Player extends events_1.EventEmitter {
375
379
  * await player.play(null); // play from queue
376
380
  */
377
381
  async play(query, requestedBy) {
382
+ if (this.playbackMode === types_1.PlaybackMode.FORWARD) {
383
+ this.debug("[Player] Cannot play while subscribed to another player. Call unsubscribeForward() first.");
384
+ return false;
385
+ }
378
386
  const debugInfo = query === null ? "null"
379
387
  : typeof query === "string" ? query
380
388
  : "tracks" in query ? `${query.tracks.length} tracks`
@@ -772,16 +780,44 @@ class Player extends events_1.EventEmitter {
772
780
  }
773
781
  }
774
782
  }
783
+ mergeTrackPreserveRef(target, source) {
784
+ if (source === target)
785
+ return;
786
+ const mergedMeta = {
787
+ ...(target.metadata || {}),
788
+ ...(source.metadata || {}),
789
+ };
790
+ Object.assign(target, source);
791
+ target.metadata = mergedMeta;
792
+ }
793
+ async applyTrackMiddleware(track) {
794
+ if (this.trackMiddlewareChain.length === 0)
795
+ return;
796
+ const ctx = { player: this, manager: this.manager };
797
+ for (const mw of this.trackMiddlewareChain) {
798
+ try {
799
+ const out = await mw(track, ctx);
800
+ if (out != null && out !== track) {
801
+ this.mergeTrackPreserveRef(track, out);
802
+ }
803
+ }
804
+ catch (err) {
805
+ this.debug(`[TrackMiddleware] Error:`, err);
806
+ }
807
+ }
808
+ }
775
809
  async getStream(track) {
776
810
  if (this.destroyed) {
777
811
  throw new Error("PLAYER_DESTROYED");
778
812
  }
813
+ await this.applyTrackMiddleware(track);
779
814
  const trackId = track.id || track.url || track.title;
780
815
  const existingStream = this.streamManager.getStreamByTrack(trackId);
781
816
  if (existingStream && !existingStream.destroyed) {
782
817
  this.debug(`[Stream] Using existing stream from manager for: ${track.title}`);
783
818
  return { stream: existingStream, type: "arbitrary" };
784
819
  }
820
+ // FIRST: Try to get stream from extensions
785
821
  let stream = await this.extensionManager.provideStream(track);
786
822
  if (this.destroyed) {
787
823
  if (stream?.stream && typeof stream.stream.destroy === "function" && !stream.stream.destroyed) {
@@ -789,10 +825,24 @@ class Player extends events_1.EventEmitter {
789
825
  }
790
826
  throw new Error("PLAYER_DESTROYED");
791
827
  }
828
+ // Handle remote playback - THIS SHOULD BE FIRST PRIORITY
829
+ if (stream?.remote && stream.handle) {
830
+ this.debug(`[Stream] Remote handle provided by extension for: ${track.title}`);
831
+ this.playbackMode = types_1.PlaybackMode.REMOTE;
832
+ this.preloadEnabled = false;
833
+ this.crossfadeEnabled = false;
834
+ // Clear any existing preload for remote mode
835
+ this.cancelPreload();
836
+ return stream;
837
+ }
838
+ // If extension returned a regular stream
792
839
  if (stream?.stream) {
793
840
  this.debug(`[Stream] Extension provided stream for: ${track.title}`);
841
+ this.playbackMode = types_1.PlaybackMode.NATIVE;
794
842
  return stream;
795
843
  }
844
+ // SECOND: Try plugins only if extension didn't handle it
845
+ this.debug(`[Stream] Extension didn't provide stream, trying plugins for: ${track.title}`);
796
846
  stream = await this.pluginManager.getStream(track);
797
847
  if (this.destroyed) {
798
848
  if (stream?.stream && typeof stream.stream.destroy === "function" && !stream.stream.destroyed) {
@@ -807,10 +857,11 @@ class Player extends events_1.EventEmitter {
807
857
  stream.stream.destroy();
808
858
  return { stream: existingAgain, type: "arbitrary" };
809
859
  }
810
- // Register with StreamManager
811
860
  this.debug(`[Stream] Plugin provided stream for: ${track.title}`);
861
+ this.playbackMode = types_1.PlaybackMode.NATIVE;
812
862
  return stream;
813
863
  }
864
+ // Check if any plugin claims to support this track but failed
814
865
  if (!this.pluginManager.hasStreamCandidate(track)) {
815
866
  throw new Error(`UNRECOVERABLE_NO_PLUGIN:${track.title}`);
816
867
  }
@@ -827,13 +878,25 @@ class Player extends events_1.EventEmitter {
827
878
  async startTrack(track) {
828
879
  if (this.destroyed)
829
880
  return false;
881
+ // First, get stream info (this will handle remote detection)
882
+ let streamInfo = null;
883
+ try {
884
+ streamInfo = await this.getStream(track);
885
+ }
886
+ catch (error) {
887
+ this.debug(`[Player] Failed to get stream for track: ${track.title}`, error);
888
+ throw error;
889
+ }
890
+ // Handle remote playback
891
+ if (streamInfo?.remote && streamInfo.handle) {
892
+ return await this.playRemote(track, streamInfo);
893
+ }
894
+ // Handle native playback
830
895
  try {
831
896
  // Try to use preloaded resource
832
897
  if (this.preloadManager.hasValidPreload(track)) {
833
898
  this.debug(`[Player] Using preloaded stream for: ${track.title}`);
834
- // Stop current playback
835
899
  this.audioPlayer.stop(true);
836
- // Clean up old current stream (but delay to be safe)
837
900
  const oldStreamId = this.currentSlot.streamId;
838
901
  if (oldStreamId && this.streamManager) {
839
902
  setTimeout(() => {
@@ -842,29 +905,24 @@ class Player extends events_1.EventEmitter {
842
905
  }
843
906
  }, 3000);
844
907
  }
845
- // Set current slot from preload
846
908
  this.promotePreloadToCurrent(track);
847
909
  const currentResource = this.currentSlot.resource;
848
910
  if (!currentResource) {
849
911
  return false;
850
912
  }
851
913
  const targetVolume = this.getTrackTargetVolume(track);
852
- // Apply volume
853
914
  if (currentResource.volume) {
854
915
  currentResource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
855
916
  }
856
- // Play
857
917
  await this.maybeAlignToBeatBoundary();
858
918
  this.audioPlayer.play(currentResource);
859
919
  await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 10_000);
860
920
  await this.applyCrossfadeIn(currentResource, track);
861
- // Start preloading next track (async, don't await)
862
921
  this.preloadNextTrack().catch((err) => {
863
922
  this.debug(`[Player] Preload error:`, err);
864
923
  });
865
924
  return true;
866
925
  }
867
- // No valid preload, load fresh
868
926
  this.debug(`[Player] No preload available, loading fresh: ${track.title}`);
869
927
  return await this.loadFreshStream(track);
870
928
  }
@@ -874,53 +932,6 @@ class Player extends events_1.EventEmitter {
874
932
  return false;
875
933
  }
876
934
  }
877
- /**
878
- * Swap preload slot to current slot
879
- */
880
- async swapToCurrent(track) {
881
- if (!this.preloadManager.hasValidPreload(track)) {
882
- return false;
883
- }
884
- const oldStreamId = this.currentSlot.streamId;
885
- // Stop current playback
886
- this.audioPlayer.stop(true);
887
- // Clean up old current stream (but keep it for a moment)
888
- if (oldStreamId && this.streamManager) {
889
- // Delay cleanup to avoid destroying if still needed
890
- setTimeout(() => {
891
- if (this.currentSlot.streamId === oldStreamId) {
892
- this.streamManager.unregisterStream(oldStreamId, true);
893
- }
894
- }, 5000);
895
- }
896
- // Set new current
897
- this.promotePreloadToCurrent(track);
898
- const currentResource = this.currentSlot.resource;
899
- if (!currentResource) {
900
- return false;
901
- }
902
- const targetVolume = this.getTrackTargetVolume(track);
903
- // Apply volume
904
- if (currentResource.volume) {
905
- currentResource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
906
- }
907
- // Play
908
- await this.maybeAlignToBeatBoundary();
909
- this.audioPlayer.play(currentResource);
910
- try {
911
- await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 10_000);
912
- await this.applyCrossfadeIn(currentResource, track);
913
- // Start preloading next track
914
- this.preloadNextTrack().catch((err) => {
915
- this.debug(`[Player] Preload error:`, err);
916
- });
917
- return true;
918
- }
919
- catch (err) {
920
- this.debug(`[Player] Failed to play swapped track:`, err);
921
- return false;
922
- }
923
- }
924
935
  /**
925
936
  * Load fresh stream when no preload available
926
937
  */
@@ -931,6 +942,10 @@ class Player extends events_1.EventEmitter {
931
942
  await this.safeCancelPreload();
932
943
  try {
933
944
  const streamInfo = await this.getStream(track);
945
+ // Handle remote playback
946
+ if (streamInfo?.remote && streamInfo.handle) {
947
+ return await this.playRemote(track, streamInfo);
948
+ }
934
949
  if (!streamInfo?.stream) {
935
950
  throw new Error(`No stream available`);
936
951
  }
@@ -983,8 +998,6 @@ class Player extends events_1.EventEmitter {
983
998
  if (this.destroyed)
984
999
  return false;
985
1000
  this.debug("[Player] playNext called");
986
- // Don't cancel preload here unless absolutely necessary
987
- // Let startTrack handle it
988
1001
  while (true) {
989
1002
  const track = this.queue.next(this.skipLoop);
990
1003
  this.skipLoop = false;
@@ -1005,7 +1018,6 @@ class Player extends events_1.EventEmitter {
1005
1018
  }
1006
1019
  }
1007
1020
  this.debug(`[Player] No next track in queue`);
1008
- this.isPlaying = false;
1009
1021
  this.emit("queueEnd");
1010
1022
  // Clean up both slots when queue is empty
1011
1023
  this.clearSlot(this.currentSlot);
@@ -1024,6 +1036,11 @@ class Player extends events_1.EventEmitter {
1024
1036
  this.antiStuckConsecutiveFailures = 0;
1025
1037
  return true;
1026
1038
  }
1039
+ // For remote playback, if startTrack returns false, it's a failure
1040
+ if (this.playbackMode === types_1.PlaybackMode.REMOTE) {
1041
+ this.debug(`[Player] Remote track failed to start: ${track.title}`);
1042
+ continue; // Skip to next track
1043
+ }
1027
1044
  const recovered = await this.attemptTrackRecovery(track, new Error("TRACK_START_RETURNED_FALSE"));
1028
1045
  if (recovered) {
1029
1046
  return true;
@@ -1038,6 +1055,11 @@ class Player extends events_1.EventEmitter {
1038
1055
  catch (err) {
1039
1056
  this.debug(`[Player] playNext error:`, err);
1040
1057
  this.emit("playerError", err, track);
1058
+ // For remote playback, just skip to next track
1059
+ if (this.playbackMode === types_1.PlaybackMode.REMOTE) {
1060
+ this.debug(`[Player] Remote track error, skipping: ${track.title}`);
1061
+ continue;
1062
+ }
1041
1063
  if (this.isUnrecoverableStreamError(err)) {
1042
1064
  this.debug(`[Player] Skipping unrecoverable track (no plugin): ${track.title}`);
1043
1065
  continue;
@@ -1056,6 +1078,27 @@ class Player extends events_1.EventEmitter {
1056
1078
  }
1057
1079
  }
1058
1080
  }
1081
+ async playRemote(track, stream) {
1082
+ if (!stream.handle)
1083
+ return false;
1084
+ try {
1085
+ // Store the remote handle for later use
1086
+ this.remoteHandle = stream.handle;
1087
+ // Set current track before playing
1088
+ this.queue.setCurrentTrack(track);
1089
+ // Emit track start event before playing (so UI updates)
1090
+ this.emit("trackStart", track);
1091
+ // Start playback via remote handle
1092
+ await stream.handle.play();
1093
+ this.debug(`[Player] Remote playback started for: ${track.title}`);
1094
+ return true;
1095
+ }
1096
+ catch (error) {
1097
+ this.debug(`[Player] Remote playback error:`, error);
1098
+ this.emit("playerError", error, track);
1099
+ return false;
1100
+ }
1101
+ }
1059
1102
  //#endregion
1060
1103
  //#region TTS
1061
1104
  ensureTTSPlayer() {
@@ -1088,6 +1131,7 @@ class Player extends events_1.EventEmitter {
1088
1131
  if (!this.connection)
1089
1132
  throw new Error("No voice connection for TTS");
1090
1133
  const ttsPlayer = this.ensureTTSPlayer();
1134
+ await this.applyTrackMiddleware(track);
1091
1135
  // Build resource from plugin stream
1092
1136
  const streamInfo = await this.pluginManager.getStream(track);
1093
1137
  if (!streamInfo) {
@@ -1205,6 +1249,145 @@ class Player extends events_1.EventEmitter {
1205
1249
  throw error;
1206
1250
  }
1207
1251
  }
1252
+ /**
1253
+ * Subscribe this player to another player's playback stream.
1254
+ *
1255
+ * This enables "forward mode", where the follower player directly subscribes
1256
+ * to the leader player's {@link audioPlayer} instead of creating its own stream.
1257
+ *
1258
+ * Greatly reduces CPU, bandwidth, and extractor usage because only the leader
1259
+ * creates and decodes the audio resource.
1260
+ *
1261
+ * ## Features
1262
+ * - Real-time shared playback
1263
+ * - Followers may join at any time
1264
+ * - Automatic track synchronization
1265
+ * - Optional volume synchronization
1266
+ * - Automatic cleanup on destroy
1267
+ * - Supports unlimited followers
1268
+ *
1269
+ * ## Lifecycle
1270
+ * - When the leader starts a track, followers automatically receive the same track metadata.
1271
+ * - When the leader pauses/resumes/stops, followers are synchronized.
1272
+ * - Destroying the leader automatically unsubscribes all followers.
1273
+ * - Destroying a follower only removes that follower.
1274
+ *
1275
+ * ## Notes
1276
+ * - Both players must already be connected to voice.
1277
+ * - A player cannot subscribe to itself.
1278
+ * - Existing playback subscriptions are automatically replaced.
1279
+ *
1280
+ * @param {Player} leader The leader player to subscribe to.
1281
+ * @param options Additional playback mirror options.
1282
+ * @param options.syncVolume When true, follower volume automatically follows the leader. Default: true.
1283
+ *
1284
+ * @returns {boolean} True if subscription succeeded.
1285
+ *
1286
+ * @example
1287
+ * follower.subscribeTo(leader);
1288
+ *
1289
+ * @example
1290
+ * follower.subscribeTo(leader, {
1291
+ * syncVolume: true,
1292
+ * });
1293
+ */
1294
+ subscribeTo(leader, options) {
1295
+ if (!leader)
1296
+ return false;
1297
+ if (leader === this) {
1298
+ this.debug(`[Player] Cannot subscribe to self`);
1299
+ return false;
1300
+ }
1301
+ if (leader.destroyed) {
1302
+ this.debug("[Player] Cannot subscribe to destroyed leader");
1303
+ return false;
1304
+ }
1305
+ if (this.destroyed) {
1306
+ this.debug("[Player] Cannot subscribe destroyed player");
1307
+ return false;
1308
+ }
1309
+ if (!!leader.forwardLeader) {
1310
+ this.debug("[Player] Cannot subscribe to follower player");
1311
+ return false;
1312
+ }
1313
+ if (!this.connection || !leader.connection) {
1314
+ this.debug(`[Player] Missing connection for subscribeTo`);
1315
+ return false;
1316
+ }
1317
+ // cleanup old leader
1318
+ if (this.forwardLeader) {
1319
+ this.unsubscribeForward("This Player new subscribeTo " + leader.guildId);
1320
+ }
1321
+ this.forwardLeader = leader;
1322
+ leader.forwardFollowers.add(this);
1323
+ try {
1324
+ // clear local playback
1325
+ this.stop();
1326
+ // detach current followers first
1327
+ for (const fp of [...this.forwardFollowers]) {
1328
+ try {
1329
+ fp.unsubscribeForward("Leader new subscribeTo " + leader.guildId);
1330
+ }
1331
+ catch { }
1332
+ }
1333
+ this.forwardFollowers.clear();
1334
+ this.queue.clear();
1335
+ if (leader.currentTrack) {
1336
+ this.queue.setCurrentTrack(leader.currentTrack);
1337
+ }
1338
+ if (options?.forwardMode ?? true)
1339
+ this.playbackMode = types_1.PlaybackMode.FORWARD;
1340
+ if (this.playbackMode === types_1.PlaybackMode.FORWARD && this.connection) {
1341
+ this.connection.subscribe(leader.audioPlayer);
1342
+ }
1343
+ this.volume = leader.volume;
1344
+ this.emit("forwardModeStart", leader);
1345
+ this.debug(`[Player] Forward mode subscribed ${this.guildId} -> ${leader.guildId}`);
1346
+ return true;
1347
+ }
1348
+ catch (e) {
1349
+ this.debug(`[Player] subscribeTo error:`, e);
1350
+ this.forwardLeader = null;
1351
+ leader.forwardFollowers.delete(this);
1352
+ return false;
1353
+ }
1354
+ }
1355
+ /**
1356
+ * Unsubscribe this player from its current playback leader.
1357
+ *
1358
+ * This disables forward mode and restores the player's own audioPlayer
1359
+ * subscription back to its voice connection.
1360
+ *
1361
+ * Automatically emitted when:
1362
+ * - The leader player is destroyed
1363
+ * - This player is destroyed
1364
+ * - A new leader subscription replaces the old one
1365
+ *
1366
+ * Emits:
1367
+ * - `forwardModeEnd`
1368
+ *
1369
+ * @returns {boolean} True if a playback subscription existed and was removed.
1370
+ *
1371
+ * @example
1372
+ * follower.unsubscribeForward();
1373
+ */
1374
+ unsubscribeForward(reason) {
1375
+ if (!this.forwardLeader) {
1376
+ return false;
1377
+ }
1378
+ const leader = this.forwardLeader;
1379
+ leader.forwardFollowers.delete(this);
1380
+ this.forwardLeader = null;
1381
+ this.playbackMode = types_1.PlaybackMode.NATIVE;
1382
+ try {
1383
+ this.connection?.subscribe(this.audioPlayer);
1384
+ }
1385
+ catch { }
1386
+ this.queue.clear();
1387
+ this.emit("forwardModeEnd", leader, reason);
1388
+ this.debug(`[Player] Forward mode unsubscribed ${this.guildId} <- ${leader.guildId}: ${reason ?? null}`);
1389
+ return true;
1390
+ }
1208
1391
  /**
1209
1392
  * Pause the current track
1210
1393
  *
@@ -1214,10 +1397,22 @@ class Player extends events_1.EventEmitter {
1214
1397
  * console.log(`Paused: ${paused}`);
1215
1398
  */
1216
1399
  pause() {
1400
+ if (this.playbackMode === types_1.PlaybackMode.FORWARD) {
1401
+ this.debug("[Player] Cannot pause while subscribed to another player");
1402
+ return false;
1403
+ }
1217
1404
  this.debug(`[Player] pause called`);
1218
- if (this.isPlaying && !this.isPaused) {
1219
- return this.audioPlayer.pause();
1405
+ if (this.playbackMode === types_1.PlaybackMode.REMOTE) {
1406
+ if (!this.remoteHandle)
1407
+ return false;
1408
+ void this.remoteHandle.pause().catch((e) => this.debug("[Player] Remote pause:", e));
1409
+ const track = this.queue.currentTrack;
1410
+ if (track)
1411
+ this.emit("playerPause", track);
1412
+ return true;
1220
1413
  }
1414
+ if (this.isPlaying && !this.isPaused)
1415
+ return this.audioPlayer.pause();
1221
1416
  return false;
1222
1417
  }
1223
1418
  /**
@@ -1229,7 +1424,20 @@ class Player extends events_1.EventEmitter {
1229
1424
  * console.log(`Resumed: ${resumed}`);
1230
1425
  */
1231
1426
  resume() {
1427
+ if (this.playbackMode === types_1.PlaybackMode.FORWARD) {
1428
+ this.debug("[Player] Cannot resume while subscribed to another player");
1429
+ return false;
1430
+ }
1232
1431
  this.debug(`[Player] resume called`);
1432
+ if (this.playbackMode === types_1.PlaybackMode.REMOTE) {
1433
+ if (!this.remoteHandle)
1434
+ return false;
1435
+ void this.remoteHandle.resume().catch((e) => this.debug("[Player] Remote resume:", e));
1436
+ const track = this.queue.currentTrack;
1437
+ if (track)
1438
+ this.emit("playerResume", track);
1439
+ return true;
1440
+ }
1233
1441
  if (this.isPaused) {
1234
1442
  const result = this.audioPlayer.unpause();
1235
1443
  if (result) {
@@ -1252,16 +1460,33 @@ class Player extends events_1.EventEmitter {
1252
1460
  * console.log(`Stopped: ${stopped}`);
1253
1461
  */
1254
1462
  stop() {
1463
+ if (this.playbackMode === types_1.PlaybackMode.FORWARD) {
1464
+ this.debug("[Player] Cannot stop while subscribed to another player");
1465
+ return false;
1466
+ }
1255
1467
  this.debug(`[Player] stop called`);
1468
+ if (this.playbackMode === types_1.PlaybackMode.REMOTE) {
1469
+ this.cancelPreload();
1470
+ this.queue.clear();
1471
+ void this.remoteHandle?.stop().catch((e) => this.debug("[Player] Remote stop:", e));
1472
+ this.emit("playerStop");
1473
+ return true;
1474
+ }
1256
1475
  // Cancel preload when stopping
1257
1476
  this.cancelPreload();
1258
1477
  this.queue.clear();
1259
1478
  const result = this.audioPlayer.stop();
1260
1479
  this.destroyCurrentStream();
1261
1480
  this.currentResource = null;
1262
- this.isPlaying = false;
1263
- this.isPaused = false;
1264
1481
  this.emit("playerStop");
1482
+ for (const fp of this.forwardFollowers) {
1483
+ try {
1484
+ fp.connection?.subscribe(fp.audioPlayer);
1485
+ fp.audioPlayer.stop(true);
1486
+ fp.emit("playerStop");
1487
+ }
1488
+ catch { }
1489
+ }
1265
1490
  return result;
1266
1491
  }
1267
1492
  /**
@@ -1278,7 +1503,17 @@ class Player extends events_1.EventEmitter {
1278
1503
  * await player.seek(90000);
1279
1504
  */
1280
1505
  async seek(position) {
1506
+ if (this.playbackMode === types_1.PlaybackMode.FORWARD) {
1507
+ this.debug("[Player] Cannot seek while subscribed to another player");
1508
+ return false;
1509
+ }
1281
1510
  this.debug(`[Player] seek called with position: ${position}ms`);
1511
+ if (this.playbackMode === types_1.PlaybackMode.REMOTE) {
1512
+ if (!this.remoteHandle)
1513
+ return false;
1514
+ await this.remoteHandle.seek(position);
1515
+ return true;
1516
+ }
1282
1517
  const track = this.queue.currentTrack;
1283
1518
  if (!track) {
1284
1519
  this.debug(`[Player] No current track to seek`);
@@ -1303,7 +1538,20 @@ class Player extends events_1.EventEmitter {
1303
1538
  * console.log(`Skipped: ${skipped}`);
1304
1539
  */
1305
1540
  skip(index) {
1541
+ if (this.playbackMode === types_1.PlaybackMode.FORWARD) {
1542
+ this.debug("[Player] Cannot skip while subscribed to another player");
1543
+ return false;
1544
+ }
1306
1545
  this.debug(`[Player] skip called with index: ${index}`);
1546
+ if (this.playbackMode === types_1.PlaybackMode.REMOTE) {
1547
+ if (typeof index === "number" && index >= 0) {
1548
+ for (let i = 0; i < index; i++)
1549
+ this.queue.remove(0);
1550
+ }
1551
+ // signal the remote backend to stop; TrackEndEvent triggers playNext()
1552
+ void this.remoteHandle?.stop().catch((e) => this.debug("[Player] Remote skip:", e));
1553
+ return true;
1554
+ }
1307
1555
  try {
1308
1556
  if (typeof index === "number" && index >= 0) {
1309
1557
  const targetTrack = this.queue.getTrack(index);
@@ -1340,6 +1588,10 @@ class Player extends events_1.EventEmitter {
1340
1588
  * console.log(`Previous: ${previous}`);
1341
1589
  */
1342
1590
  async previous() {
1591
+ if (this.playbackMode === types_1.PlaybackMode.FORWARD) {
1592
+ this.debug("[Player] Cannot previous while subscribed to another player");
1593
+ return false;
1594
+ }
1343
1595
  this.debug(`[Player] previous called`);
1344
1596
  const track = this.queue.previous();
1345
1597
  if (!track)
@@ -1392,6 +1644,7 @@ class Player extends events_1.EventEmitter {
1392
1644
  saveOptions = options;
1393
1645
  }
1394
1646
  try {
1647
+ await this.applyTrackMiddleware(track);
1395
1648
  // Skip extension manager for saving - we want the raw stream without filters/seek applied, and extensions may not support this
1396
1649
  let streamInfo = await this.pluginManager.getStream(track);
1397
1650
  if (!streamInfo || !streamInfo.stream) {
@@ -1468,6 +1721,12 @@ class Player extends events_1.EventEmitter {
1468
1721
  * console.log(`Auto-play mode: ${autoPlayMode}`);
1469
1722
  */
1470
1723
  autoPlay(mode) {
1724
+ if (this.playbackMode === types_1.PlaybackMode.FORWARD) {
1725
+ if (!mode)
1726
+ return this.forwardLeader?.autoPlay() ?? false;
1727
+ this.debug("[Player] Cannot autoPlay while subscribed to another player");
1728
+ return false;
1729
+ }
1471
1730
  return this.queue.autoPlay(mode);
1472
1731
  }
1473
1732
  /**
@@ -1480,11 +1739,20 @@ class Player extends events_1.EventEmitter {
1480
1739
  * console.log(`Volume set: ${volumeSet}`);
1481
1740
  */
1482
1741
  setVolume(volume) {
1742
+ if (this.playbackMode === types_1.PlaybackMode.FORWARD) {
1743
+ this.debug("[Player] Cannot setVolume while subscribed to another player");
1744
+ return false;
1745
+ }
1483
1746
  this.debug(`[Player] setVolume called: ${volume}`);
1484
1747
  if (volume < 0 || volume > 200)
1485
1748
  return false;
1486
1749
  const oldVolume = this.volume;
1487
1750
  this.volume = volume;
1751
+ if (this.playbackMode === types_1.PlaybackMode.REMOTE) {
1752
+ void this.remoteHandle?.setVolume(volume).catch((e) => this.debug("[Player] Remote volume:", e));
1753
+ this.emit("volumeChange", oldVolume, volume);
1754
+ return true;
1755
+ }
1488
1756
  const resourceVolume = this.currentResource?.volume;
1489
1757
  if (resourceVolume) {
1490
1758
  if (this.volumeInterval)
@@ -1504,6 +1772,12 @@ class Player extends events_1.EventEmitter {
1504
1772
  }, 300);
1505
1773
  }
1506
1774
  this.emit("volumeChange", oldVolume, volume);
1775
+ for (const fp of this.forwardFollowers) {
1776
+ try {
1777
+ fp.volume = volume;
1778
+ }
1779
+ catch { }
1780
+ }
1507
1781
  return true;
1508
1782
  }
1509
1783
  /**
@@ -1544,6 +1818,10 @@ class Player extends events_1.EventEmitter {
1544
1818
  */
1545
1819
  async insert(query, index, requestedBy) {
1546
1820
  try {
1821
+ if (this.playbackMode === types_1.PlaybackMode.FORWARD) {
1822
+ this.debug("[Player] Cannot insert while subscribed to another player");
1823
+ return false;
1824
+ }
1547
1825
  this.debug(`[Player] insert called at index ${index} with type: ${typeof query}`);
1548
1826
  let tracksToAdd = [];
1549
1827
  let isPlaylist = false;
@@ -1754,6 +2032,10 @@ class Player extends events_1.EventEmitter {
1754
2032
  if (this.destroyed)
1755
2033
  return;
1756
2034
  this.destroyed = true;
2035
+ if (this.remoteHandle?.destroy) {
2036
+ this.remoteHandle.destroy().catch(() => { });
2037
+ this.remoteHandle = undefined;
2038
+ }
1757
2039
  if (this.leaveTimeout) {
1758
2040
  clearTimeout(this.leaveTimeout);
1759
2041
  this.leaveTimeout = null;
@@ -1781,14 +2063,21 @@ class Player extends events_1.EventEmitter {
1781
2063
  this.pluginManager.clear();
1782
2064
  this.filter.destroy();
1783
2065
  this.extensionManager.destroy();
1784
- this.isPlaying = false;
1785
- this.isPaused = false;
1786
2066
  // Clear any remaining intervals
1787
2067
  if (this.volumeInterval) {
1788
2068
  clearInterval(this.volumeInterval);
1789
2069
  this.volumeInterval = null;
1790
2070
  }
1791
2071
  this.emit("playerDestroy");
2072
+ this.unsubscribeForward("Player destroy");
2073
+ // release followers
2074
+ for (const fp of [...this.forwardFollowers]) {
2075
+ try {
2076
+ fp.unsubscribeForward("Leader destroy");
2077
+ }
2078
+ catch { }
2079
+ }
2080
+ this.forwardFollowers.clear();
1792
2081
  this.removeAllListeners();
1793
2082
  }
1794
2083
  //#endregion
@@ -1860,13 +2149,7 @@ class Player extends events_1.EventEmitter {
1860
2149
  this.audioPlayer.play(resource);
1861
2150
  }
1862
2151
  // Restore playing state
1863
- if (wasPlaying && !wasPaused) {
1864
- this.isPlaying = true;
1865
- this.isPaused = false;
1866
- }
1867
- else if (wasPaused) {
1868
- this.isPlaying = false;
1869
- this.isPaused = true;
2152
+ if (wasPaused) {
1870
2153
  this.audioPlayer.pause();
1871
2154
  }
1872
2155
  this.debug(`[Player] Successfully applied filter to current track at position ${currentPosition}ms`);
@@ -1932,6 +2215,9 @@ class Player extends events_1.EventEmitter {
1932
2215
  if (track) {
1933
2216
  this.debug(`[Player] Track ended: ${track.title}`);
1934
2217
  this.emit("trackEnd", track);
2218
+ for (const fp of this.forwardFollowers) {
2219
+ fp.emit("trackEnd", track);
2220
+ }
1935
2221
  }
1936
2222
  void this.playNext();
1937
2223
  }
@@ -1939,30 +2225,41 @@ class Player extends events_1.EventEmitter {
1939
2225
  (oldState.status === voice_1.AudioPlayerStatus.Idle || oldState.status === voice_1.AudioPlayerStatus.Buffering)) {
1940
2226
  // Track started
1941
2227
  this.clearLeaveTimeout();
1942
- this.isPlaying = true;
1943
- this.isPaused = false;
1944
2228
  const track = this.queue.currentTrack;
1945
2229
  if (track) {
1946
2230
  this.debug(`[Player] Track started: ${track.title}`);
1947
2231
  this.emit("trackStart", track);
2232
+ for (const fp of this.forwardFollowers) {
2233
+ try {
2234
+ fp.queue.clear();
2235
+ fp.connection?.subscribe(this.audioPlayer);
2236
+ fp.queue.setCurrentTrack(track);
2237
+ fp.emit("trackStart", track);
2238
+ }
2239
+ catch (e) {
2240
+ this.debug(`[Player] Failed to sync follower ${fp.guildId}:`, e);
2241
+ }
2242
+ }
1948
2243
  }
1949
2244
  }
1950
2245
  else if (newState.status === voice_1.AudioPlayerStatus.Paused && oldState.status !== voice_1.AudioPlayerStatus.Paused) {
1951
- // Track paused
1952
- this.isPaused = true;
1953
2246
  const track = this.queue.currentTrack;
1954
2247
  if (track) {
1955
2248
  this.debug(`[Player] Player paused on track: ${track.title}`);
1956
2249
  this.emit("playerPause", track);
2250
+ for (const fp of this.forwardFollowers) {
2251
+ fp.emit("playerPause", track);
2252
+ }
1957
2253
  }
1958
2254
  }
1959
2255
  else if (newState.status !== voice_1.AudioPlayerStatus.Paused && oldState.status === voice_1.AudioPlayerStatus.Paused) {
1960
- // Track resumed
1961
- this.isPaused = false;
1962
2256
  const track = this.queue.currentTrack;
1963
2257
  if (track) {
1964
2258
  this.debug(`[Player] Player resumed on track: ${track.title}`);
1965
2259
  this.emit("playerResume", track);
2260
+ for (const fp of this.forwardFollowers) {
2261
+ fp.emit("playerResume", track);
2262
+ }
1966
2263
  }
1967
2264
  }
1968
2265
  else if (newState.status === voice_1.AudioPlayerStatus.AutoPaused) {
@@ -2056,6 +2353,25 @@ class Player extends events_1.EventEmitter {
2056
2353
  plugins: this.pluginManager.getAll().map((plugin) => plugin.name),
2057
2354
  };
2058
2355
  }
2356
+ exitRemoteMode() {
2357
+ if (this.playbackMode !== types_1.PlaybackMode.REMOTE)
2358
+ return;
2359
+ this.debug("[Player] Exiting REMOTE mode, restoring native playback");
2360
+ void this.remoteHandle?.destroy().catch(() => { });
2361
+ this.remoteHandle = undefined;
2362
+ this.playbackMode = types_1.PlaybackMode.NATIVE;
2363
+ this._remotePaused = false;
2364
+ // Restore preload/crossfade from original options
2365
+ const preloadOptions = this.options.preload ?? {};
2366
+ const autoDisable = preloadOptions.autoDisableInLowPerformance ?? true;
2367
+ this.preloadEnabled = (preloadOptions.enabled ?? true) && !(this.lowPerformanceMode && autoDisable);
2368
+ const crossfadeOptions = this.options.crossfade ?? {};
2369
+ const cfAutoDisable = crossfadeOptions.autoDisableInLowPerformance ?? true;
2370
+ this.crossfadeEnabled =
2371
+ typeof crossfadeOptions.enabled === "boolean" ? crossfadeOptions.enabled : (crossfadeOptions.autoEnable ?? true);
2372
+ if (this.lowPerformanceMode && cfAutoDisable)
2373
+ this.crossfadeEnabled = false;
2374
+ }
2059
2375
  /**
2060
2376
  * Get serializable state (for manual persistence)
2061
2377
  */
@@ -2109,6 +2425,59 @@ class Player extends events_1.EventEmitter {
2109
2425
  totalStreams: this.streamManager.getStreamCount(),
2110
2426
  };
2111
2427
  }
2428
+ getForwardHealthStatus() {
2429
+ const issues = [];
2430
+ const details = {};
2431
+ if (this.playbackMode === types_1.PlaybackMode.FORWARD && this.forwardLeader) {
2432
+ // This player is a follower
2433
+ details.leaderId = this.forwardLeader.guildId;
2434
+ details.connectionState = this.connection?.state.status;
2435
+ details.audioPlayerState = this.audioPlayer.state.status;
2436
+ if (this.forwardLeader.destroyed) {
2437
+ issues.push("Leader is destroyed");
2438
+ }
2439
+ if (!this.forwardLeader.connection) {
2440
+ issues.push("Leader has no connection");
2441
+ }
2442
+ if (this.forwardLeader.destroyed || !this.forwardLeader.connection) {
2443
+ issues.push("Leader is unavailable");
2444
+ }
2445
+ return {
2446
+ guildId: this.guildId,
2447
+ healthy: issues.length === 0,
2448
+ role: "follower",
2449
+ issues,
2450
+ details,
2451
+ };
2452
+ }
2453
+ else if (this.forwardFollowers.size > 0) {
2454
+ // This player is a leader
2455
+ details.followerCount = this.forwardFollowers.size;
2456
+ details.connectionState = this.connection?.state.status;
2457
+ const deadFollowers = [];
2458
+ for (const follower of this.forwardFollowers) {
2459
+ if (follower.destroyed)
2460
+ deadFollowers.push(follower.guildId);
2461
+ }
2462
+ if (deadFollowers.length > 0) {
2463
+ issues.push(`Has ${deadFollowers.length} dead followers: ${deadFollowers.join(", ")}`);
2464
+ }
2465
+ return {
2466
+ guildId: this.guildId,
2467
+ healthy: true, // Leader being healthy doesn't depend on followers
2468
+ role: "leader",
2469
+ issues,
2470
+ details,
2471
+ };
2472
+ }
2473
+ return {
2474
+ guildId: this.guildId,
2475
+ healthy: true,
2476
+ role: "none",
2477
+ issues: [],
2478
+ details: {},
2479
+ };
2480
+ }
2112
2481
  //#endregion
2113
2482
  //#region Getters
2114
2483
  /**
@@ -2189,8 +2558,45 @@ class Player extends events_1.EventEmitter {
2189
2558
  return this.queue.relatedTracks();
2190
2559
  }
2191
2560
  get isLive() {
2561
+ if (this.playbackMode === types_1.PlaybackMode.FORWARD)
2562
+ return true; //forward Mode -> live from Leader
2192
2563
  return this.currentTrack?.isLive === true;
2193
2564
  }
2565
+ get isPlaying() {
2566
+ if (this.playbackMode === types_1.PlaybackMode.FORWARD) {
2567
+ if (!this.forwardLeader || this.forwardLeader.destroyed) {
2568
+ this.unsubscribeForward("Leader destroyed");
2569
+ return false;
2570
+ }
2571
+ return this.forwardLeader.isPlaying;
2572
+ }
2573
+ if (this.playbackMode === types_1.PlaybackMode.REMOTE) {
2574
+ return !!this.queue.currentTrack; // driven by queue state, not audioPlayer
2575
+ }
2576
+ return (this.audioPlayer.state.status === voice_1.AudioPlayerStatus.Playing || this.audioPlayer.state.status === voice_1.AudioPlayerStatus.Buffering);
2577
+ }
2578
+ get isPaused() {
2579
+ if (this.playbackMode === types_1.PlaybackMode.FORWARD) {
2580
+ return this.forwardLeader?.isPaused ?? false;
2581
+ }
2582
+ if (this.playbackMode === types_1.PlaybackMode.REMOTE) {
2583
+ // Extension tracks pause state via handle; Player exposes a flag
2584
+ return this._remotePaused;
2585
+ }
2586
+ return this.audioPlayer.state.status === voice_1.AudioPlayerStatus.Paused;
2587
+ }
2588
+ get isIdle() {
2589
+ if (this.playbackMode === types_1.PlaybackMode.FORWARD) {
2590
+ return this.forwardLeader?.isIdle ?? false;
2591
+ }
2592
+ return this.audioPlayer.state.status === voice_1.AudioPlayerStatus.Idle;
2593
+ }
2594
+ get isBuffering() {
2595
+ if (this.playbackMode === types_1.PlaybackMode.FORWARD) {
2596
+ return this.forwardLeader?.isBuffering ?? false;
2597
+ }
2598
+ return this.audioPlayer.state.status === voice_1.AudioPlayerStatus.Buffering;
2599
+ }
2194
2600
  }
2195
2601
  exports.Player = Player;
2196
2602
  //# sourceMappingURL=Player.js.map