UNPKG

magmastream

Version:

A user-friendly Lavalink client designed for NodeJS.

1,023 lines (1,022 loc) 44.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Player = void 0; const tslib_1 = require("tslib"); const Filters_1 = require("./Filters"); const Manager_1 = require("./Manager"); const Node_1 = require("./Node"); const Queue_1 = require("./Queue"); const Utils_1 = require("./Utils"); const _ = tslib_1.__importStar(require("lodash")); const playerCheck_1 = tslib_1.__importDefault(require("../utils/playerCheck")); const axios_1 = tslib_1.__importDefault(require("axios")); 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 = Utils_1.StateTypes.Disconnected; /** The equalizer bands array. */ bands = new Array(15).fill(0.0); /** The voice state object from Discord. */ voiceState; /** The Manager. */ manager; /** The autoplay state of the player. */ isAutoplay = false; /** The number of times to try autoplay before emitting queueEnd. */ autoplayTries = null; static _manager; data = {}; dynamicLoopInterval = null; dynamicRepeatIntervalMs = null; /** * 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 RangeError("Manager has not been initiated."); // If a player with the same guild ID already exists, return it. if (this.manager.players.has(options.guildId)) { return this.manager.players.get(options.guildId); } // Check the player options for errors. (0, playerCheck_1.default)(options); // Set the guild ID and voice state. this.guildId = options.guildId; this.voiceState = Object.assign({ op: "voiceUpdate", guild_id: options.guildId, }); // 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.node); this.node = node || this.manager.useableNode; // If no node is available, throw an error. if (!this.node) throw new RangeError("No available nodes."); // Initialize the queue with the guild ID and manager. this.queue = new Queue_1.Queue(this.guildId, this.manager); this.queue.previous = new Array(); // Add the player to the manager's player collection. this.manager.players.set(options.guildId, this); // Set the initial volume. this.setVolume(options.volume ?? 100); // Initialize the filters. this.filters = new Filters_1.Filters(this); // Emit the playerCreate event. this.manager.emit(Manager_1.ManagerEventTypes.PlayerCreate, this); } /** * 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]; } /** * 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; } /** * 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 RangeError("No voice channel has been set. You must use the `setVoiceChannelId()` method to set the voice channel before connecting."); } // Set the player state to connecting. this.state = Utils_1.StateTypes.Connecting; // Clone the current player state for comparison. const oldPlayer = this ? { ...this } : null; // Send the voice state update to the gateway. this.manager.options.send(this.guildId, { 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 = Utils_1.StateTypes.Connected; // Emit the player state update event. this.manager.emit(Manager_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, { changeType: Manager_1.PlayerStateEventTypes.ConnectionChange, details: { changeType: "connect", previousConnection: oldPlayer?.state === Utils_1.StateTypes.Connected, currentConnection: true, }, }); } /** * Disconnects the player from the voice channel. * @throws {TypeError} If the player is not connected. * @returns {this} - The current instance of the Player class for method chaining. */ async disconnect() { // Set the player state to disconnecting. this.state = Utils_1.StateTypes.Disconnecting; // Clone the current player state for comparison. const oldPlayer = this ? { ...this } : null; // Pause the player. await this.pause(true); // Send the voice state update to the gateway. this.manager.options.send(this.guildId, { op: 4, d: { guild_id: this.guildId, channel_id: null, self_mute: false, self_deaf: false, }, }); // Set the player voice channel to null. this.voiceChannelId = null; // Set the player state to disconnected. this.state = Utils_1.StateTypes.Disconnected; // Emit the player state update event. this.manager.emit(Manager_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, { changeType: Manager_1.PlayerStateEventTypes.ConnectionChange, details: { changeType: "disconnect", previousConnection: oldPlayer.state === Utils_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) { if (this.state === Utils_1.StateTypes.Disconnected) return true; const oldPlayer = this ? { ...this } : null; this.state = Utils_1.StateTypes.Destroying; if (disconnect) { await this.disconnect(); } await this.node.rest.destroyPlayer(this.guildId); this.queue.clear(); this.manager.emit(Manager_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, null, { changeType: Manager_1.PlayerStateEventTypes.PlayerDestroy, }); this.manager.emit(Manager_1.ManagerEventTypes.PlayerDestroy, this); return this.manager.players.delete(this.guildId); } /** * 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 TypeError("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.connect(); // Emit a player state update event this.manager.emit(Manager_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, { changeType: Manager_1.PlayerStateEventTypes.ChannelChange, details: { changeType: "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 TypeError("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; // Emit a player state update event with channel change details this.manager.emit(Manager_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, { changeType: Manager_1.PlayerStateEventTypes.ChannelChange, details: { changeType: "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 TypeError("You must provide the message of the now playing message."); } this.nowPlayingMessage = message; return this.nowPlayingMessage; } async play(optionsOrTrack, playOptions) { if (typeof optionsOrTrack !== "undefined" && Utils_1.TrackUtils.validate(optionsOrTrack)) { this.queue.current = optionsOrTrack; } if (!this.queue.current) throw new RangeError("No current track."); const finalOptions = playOptions ? playOptions : ["startTime", "endTime", "noReplace"].every((v) => Object.keys(optionsOrTrack || {}).includes(v)) ? optionsOrTrack : {}; await this.node.rest.updatePlayer({ guildId: this.guildId, data: { encodedTrack: this.queue.current?.track, ...finalOptions, }, }); this.playing = true; this.position = 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} botUser - 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, botUser, tries) { if (typeof autoplayState !== "boolean") { throw new Error("autoplayState must be a boolean."); } if (autoplayState) { if (!botUser) { throw new Error("botUser must be provided when enabling autoplay."); } if (!["ClientUser", "User"].includes(botUser.constructor.name)) { throw new Error("botUser must be a user-object."); } this.autoplayTries = tries && typeof tries === "number" && tries > 0 ? tries : 3; // Default to 3 if invalid this.isAutoplay = true; this.set("Internal_BotUser", botUser); } else { this.isAutoplay = false; this.autoplayTries = null; this.set("Internal_BotUser", null); } const oldPlayer = { ...this }; this.manager.emit(Manager_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, { changeType: Manager_1.PlayerStateEventTypes.AutoPlayChange, details: { 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 node = this.manager.useableNode; if (!node) { throw new Error("No available nodes."); } if (!Utils_1.TrackUtils.validate(track)) { throw new RangeError('"Track must be a "Track" or "Track[]'); } // Get the Last.fm API key and the available source managers const apiKey = this.manager.options.lastFmApiKey; const enabledSources = node.info.sourceManagers; // Determine if YouTube should be used if (!apiKey && enabledSources.includes("youtube")) { // Use YouTube-based autoplay return await this.handleYouTubeRecommendations(track); } if (!apiKey) return []; // Handle Last.fm-based autoplay (or other platforms) const selectedSource = node.selectPlatform(enabledSources); if (selectedSource) { // Use the selected source to handle autoplay return await this.handlePlatformAutoplay(track, selectedSource, apiKey); } // If no source is available, return false return []; } /** * Handles YouTube-based recommendations. * @param {Track} track - The track to find recommendations for. * @returns {Promise<Track[]>} - Array of recommended tracks. */ async handleYouTubeRecommendations(track) { // Check if the previous track has a YouTube URL const hasYouTubeURL = ["youtube.com", "youtu.be"].some((url) => track.uri.includes(url)); // Get the video ID from the previous track's URL let videoID = null; if (hasYouTubeURL) { videoID = track.uri.split("=").pop(); } else { const searchResult = await this.manager.search({ query: `${track.author} - ${track.title}`, source: Manager_1.SearchPlatform.YouTube }, track.requester); videoID = searchResult.tracks[0]?.uri.split("=").pop(); } // If the video ID is not found, return false if (!videoID) return []; // Get a random video index between 2 and 24 let randomIndex; let searchURI; do { // Generate a random index between 2 and 24 randomIndex = Math.floor(Math.random() * 23) + 2; // Build the search URI searchURI = `https://www.youtube.com/watch?v=${videoID}&list=RD${videoID}&index=${randomIndex}`; } while (track.uri.includes(searchURI)); // Search for the video and return false if the search fails const res = await this.manager.search({ query: searchURI, source: Manager_1.SearchPlatform.YouTube }, track.requester); if (res.loadType === Utils_1.LoadTypes.Empty || res.loadType === Utils_1.LoadTypes.Error) return []; // Return all track titles that do not have the same URI as the track.uri from before return res.tracks.filter((t) => t.uri !== track.uri); } /** * Handles Last.fm-based autoplay (or other platforms). * @param {Track} track - The track to find recommendations for. * @param {SearchPlatform} source - The selected search platform. * @param {string} apiKey - The Last.fm API key. * @returns {Promise<Track[]>} - Array of recommended tracks. */ async handlePlatformAutoplay(track, source, apiKey) { let { author: artist } = track; const { title } = track; if (!artist || !title) { if (!title) { // No title provided, search for the artist's top tracks const noTitleUrl = `https://ws.audioscrobbler.com/2.0/?method=artist.getTopTracks&artist=${artist}&autocorrect=1&api_key=${apiKey}&format=json`; const response = await axios_1.default.get(noTitleUrl); if (response.data.error || !response.data.toptracks?.track?.length) return []; const randomTrack = response.data.toptracks.track[Math.floor(Math.random() * response.data.toptracks.track.length)]; const res = await this.manager.search({ query: `${randomTrack.artist.name} - ${randomTrack.name}`, source: source }, track.requester); if (res.loadType === Utils_1.LoadTypes.Empty || res.loadType === Utils_1.LoadTypes.Error) return []; const filteredTracks = res.tracks.filter((t) => t.uri !== track.uri); if (!filteredTracks) return []; return filteredTracks; } if (!artist) { // No artist provided, search for the track title const noArtistUrl = `https://ws.audioscrobbler.com/2.0/?method=track.search&track=${title}&api_key=${apiKey}&format=json`; const response = await axios_1.default.get(noArtistUrl); artist = response.data.results.trackmatches?.track?.[0]?.artist; if (!artist) return []; } } // Search for similar tracks to the current track const url = `https://ws.audioscrobbler.com/2.0/?method=track.getSimilar&artist=${artist}&track=${title}&limit=10&autocorrect=1&api_key=${apiKey}&format=json`; let response; try { response = await axios_1.default.get(url); } catch (error) { if (error) return []; } if (response.data.error || !response.data.similartracks?.track?.length) { // Retry the request if the first attempt fails const retryUrl = `https://ws.audioscrobbler.com/2.0/?method=artist.getTopTracks&artist=${artist}&autocorrect=1&api_key=${apiKey}&format=json`; const retryResponse = await axios_1.default.get(retryUrl); if (retryResponse.data.error || !retryResponse.data.toptracks?.track?.length) return []; const randomTrack = retryResponse.data.toptracks.track[Math.floor(Math.random() * retryResponse.data.toptracks.track.length)]; const res = await this.manager.search({ query: `${randomTrack.artist.name} - ${randomTrack.name}`, source: source }, track.requester); if (res.loadType === Utils_1.LoadTypes.Empty || res.loadType === Utils_1.LoadTypes.Error) return []; const filteredTracks = res.tracks.filter((t) => t.uri !== track.uri); if (!filteredTracks) return []; return filteredTracks; } return response.data.similartracks.track.filter((t) => t.uri !== track.uri); } /** * Sets the volume of the player. * @param {number} volume - The new volume. Must be between 0 and 1000. * @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 1000. * @emits {PlayerStateUpdate} - Emitted when the volume is changed. * @example * player.setVolume(50); */ async setVolume(volume) { if (isNaN(volume)) throw new TypeError("Volume must be a number."); if (volume < 0 || volume > 1000) throw new RangeError("Volume must be between 0 and 1000."); const oldPlayer = this ? { ...this } : null; await this.node.rest.updatePlayer({ guildId: this.options.guildId, data: { volume, }, }); this.volume = volume; this.manager.emit(Manager_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, { changeType: Manager_1.PlayerStateEventTypes.VolumeChange, details: { previousVolume: oldPlayer.volume || null, 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 = [Node_1.SponsorBlockSegment.Sponsor, Node_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 TypeError('Repeat can only be "true" or "false".'); // 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(Manager_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, { changeType: Manager_1.PlayerStateEventTypes.RepeatChange, detail: { changeType: "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 TypeError('Repeat can only be "true" or "false".'); // 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(Manager_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, { changeType: Manager_1.PlayerStateEventTypes.RepeatChange, detail: { changeType: "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. */ setDynamicRepeat(repeat, ms) { // Validate the repeat parameter if (typeof repeat !== "boolean") { throw new TypeError('Repeat can only be "true" or "false".'); } // Ensure the queue has more than one track for dynamic repeat if (this.queue.size <= 1) { throw new RangeError("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(() => { if (!this.dynamicRepeat) return; // Shuffle the queue and replace it with the shuffled tracks const shuffled = _.shuffle(this.queue); this.queue.clear(); shuffled.forEach((track) => { this.queue.add(track); }); }, 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(Manager_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, { changeType: Manager_1.PlayerStateEventTypes.RepeatChange, detail: { changeType: "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 if (!this.queue.current?.track) { // If the queue has tracks, play the next one if (this.queue.length) await this.play(); return this; } // Reset the track's position to the start await this.node.rest.updatePlayer({ guildId: this.guildId, data: { position: 0, encodedTrack: this.queue.current?.track, }, }); 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 = []; if (typeof amount === "number" && amount > 1) { if (amount > this.queue.length) throw new RangeError("Cannot skip more than the queue length."); removedTracks = this.queue.slice(0, amount - 1); this.queue.splice(0, amount - 1); } this.node.rest.updatePlayer({ guildId: this.guildId, data: { encodedTrack: null, }, }); this.manager.emit(Manager_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, { changeType: Manager_1.PlayerStateEventTypes.QueueChange, details: { changeType: "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 RangeError('Pause can only be "true" or "false".'); // 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(Manager_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, { changeType: Manager_1.PlayerStateEventTypes.PauseChange, details: { 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() { // Check if there are previous tracks in the queue. if (!this.queue.previous.length) { throw new Error("No previous track available."); } // Capture the current state of the player before making changes. const oldPlayer = { ...this }; // Store the current track before changing it. // let currentTrackBeforeChange: Track | null = this.queue.current ? (this.queue.current as Track) : null; // Get the last played track and remove it from the history const lastTrack = this.queue.previous.pop(); // Set the skip flag to true to prevent the onTrackEnd event from playing the next track. this.set("skipFlag", true); await this.play(lastTrack); // Add the current track back to the end of the previous queue // if (currentTrackBeforeChange) this.queue.push(currentTrackBeforeChange); // Emit a player state update event indicating the track change to previous. this.manager.emit(Manager_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, { changeType: Manager_1.PlayerStateEventTypes.TrackChange, details: { changeType: "previous", track: lastTrack, }, }); // Reset the skip flag. this.set("skipFlag", false); 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) { if (!this.queue.current) return undefined; position = Number(position); // Check if the position is valid. if (isNaN(position)) { throw new RangeError("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 > this.queue.current.duration) { position = Math.max(Math.min(position, this.queue.current.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(Manager_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this, { changeType: Manager_1.PlayerStateEventTypes.TrackChange, details: { changeType: "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) throw new Error(`Node with identifier ${identifier} not found`); if (node.options.identifier === this.node.options.identifier) { return this; } try { const playerPosition = this.position; const { sessionId, event: { token, endpoint }, } = this.voiceState; const currentTrack = this.queue.current ? this.queue.current : null; 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, encodedTrack: currentTrack?.track, voice: { token, endpoint, sessionId } }, }); await this.filters.updateFilters(); } catch (error) { throw new Error(`Failed to move player to node ${identifier}: ${error}`); } } /** * Transfers the player to a new server. If the player already exists on the new server * and force is false, this method will return the existing player. Otherwise, a new player * will be created and the current player will be destroyed. * @param {PlayerOptions} newOptions - The new options for the player. * @param {boolean} force - Whether to force the creation of a new player. * @returns {Promise<Player>} - The new player instance. */ async switchGuild(newOptions, force = false) { if (!newOptions.guildId) throw new Error("guildId is required"); if (!newOptions.voiceChannelId) throw new Error("Voice channel ID is required"); if (!newOptions.textChannelId) throw new Error("Text channel ID is required"); // Check if a player already exists for the new guild let newPlayer = this.manager.players.get(newOptions.guildId); // If the player already exists and force is false, return the existing player if (newPlayer && !force) return newPlayer; const oldPlayerProperties = { paused: this.paused, selfMute: this.options.selfMute, selfDeafen: this.options.selfDeafen, volume: this.volume, position: this.position, queue: { current: this.queue.current, tracks: [...this.queue], previous: [...this.queue.previous], }, trackRepeat: this.trackRepeat, queueRepeat: this.queueRepeat, dynamicRepeat: this.dynamicRepeat, dynamicRepeatIntervalMs: this.dynamicRepeatIntervalMs, ClientUser: this.get("Internal_BotUser"), filters: this.filters, nowPlayingMessage: this.nowPlayingMessage, isAutoplay: this.isAutoplay, }; // If force is true, destroy the existing player for the new guild if (force && newPlayer) { await newPlayer.destroy(); } newOptions.node = newOptions.node ?? this.options.node; newOptions.selfDeafen = newOptions.selfDeafen ?? oldPlayerProperties.selfDeafen; newOptions.selfMute = newOptions.selfMute ?? oldPlayerProperties.selfMute; newOptions.volume = newOptions.volume ?? oldPlayerProperties.volume; // Deep clone the current player const clonedPlayer = this.manager.create(newOptions); // Connect the cloned player to the new voice channel clonedPlayer.connect(); // Update the player's state on the Lavalink node await clonedPlayer.node.rest.updatePlayer({ guildId: clonedPlayer.guildId, data: { paused: oldPlayerProperties.paused, volume: oldPlayerProperties.volume, position: oldPlayerProperties.position, encodedTrack: oldPlayerProperties.queue.current?.track, }, }); clonedPlayer.queue.current = oldPlayerProperties.queue.current; clonedPlayer.queue.previous = oldPlayerProperties.queue.previous; clonedPlayer.queue.add(oldPlayerProperties.queue.tracks); clonedPlayer.filters = oldPlayerProperties.filters; clonedPlayer.isAutoplay = oldPlayerProperties.isAutoplay; clonedPlayer.nowPlayingMessage = oldPlayerProperties.nowPlayingMessage; clonedPlayer.trackRepeat = oldPlayerProperties.trackRepeat; clonedPlayer.queueRepeat = oldPlayerProperties.queueRepeat; clonedPlayer.dynamicRepeat = oldPlayerProperties.dynamicRepeat; clonedPlayer.dynamicRepeatIntervalMs = oldPlayerProperties.dynamicRepeatIntervalMs; clonedPlayer.set("Internal_BotUser", oldPlayerProperties.ClientUser); clonedPlayer.paused = oldPlayerProperties.paused; // Update filters for the cloned player await clonedPlayer.filters.updateFilters(); // Debug information const debugInfo = { success: true, message: `Transferred ${clonedPlayer.queue.length} tracks successfully to <#${newOptions.voiceChannelId}> bound to <#${newOptions.textChannelId}>.`, player: { guildId: clonedPlayer.guildId, voiceChannelId: clonedPlayer.voiceChannelId, textChannelId: clonedPlayer.textChannelId, volume: clonedPlayer.volume, playing: clonedPlayer.playing, queueSize: clonedPlayer.queue.size, }, }; this.manager.emit(Manager_1.ManagerEventTypes.Debug, `[PLAYER] Transferred player to a new server: ${JSON.stringify(debugInfo)}.`); // Return the cloned player return clonedPlayer; } /** * 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. * @throws {RangeError} - If the 'lavalyrics-plugin' is not available on the Lavalink node. */ async getCurrentLyrics(skipTrackSource = false) { // Check if the 'lavalyrics-plugin' is available on the node const hasLyricsPlugin = this.node.info.plugins.some((plugin) => plugin.name === "lavalyrics-plugin"); if (!hasLyricsPlugin) { throw new RangeError(`There is no lavalyrics-plugin available in the Lavalink node: ${this.node.options.identifier}`); } // Fetch the lyrics for the current track from the Lavalink node let result = (await this.node.getLyrics(this.queue.current, 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; } } exports.Player = Player;