magmastream
Version:
A user-friendly Lavalink client designed for NodeJS.
1,182 lines (1,181 loc) • 49.7 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Player = void 0;
const tslib_1 = require("tslib");
const Filters_1 = require("./Filters");
const MemoryQueue_1 = require("../statestorage/MemoryQueue");
const Utils_1 = require("./Utils");
const _ = tslib_1.__importStar(require("lodash"));
const playerCheck_1 = tslib_1.__importDefault(require("../utils/playerCheck"));
const RedisQueue_1 = require("../statestorage/RedisQueue");
const Enums_1 = require("./Enums");
const ws_1 = require("ws");
const JsonQueue_1 = require("../statestorage/JsonQueue");
const MagmastreamError_1 = require("./MagmastreamError");
class Player {
options;
/** The Queue for the Player. */
queue;
/** The filters applied to the audio. */
filters;
/** Whether the queue repeats the track. */
trackRepeat = false;
/** Whether the queue repeats the queue. */
queueRepeat = false;
/**Whether the queue repeats and shuffles after each song. */
dynamicRepeat = false;
/** The time the player is in the track. */
position = 0;
/** Whether the player is playing. */
playing = false;
/** Whether the player is paused. */
paused = false;
/** The volume for the player */
volume = 100;
/** The Node for the Player. */
node;
/** The guild ID for the player. */
guildId;
/** The voice channel for the player. */
voiceChannelId = null;
/** The text channel for the player. */
textChannelId = null;
/**The now playing message. */
nowPlayingMessage;
/** The current state of the player. */
state = Enums_1.StateTypes.Disconnected;
/** The equalizer bands array. */
bands = new Array(15).fill(0.0);
/** The voice state object from the node. */
voiceState;
/** The Manager. */
manager;
/** The autoplay state of the player. */
isAutoplay = false;
/** The number of times to try autoplay before emitting queueEnd. */
autoplayTries = 3;
/** The cluster ID for the player. */
clusterId = 0;
data = {};
dynamicLoopInterval = null;
dynamicRepeatIntervalMs = null;
static _manager;
/** Should only be used when the node is a NodeLink */
voiceReceiverWsClient;
isConnectToVoiceReceiver;
voiceReceiverReconnectTimeout;
voiceReceiverAttempt;
voiceReceiverReconnectTries;
/**
* Creates a new player, returns one if it already exists.
* @param options The player options.
* @see https://docs.magmastream.com/main/introduction/getting-started
*/
constructor(options) {
this.options = options;
// If the Manager is not initiated, throw an error.
if (!this.manager)
this.manager = Utils_1.Structure.get("Player")._manager;
if (!this.manager) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.GENERAL_INVALID_MANAGER,
message: "Manager instance is required.",
});
}
this.clusterId = this.manager.options.clusterId ?? 0;
// Check the player options for errors.
(0, playerCheck_1.default)(options);
this.options = {
...options,
applyVolumeAsFilter: options.applyVolumeAsFilter ?? false,
selfMute: options.selfMute ?? false,
selfDeafen: options.selfDeafen ?? false,
volume: options.volume ?? 100,
pauseOnDisconnect: options.pauseOnDisconnect ?? true,
};
// Set the guild ID and voice state.
this.guildId = options.guildId;
this.voiceState = {};
// Set the voice and text channels if they exist.
if (options.voiceChannelId)
this.voiceChannelId = options.voiceChannelId;
if (options.textChannelId)
this.textChannelId = options.textChannelId;
// Set the node to use, either the specified node or the first available node.
const node = this.manager.nodes.get(options.nodeIdentifier);
this.node = node || this.manager.useableNode;
// If no node is available, throw an error.
if (!this.node) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.MANAGER_NO_NODES,
message: "No available nodes for the player found.",
context: { guildId: this.guildId },
});
}
// Initialize the queue with the guild ID and manager.
switch (this.manager.options.stateStorage.type) {
case Enums_1.StateStorageType.Redis:
this.queue = new RedisQueue_1.RedisQueue(this.guildId, this.manager);
break;
case Enums_1.StateStorageType.Memory:
this.queue = new MemoryQueue_1.MemoryQueue(this.guildId, this.manager);
break;
case Enums_1.StateStorageType.JSON:
this.queue = new JsonQueue_1.JsonQueue(this.guildId, this.manager);
break;
}
// Add the player to the manager's player collection.
this.manager.players.set(options.guildId, this);
// Set the initial volume.
this.setVolume(options.volume);
// Initialize the filters.
this.filters = new Filters_1.Filters(this, this.manager);
// Emit the playerCreate event.
this.manager.emit(Enums_1.ManagerEventTypes.PlayerCreate, this);
}
/**
* Initializes the static properties of the Player class.
* @hidden
* @param manager The Manager to use.
*/
static init(manager) {
// Set the Manager to use.
this._manager = manager;
}
/**
* Set custom data.
* @param key - The key to set the data for.
* @param value - The value to set the data to.
*/
set(key, value) {
// Store the data in the data object using the key.
this.data[key] = value;
}
/**
* Retrieves custom data associated with a given key.
* @template T - The expected type of the data.
* @param {string} key - The key to retrieve the data for.
* @returns {T} - The data associated with the key, cast to the specified type.
*/
get(key) {
// Access the data object using the key and cast it to the specified type T.
return this.data[key];
}
/**
* Same as Manager#search() but a shortcut on the player itself.
* @param query
* @param requester
*/
async search(query, requester) {
return await this.manager.search(query, requester);
}
/**
* Connects the player to the voice channel.
* @throws {RangeError} If no voice channel has been set.
* @returns {void}
*/
connect() {
// Check if the voice channel has been set.
if (!this.voiceChannelId) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLAYER_INVALID_CONFIG,
message: "No voice channel has been set. You must set the voice channel before connecting.",
context: { voiceChannelId: this.voiceChannelId },
});
}
// Set the player state to connecting.
this.state = Enums_1.StateTypes.Connecting;
// Clone the current player state for comparison.
const oldPlayer = this ? { ...this } : null;
this.manager.sendPacket({
op: 4,
d: {
guild_id: this.guildId,
channel_id: this.voiceChannelId,
self_mute: this.options.selfMute || false,
self_deaf: this.options.selfDeafen || false,
},
});
// Set the player state to connected.
this.state = Enums_1.StateTypes.Connected;
// Emit the player state update event.
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, {
changeType: Enums_1.PlayerStateEventTypes.ConnectionChange,
details: {
type: "connection",
action: "connect",
previousConnection: oldPlayer?.state === Enums_1.StateTypes.Connected,
currentConnection: true,
},
});
}
/**
* Disconnects the player from the voice channel.
* @returns {this} The player instance.
*/
async disconnect() {
// Set the player state to disconnecting.
this.state = Enums_1.StateTypes.Disconnecting;
// Clone the current player state for comparison.
const oldPlayer = this ? { ...this } : null;
// Pause the player.
if (this.options.pauseOnDisconnect) {
await this.pause(true);
}
// Send the voice state update to the gateway.
this.manager.sendPacket({
op: 4,
d: {
guild_id: this.guildId,
channel_id: null,
self_mute: null,
self_deaf: null,
},
});
// Set the player voice channel to null.
this.voiceChannelId = null;
// Set the player state to disconnected.
this.state = Enums_1.StateTypes.Disconnected;
// Emit the player state update event.
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, {
changeType: Enums_1.PlayerStateEventTypes.ConnectionChange,
details: {
type: "connection",
action: "disconnect",
previousConnection: oldPlayer.state === Enums_1.StateTypes.Connected,
currentConnection: false,
},
});
return this;
}
/**
* Destroys the player and clears the queue.
* @param {boolean} disconnect - Whether to disconnect the player from the voice channel.
* @returns {Promise<boolean>} - Whether the player was successfully destroyed.
* @emits {PlayerDestroy} - Emitted when the player is destroyed.
* @emits {PlayerStateUpdate} - Emitted when the player state is updated.
*/
async destroy(disconnect = true) {
this.state = Enums_1.StateTypes.Destroying;
if (this.dynamicLoopInterval) {
clearInterval(this.dynamicLoopInterval);
this.dynamicLoopInterval = null;
}
if (this.voiceReceiverReconnectTimeout) {
clearTimeout(this.voiceReceiverReconnectTimeout);
this.voiceReceiverReconnectTimeout = null;
}
if (this.voiceReceiverWsClient) {
this.voiceReceiverWsClient.removeAllListeners();
this.voiceReceiverWsClient.close();
this.voiceReceiverWsClient = null;
}
if (disconnect) {
await this.disconnect().catch(() => { });
}
await this.node.rest.destroyPlayer(this.guildId).catch(() => { });
await this.queue.destroy();
this.nowPlayingMessage = undefined;
this.manager.emit(Enums_1.ManagerEventTypes.PlayerDestroy, this);
const deleted = this.manager.players.delete(this.guildId);
if (this.manager.options.stateStorage.deleteDestroyedPlayers) {
await this.manager.cleanupInactivePlayer(this.guildId);
}
return deleted;
}
/**
* Sets the player voice channel.
* @param {string} channel - The new voice channel ID.
* @returns {this} - The player instance.
* @throws {TypeError} If the channel parameter is not a string.
*/
setVoiceChannelId(channel) {
// Validate the channel parameter
if (typeof channel !== "string") {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLAYER_INVALID_CONFIG,
message: "Channel must be a non-empty string.",
});
}
// Clone the current player state for comparison
const oldPlayer = this ? { ...this } : null;
// Update the player voice channel
this.voiceChannelId = channel;
this.options.voiceChannelId = channel;
this.connect();
// Emit a player state update event
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, {
changeType: Enums_1.PlayerStateEventTypes.ChannelChange,
details: {
type: "channel",
action: "voice",
previousChannel: oldPlayer.voiceChannelId || null,
currentChannel: this.voiceChannelId,
},
});
return this;
}
/**
* Sets the player text channel.
*
* This method updates the text channel associated with the player. It also
* emits a player state update event indicating the change in the channel.
*
* @param {string} channel - The new text channel ID.
* @returns {this} - The player instance for method chaining.
* @throws {TypeError} If the channel parameter is not a string.
*/
setTextChannelId(channel) {
// Validate the channel parameter
if (typeof channel !== "string") {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLAYER_INVALID_CONFIG,
message: "Channel must be a non-empty string.",
});
}
// Clone the current player state for comparison
const oldPlayer = this ? { ...this } : null;
// Update the text channel property
this.textChannelId = channel;
this.options.textChannelId = channel;
// Emit a player state update event with channel change details
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, {
changeType: Enums_1.PlayerStateEventTypes.ChannelChange,
details: {
type: "channel",
action: "text",
previousChannel: oldPlayer.textChannelId || null,
currentChannel: this.textChannelId,
},
});
// Return the player instance for chaining
return this;
}
/**
* Sets the now playing message.
*
* @param message - The message of the now playing message.
* @returns The now playing message.
*/
setNowPlayingMessage(message) {
if (!message) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLAYER_INVALID_NOW_PLAYING_MESSAGE,
message: "You must provide the message of the now playing message.",
});
}
this.set("nowPlayingMessage", message);
this.nowPlayingMessage = message;
return this.nowPlayingMessage;
}
async play(optionsOrTrack, playOptions) {
if (typeof optionsOrTrack !== "undefined" && Utils_1.TrackUtils.validate(optionsOrTrack)) {
await this.queue.setCurrent(optionsOrTrack);
}
const currentTrack = await this.queue.getCurrent();
if (!currentTrack) {
const error = new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLAYER_QUEUE_EMPTY,
message: "The queue is empty.",
});
console.error(error);
return this;
}
const isPlayOptions = (v) => typeof v === "object" && v !== null && ("startTime" in v || "endTime" in v || "noReplace" in v);
const finalOptions = playOptions ? playOptions : isPlayOptions(optionsOrTrack) ? optionsOrTrack : {};
await this.node.rest.updatePlayer({
guildId: this.guildId,
noReplace: finalOptions.noReplace,
data: {
track: {
encoded: currentTrack.track,
},
...(typeof finalOptions.startTime === "number" && { position: finalOptions.startTime }),
...(typeof finalOptions.endTime === "number" && finalOptions.endTime > 0 && { endTime: finalOptions.endTime }),
},
});
this.playing = true;
this.position = finalOptions.startTime ?? 0;
return this;
}
/**
* Sets the autoplay-state of the player.
*
* Autoplay is a feature that makes the player play a recommended
* track when the current track ends.
*
* @param {boolean} autoplayState - Whether or not autoplay should be enabled.
* @param {object} AutoplayUser - The user-object that should be used as the bot-user.
* @param {number} [tries=3] - The number of times the player should try to find a
* recommended track if the first one doesn't work.
* @returns {this} - The player instance.
*/
setAutoplay(autoplayState, AutoplayUser, tries) {
if (typeof autoplayState !== "boolean") {
const error = new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLAYER_INVALID_AUTOPLAY,
message: "autoplayState must be a boolean.",
});
console.error(error);
return this;
}
if (autoplayState) {
if (!AutoplayUser) {
const error = new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLAYER_INVALID_AUTOPLAY,
message: "AutoplayUser must be provided when enabling autoplay.",
});
console.error(error);
return this;
}
this.autoplayTries = tries && typeof tries === "number" && tries > 0 ? tries : 3; // Default to 3 if invalid
this.isAutoplay = true;
this.set("Internal_AutoplayUser", AutoplayUser);
}
else {
this.isAutoplay = false;
this.autoplayTries = null;
this.set("Internal_AutoplayUser", null);
}
const oldPlayer = { ...this };
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, {
changeType: Enums_1.PlayerStateEventTypes.AutoPlayChange,
details: {
type: "autoplay",
action: "toggle",
previousAutoplay: oldPlayer.isAutoplay,
currentAutoplay: this.isAutoplay,
},
});
return this;
}
/**
* Gets recommended tracks and returns an array of tracks.
* @param {Track} track - The track to find recommendations for.
* @returns {Promise<Track[]>} - Array of recommended tracks.
*/
async getRecommendedTracks(track) {
const tracks = await Utils_1.AutoPlayUtils.getRecommendedTracks(track);
return tracks;
}
/**
* Sets the volume of the player.
* @param {number} volume - The new volume. Must be between 0 and 500 when using filter mode (100 = 100%).
* @returns {Promise<Player>} - The updated player.
* @throws {TypeError} If the volume is not a number.
* @throws {RangeError} If the volume is not between 0 and 500 when using filter mode (100 = 100%).
* @emits {PlayerStateUpdate} - Emitted when the volume is changed.
* @example
* player.setVolume(50);
*/
async setVolume(volume) {
if (isNaN(volume)) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLAYER_INVALID_VOLUME,
message: "Volume must be a number.",
});
}
if (this.options.applyVolumeAsFilter) {
if (volume < 0 || volume > 500) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLAYER_INVALID_VOLUME,
message: "Volume must be between 0 and 500 when using filter mode (100 = 100%).",
});
}
}
else {
if (volume < 0 || volume > 1000) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLAYER_INVALID_VOLUME,
message: "Volume must be between 0 and 1000.",
});
}
}
const oldVolume = this.volume;
const oldPlayer = { ...this };
const data = this.options.applyVolumeAsFilter ? { filters: { volume: volume / 100 } } : { volume };
await this.node.rest.updatePlayer({
guildId: this.options.guildId,
data,
});
this.volume = volume;
this.options.volume = volume;
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, {
changeType: Enums_1.PlayerStateEventTypes.VolumeChange,
details: {
type: "volume",
action: "adjust",
previousVolume: oldVolume,
currentVolume: this.volume,
},
});
return this;
}
/**
* Sets the sponsorblock for the player. This will set the sponsorblock segments for the player to the given segments.
* @param {SponsorBlockSegment[]} segments - The sponsorblock segments to set. Defaults to `[SponsorBlockSegment.Sponsor, SponsorBlockSegment.SelfPromo]` if not provided.
* @returns {Promise<void>} The promise is resolved when the operation is complete.
*/
async setSponsorBlock(segments = [Enums_1.SponsorBlockSegment.Sponsor, Enums_1.SponsorBlockSegment.SelfPromo]) {
return this.node.setSponsorBlock(this, segments);
}
/**
* Gets the sponsorblock for the player.
* @returns {Promise<SponsorBlockSegment[]>} The sponsorblock segments.
*/
async getSponsorBlock() {
return this.node.getSponsorBlock(this);
}
/**
* Deletes the sponsorblock for the player. This will remove all sponsorblock segments that have been set for the player.
* @returns {Promise<void>}
*/
async deleteSponsorBlock() {
return this.node.deleteSponsorBlock(this);
}
/**
* Sets the track repeat mode.
* When track repeat is enabled, the current track will replay after it ends.
* Disables queueRepeat and dynamicRepeat modes if enabled.
*
* @param repeat - A boolean indicating whether to enable track repeat.
* @returns {this} - The player instance.
* @throws {TypeError} If the repeat parameter is not a boolean.
*/
setTrackRepeat(repeat) {
// Ensure the repeat parameter is a boolean
if (typeof repeat !== "boolean") {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLAYER_INVALID_REPEAT,
message: "Repeat must be a boolean.",
});
}
// Clone the current player state for event emission
const oldPlayer = this ? { ...this } : null;
if (repeat) {
// Enable track repeat and disable other repeat modes
this.trackRepeat = true;
this.queueRepeat = false;
this.dynamicRepeat = false;
}
else {
// Disable all repeat modes
this.trackRepeat = false;
this.queueRepeat = false;
this.dynamicRepeat = false;
}
// Emit an event indicating the repeat mode has changed
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, {
changeType: Enums_1.PlayerStateEventTypes.RepeatChange,
details: {
type: "repeat",
action: "track",
previousRepeat: this.getRepeatState(oldPlayer),
currentRepeat: this.getRepeatState(this),
},
});
return this;
}
/**
* Sets the queue repeat.
* @param repeat Whether to repeat the queue or not
* @returns {this} - The player instance.
* @throws {TypeError} If the repeat parameter is not a boolean
*/
setQueueRepeat(repeat) {
// Ensure the repeat parameter is a boolean
if (typeof repeat !== "boolean") {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLAYER_INVALID_REPEAT,
message: "Repeat must be a boolean.",
});
}
// Get the current player state
const oldPlayer = this ? { ...this } : null;
// Update the player state
if (repeat) {
this.trackRepeat = false;
this.queueRepeat = true;
this.dynamicRepeat = false;
}
else {
this.trackRepeat = false;
this.queueRepeat = false;
this.dynamicRepeat = false;
}
// Emit the player state update event
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, {
changeType: Enums_1.PlayerStateEventTypes.RepeatChange,
details: {
type: "repeat",
action: "queue",
previousRepeat: this.getRepeatState(oldPlayer),
currentRepeat: this.getRepeatState(this),
},
});
return this;
}
/**
* Sets the queue to repeat and shuffles the queue after each song.
* @param repeat "true" or "false".
* @param ms After how many milliseconds to trigger dynamic repeat.
* @returns {this} - The player instance.
* @throws {TypeError} If the repeat parameter is not a boolean.
* @throws {RangeError} If the queue size is less than or equal to 1.
*/
async setDynamicRepeat(repeat, ms) {
// Validate the repeat parameter
if (typeof repeat !== "boolean") {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLAYER_INVALID_REPEAT,
message: "Repeat must be a boolean.",
});
}
// Ensure the queue has more than one track for dynamic repeat
if ((await this.queue.size()) <= 1) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLAYER_INVALID_REPEAT,
message: "The queue size must be greater than 1.",
});
}
// Clone the current player state for comparison
const oldPlayer = this ? { ...this } : null;
if (repeat) {
// Disable other repeat modes when dynamic repeat is enabled
this.trackRepeat = false;
this.queueRepeat = false;
this.dynamicRepeat = true;
// Set an interval to shuffle the queue periodically
this.dynamicLoopInterval = setInterval(async () => {
if (!this.dynamicRepeat)
return;
// Shuffle the queue and replace it with the shuffled tracks
const tracks = await this.queue.getTracks();
const shuffled = _.shuffle(tracks);
await this.queue.clear();
await this.queue.add(shuffled);
}, ms);
// Store the ms value
this.dynamicRepeatIntervalMs = ms;
}
else {
// Clear the interval and reset repeat states
clearInterval(this.dynamicLoopInterval);
this.dynamicRepeatIntervalMs = null;
this.trackRepeat = false;
this.queueRepeat = false;
this.dynamicRepeat = false;
}
// Emit a player state update event
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, {
changeType: Enums_1.PlayerStateEventTypes.RepeatChange,
details: {
type: "repeat",
action: "dynamic",
previousRepeat: this.getRepeatState(oldPlayer),
currentRepeat: this.getRepeatState(this),
},
});
return this;
}
/**
* Restarts the currently playing track from the beginning.
* If there is no track playing, it will play the next track in the queue.
* @returns {Promise<Player>} The current instance of the Player class for method chaining.
*/
async restart() {
// Check if there is a current track in the queue
const currentTrack = await this.queue.getCurrent();
if (!currentTrack?.track) {
// If the queue has tracks, play the next one
if (await this.queue.size())
await this.play();
return this;
}
// Reset the track's position to the start
await this.node.rest.updatePlayer({
guildId: this.guildId,
data: {
position: 0,
track: {
encoded: currentTrack.track,
userData: currentTrack.requester,
},
},
});
return this;
}
/**
* Stops the player and optionally removes tracks from the queue.
* @param {number} [amount] The amount of tracks to remove from the queue. If not provided, removes the current track if it exists.
* @returns {Promise<this>} - The player instance.
* @throws {RangeError} If the amount is greater than the queue length.
*/
async stop(amount) {
const oldPlayer = { ...this };
let removedTracks = [];
const current = await this.queue.getCurrent(); // may be null
if (typeof amount === "number" && amount > 1) {
const queueSize = await this.queue.size();
if (amount > queueSize) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLAYER_QUEUE_EMPTY,
message: "The amount of tracks to remove is greater than the queue size.",
});
}
removedTracks = await this.queue.getSlice(0, amount - 1);
await this.queue.modifyAt(0, amount - 1);
const toAdd = [];
if (current)
toAdd.push(current);
toAdd.push(...removedTracks);
await this.queue.addPrevious(toAdd);
}
// This will trigger trackEnd for the current track; since we already added current,
// addPrevious will ignore duplicates.
this.node.rest.updatePlayer({
guildId: this.guildId,
data: {
track: {
encoded: null,
},
},
});
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, {
changeType: Enums_1.PlayerStateEventTypes.QueueChange,
details: {
type: "queue",
action: "remove",
tracks: removedTracks,
},
});
return this;
}
/**
* Skips the current track.
* @returns {this} - The player instance.
* @throws {Error} If there are no tracks in the queue.
* @emits {PlayerStateUpdate} - With {@link PlayerStateEventTypes.TrackChange} as the change type.
*/
async pause(pause) {
// Validate the pause parameter to ensure it's a boolean.
if (typeof pause !== "boolean") {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLAYER_INVALID_PAUSE,
message: "Pause must be a boolean.",
});
}
// If the pause state is already as desired or there are no tracks, return early.
if (this.paused === pause || !this.queue.totalSize)
return this;
// Create a copy of the current player state for event emission.
const oldPlayer = this ? { ...this } : null;
// Update the playing and paused states.
this.playing = !pause;
this.paused = pause;
// Send an update to the backend to change the pause state of the player.
await this.node.rest.updatePlayer({
guildId: this.guildId,
data: {
paused: pause,
},
});
// Emit an event indicating the pause state has changed.
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, {
changeType: Enums_1.PlayerStateEventTypes.PauseChange,
details: {
type: "pause",
action: "pause",
previousPause: oldPlayer.paused,
currentPause: this.paused,
},
});
return this;
}
/**
* Skips to the previous track in the queue.
* @returns {this} - The player instance.
* @throws {Error} If there are no previous tracks in the queue.
* @emits {PlayerStateUpdate} - With {@link PlayerStateEventTypes.TrackChange} as the change type.
*/
async previous(addBackToQueue = true) {
// Pop the most recent previous track (from tail)
const lastTrack = await this.queue.popPrevious();
if (!lastTrack) {
await this.queue.clearPrevious();
const error = new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLAYER_PREVIOUS_EMPTY,
message: "Previous queue is empty.",
});
console.error(error);
return this;
}
// Capture the current state of the player before making changes.
const oldPlayer = { ...this };
// Prevent re-adding the current track
this.set("skipFlag", true);
// Add the current track to the queue if addBackToQueue is true
if (addBackToQueue) {
const currentPlayingTrack = await this.queue.getCurrent();
if (currentPlayingTrack) {
await this.queue.add(currentPlayingTrack, 0);
}
}
await this.play(lastTrack);
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, {
changeType: Enums_1.PlayerStateEventTypes.TrackChange,
details: {
type: "track",
action: "previous",
track: lastTrack,
},
});
return this;
}
/**
* Seeks to a given position in the currently playing track.
* @param position - The position in milliseconds to seek to.
* @returns {this} - The player instance.
* @throws {Error} If the position is invalid.
* @emits {PlayerStateUpdate} - With {@link PlayerStateEventTypes.TrackChange} as the change type.
*/
async seek(position) {
const currentTrack = await this.queue.getCurrent();
if (!currentTrack)
return undefined;
position = Number(position);
// Check if the position is valid.
if (isNaN(position)) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLAYER_INVALID_SEEK,
message: "Position must be a number.",
});
}
// Get the old player state.
const oldPlayer = this ? { ...this } : null;
// Clamp the position to ensure it is within the valid range.
if (position < 0 || position > currentTrack.duration) {
position = Math.max(Math.min(position, currentTrack.duration), 0);
}
// Update the player's position.
this.position = position;
// Send the seek request to the node.
await this.node.rest.updatePlayer({
guildId: this.guildId,
data: {
position: position,
},
});
// Emit an event to notify the manager of the track change.
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, {
changeType: Enums_1.PlayerStateEventTypes.TrackChange,
details: {
type: "track",
action: "timeUpdate",
previousTime: oldPlayer.position,
currentTime: this.position,
},
});
return this;
}
/**
* Returns the current repeat state of the player.
* @param player The player to get the repeat state from.
* @returns The repeat state of the player, or null if it is not repeating.
*/
getRepeatState(player) {
// If the queue is repeating, return the queue repeat state.
if (player.queueRepeat)
return "queue";
// If the track is repeating, return the track repeat state.
if (player.trackRepeat)
return "track";
// If the dynamic repeat is enabled, return the dynamic repeat state.
if (player.dynamicRepeat)
return "dynamic";
// If none of the above conditions are met, return null.
return null;
}
/**
* Automatically moves the player to a usable node.
* @returns {Promise<Player | void>} - The player instance or void if not moved.
*/
async autoMoveNode() {
// Get a usable node from the manager
const node = this.manager.useableNode;
// Move the player to the usable node and return the result
return await this.moveNode(node.options.identifier);
}
/**
* Moves the player to another node.
* @param {string} identifier - The identifier of the node to move to.
* @returns {Promise<Player>} - The player instance after being moved.
*/
async moveNode(identifier) {
const node = this.manager.nodes.get(identifier);
if (!node) {
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Tried to move to non-existent node: ${identifier}`);
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.MANAGER_NODE_NOT_FOUND,
message: "Node not found.",
context: { identifier },
});
}
if (this.state !== Enums_1.StateTypes.Connected) {
return this;
}
if (node.options.identifier === this.node.options.identifier) {
return this;
}
try {
const playerPosition = this.position;
const currentTrack = await this.queue.getCurrent();
// Safely get voice state properties with null checks
const sessionId = this.voiceState?.sessionId;
const token = this.voiceState?.event?.token;
const endpoint = this.voiceState?.event?.endpoint;
const channelId = this.voiceState?.channelId;
if (!sessionId || !token || !endpoint || !channelId) {
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Voice state is not properly initialized for player ${this.guildId}. The bot might not be connected to a voice channel.`);
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLAYER_STATE_INVALID,
message: `Voice state is not properly initialized. The bot might not be connected to a voice channel.`,
context: { guildId: this.guildId },
});
}
await this.node.rest.destroyPlayer(this.guildId).catch(() => { });
this.manager.players.delete(this.guildId);
this.node = node;
this.manager.players.set(this.guildId, this);
await this.node.rest.updatePlayer({
guildId: this.guildId,
data: {
paused: this.paused,
volume: this.volume,
position: playerPosition,
track: { encoded: currentTrack?.track },
voice: { token, endpoint, sessionId, channelId },
},
});
await this.filters.updateFilters();
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLAYER_MOVE_FAILED,
message: "Error moving player to node.",
cause: err,
context: { guildId: this.guildId },
});
this.manager.emit(Enums_1.ManagerEventTypes.Debug, error);
console.error(error);
}
}
/**
* Retrieves the data associated with the player.
* @returns {Record<string, unknown>} - The data associated with the player.
*/
getData() {
return this.data;
}
/**
* Retrieves the dynamic loop interval of the player.
* @returns {NodeJS.Timeout | null} - The dynamic loop interval of the player.
*/
getDynamicLoopIntervalPublic() {
return this.dynamicLoopInterval;
}
/**
* Retrieves the data associated with the player.
* @returns {Record<string, unknown>} - The data associated with the player.
*/
getSerializableData() {
return { ...this.data };
}
/**
* Retrieves the current lyrics for the playing track.
* @param skipTrackSource - Indicates whether to skip the track source when fetching lyrics.
* @returns {Promise<Lyrics>} - The lyrics of the current track.
*/
async getCurrentLyrics(skipTrackSource = false) {
// Fetch the lyrics for the current track from the Lavalink node
let result = (await this.node.getLyrics(await this.queue.getCurrent(), skipTrackSource));
// If no lyrics are found, return a default empty lyrics object
if (!result) {
result = {
source: null,
provider: null,
text: null,
lines: [],
plugin: [],
};
}
return result;
}
/**
* Sets up the voice receiver for the player.
* @returns {Promise<void>} - A promise that resolves when the voice receiver is set up.
* @throws {Error} - If the node is not a NodeLink.
*/
async setupVoiceReceiver() {
if (!this.node.isNodeLink) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.NODE_PROTOCOL_ERROR,
message: `The node is not a NodeLink, cannot setup voice receiver.`,
context: { identifier: this.node.options.identifier },
});
}
if (this.voiceReceiverWsClient)
await this.removeVoiceReceiver();
const headers = {
Authorization: this.node.options.password,
"User-Id": this.manager.options.clientId,
"Guild-Id": this.guildId,
"Client-Name": this.manager.options.clientName,
};
const { host, useSSL, port } = this.node.options;
this.voiceReceiverWsClient = new ws_1.WebSocket(`${useSSL ? "wss" : "ws"}://${host}:${port}/connection/data`, { headers });
this.voiceReceiverWsClient.on("open", () => this.openVoiceReceiver());
this.voiceReceiverWsClient.on("error", (err) => this.onVoiceReceiverError(err));
this.voiceReceiverWsClient.on("message", (data) => this.onVoiceReceiverMessage(data.toString()));
this.voiceReceiverWsClient.on("close", (code, reason) => this.closeVoiceReceiver(code, reason.toString()));
}
/**
* Removes the voice receiver for the player.
* @returns {Promise<void>} - A promise that resolves when the voice receiver is removed.
* @throws {Error} - If the node is not a NodeLink.
*/
async removeVoiceReceiver() {
if (!this.node.isNodeLink) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.NODE_PROTOCOL_ERROR,
message: `The node is not a NodeLink, cannot remove voice receiver.`,
context: { identifier: this.node.options.identifier },
});
}
if (this.voiceReceiverWsClient) {
this.voiceReceiverWsClient.close(1000, "destroy");
this.voiceReceiverWsClient.removeAllListeners();
this.voiceReceiverWsClient = null;
}
this.isConnectToVoiceReceiver = false;
}
/**
* Closes the voice receiver for the player.
* @param {number} code - The code to close the voice receiver with.
* @param {string} reason - The reason to close the voice receiver with.
* @returns {Promise<void>} - A promise that resolves when the voice receiver is closed.
*/
async closeVoiceReceiver(code, reason) {
await this.disconnectVoiceReceiver();
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[PLAYER] Closed voice receiver for player ${this.guildId} with code ${code} and reason ${reason}`);
if (code !== 1000)
await this.reconnectVoiceReceiver();
}
/**
* Reconnects the voice receiver for the player.
* @returns {Promise<void>} - A promise that resolves when the voice receiver is reconnected.
*/
async reconnectVoiceReceiver() {
this.voiceReceiverReconnectTimeout = setTimeout(async () => {
if (this.voiceReceiverAttempt > this.voiceReceiverReconnectTries) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.PLAYER_VOICE_RECEIVER_ERROR,
message: `Failed to reconnect to voice receiver for player ${this.guildId}`,
context: { identifier: this.node.options.identifier },
});
}
this.voiceReceiverWsClient?.removeAllListeners();
this.voiceReceiverWsClient = null;
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[PLAYER] Reconnecting to voice receiver for player ${this.guildId}`);
await this.setupVoiceReceiver();
this.voiceReceiverAttempt++;
}, this.node.options.retryDelayMs);
}
/**
* Disconnects the voice receiver for the player.
* @returns {Promise<void>} - A promise that resolves when the voice receiver is disconnected.
*/
async disconnectVoiceReceiver() {
if (!this.isConnectToVoiceReceiver)
return;
this.voiceReceiverWsClient?.close(1000, "destroy");
this.voiceReceiverWsClient?.removeAllListeners();
this.voiceReceiverWsClient = null;
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[PLAYER] Disconnected from voice receiver for player ${this.guildId}`);
this.manager.emit(Enums_1.ManagerEventTypes.VoiceReceiverDisconnect, this);
}
/**
* Opens the voice receiver for the player.
* @returns {Promise<void>} - A promise that resolves when the voice receiver is opened.
*/
async openVoiceReceiver() {
if (this.voiceReceiverReconnectTimeout)
clearTimeout(this.voiceReceiverReconnectTimeout);
this.voiceReceiverReconnectTimeout = null;
this.isConnectToVoiceReceiver = true;
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[PLAYER] Opened voice receiver for player ${this.guildId}`);
this.manager.emit(Enums_1.ManagerEventTypes.VoiceReceiverConnect, this);
}
/**
* Handles a voice receiver message.
* @param {string} payload - The payload to handle.
* @returns {Promise<void>} - A promise that resolves when the voice receiver message is handled.
*/
async onVoiceReceiverMessage(payload) {
const packet = JSON.parse(payload);
if (!packet?.op)
return;
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `VoiceReceiver recieved a payload: ${Utils_1.JSONUtils.safe(payload, 2)}`);
switch (packet.type) {
case "startSpeakingEvent": {
this.manager.emit(Enums_1.ManagerEventTypes.VoiceReceiverStartSpeaking, this, packet.data);
break;
}
case "endSpeakingEvent": {
const data = {
...packet.data,
data: Buffer.from(packet.data.data, "base64"),
};
this.manager.emit(Enums_1.ManagerEventTypes.VoiceReceiverEndSpeaking, this, data);
break;
}
default: {
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `VoiceReceiver recieved an unknown payload: ${Utils_1.JSONUtils.safe(payload, 2)}`);
break;
}
}
}
/**
* Handles a voice receiver error.
* @param {Error} error - The error to handle.
* @returns {Promise<void>} - A promise that resolves when the voice receiver error is handled.
*/
async onVoiceReceiverError(error) {
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `VoiceReceiver error for player ${this.guildId}: ${error.message}`);
this.manager.emit(Enums_1.ManagerEventTypes.VoiceReceiverError, this, error);
}
/**
* Updates the voice state for the player.
* @returns {Promise<void>} - A promise that resolves when the voice state is updated.
*/
async updateVoice() {
const vs = this.voiceState;
const ev = vs?.event;
if (!vs?.channelId || !vs?.sessionId || !ev?.token || !ev?.endpoint)
return;
await this.node.rest.updatePlayer({
guildId: this.options.guildId,
data: {
voice: {
token: ev.token,
endpoint: ev.endpoint,
sessionId: vs.sessionId,
channelId: vs.channelId,
},
},
});
}
}
exports.Player = Player;