UNPKG

magmastream

Version:

A user-friendly Lavalink client designed for NodeJS.

1,182 lines (1,181 loc) 49.7 kB
"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;