aqualink
Version:
An Lavalink client, focused in pure performance and features
490 lines (398 loc) • 15.8 kB
JavaScript
"use strict";
const { EventEmitter } = require('eventemitter3');
const Connection = require("./Connection");
const Queue = require("./Queue");
const Filters = require("./Filters");
const { spAutoPlay, scAutoPlay } = require('../handlers/autoplay');
class Player extends EventEmitter {
static LOOP_MODES = Object.freeze({
NONE: "none", TRACK: "track", QUEUE: "queue"
});
static EVENT_HANDLERS = Object.freeze({
TrackStartEvent: "trackStart",
TrackEndEvent: "trackEnd",
TrackExceptionEvent: "trackError",
TrackStuckEvent: "trackStuck",
TrackChangeEvent: "trackChange",
WebSocketClosedEvent: "socketClosed"
});
static validModes = new Set(Object.values(Player.LOOP_MODES));
constructor(aqua, nodes, options = {}) {
super();
this.aqua = aqua;
this.nodes = nodes;
this.guildId = options.guildId;
this.textChannel = options.textChannel;
this.voiceChannel = options.voiceChannel;
this.connection = new Connection(this);
this.filters = new Filters(this);
this.queue = new Queue();
this.volume = Math.min(Math.max(options.defaultVolume ?? 100, 0), 200);
this.loop = Player.validModes.has(options.loop) ? options.loop : Player.LOOP_MODES.NONE;
this.shouldDeleteMessage = Boolean(options.shouldDeleteMessage);
this.leaveOnEnd = Boolean(options.leaveOnEnd);
this.previousTracks = new Array(50);
this.previousTracksIndex = 0;
this.previousTracksCount = 0;
this.playing = false;
this.paused = false;
this.connected = false;
this.current = null;
this.position = 0;
this.timestamp = 0;
this.ping = 0;
this.nowPlayingMessage = null;
this.isAutoplayEnabled = false;
this.isAutoplay = false;
this._pendingUpdates = {};
this._updateTimeout = null;
this._boundHandlers = {
playerUpdate: this._handlePlayerUpdate.bind(this),
event: this._handleEvent.bind(this)
};
this.on("playerUpdate", this._boundHandlers.playerUpdate);
this.on("event", this._boundHandlers.event);
this._dataStore = new Map();
}
get previous() {
if (!this.previousTracksCount) return null;
return this.previousTracks[(this.previousTracksIndex - 1 + 50) % 50];
}
get currenttrack() {
return this.current;
}
batchUpdatePlayer(data, immediate = false) {
this._pendingUpdates = { ...this._pendingUpdates, ...data };
if (this._updateTimeout) {
clearTimeout(this._updateTimeout);
this._updateTimeout = null;
}
if (immediate || data.track) {
const updates = this._pendingUpdates;
this._pendingUpdates = {};
return this.updatePlayer(updates);
}
this._updateTimeout = setTimeout(() => {
const updates = this._pendingUpdates;
this._pendingUpdates = {};
this.updatePlayer(updates);
this._updateTimeout = null;
}, 50);
return Promise.resolve();
}
async autoplay(player) {
if (!player) throw new Error("Player is undefined. const player = aqua.plaerers.get(guildId);");
if (!this.isAutoplayEnabled) {
this.aqua.emit("debug", this.guildId, "Autoplay is disabled.");
return this;
}
this.isAutoplay = true;
if (!this.previous) return this;
try {
const { sourceName, identifier, uri, requester } = this.previous.info;
this.aqua.emit("debug", this.guildId, `Attempting autoplay for ${sourceName}`);
const sourceHandlers = {
youtube: async () => ({
query: `https://www.youtube.com/watch?v=${identifier}&list=RD${identifier}`,
source: "ytmsearch"
}),
soundcloud: async () => {
const scResults = await scAutoPlay(uri);
return scResults?.length ? { query: scResults[0], source: "scsearch" } : null;
},
spotify: async () => {
const spResult = await spAutoPlay(identifier);
return spResult ? { query: `https://open.spotify.com/track/${spResult}`, source: "spotify" } : null;
}
};
const handler = sourceHandlers[sourceName];
if (!handler) return this;
const result = await handler();
if (!result) return this;
const { query, source } = result;
const response = await this.aqua.resolve({ query, source, requester });
const failTypes = new Set(["error", "empty", "LOAD_FAILED", "NO_MATCHES"]);
if (!response?.tracks?.length || failTypes.has(response.loadType)) {
return this.stop();
}
const track = response.tracks[Math.floor(Math.random() * response.tracks.length)];
if (!track?.info?.title) throw new Error("Invalid track object: missing title or info.");
track.requester = this.previous.requester || { id: "Unknown" };
this.queue.push(track);
await this.play();
return this;
} catch (error) {
console.error("Autoplay error:", error);
return this.stop();
}
}
setAutoplay(enabled) {
this.isAutoplayEnabled = Boolean(enabled);
this.aqua.emit("debug", this.guildId, `Autoplay has been ${enabled ? "enabled" : "disabled"}.`);
return this;
}
addToPreviousTrack(track) {
if (!track) return;
this.previousTracks[this.previousTracksIndex] = track;
this.previousTracksIndex = (this.previousTracksIndex + 1) % 50;
if (this.previousTracksCount < 50) this.previousTracksCount++;
}
_handlePlayerUpdate({ state }) {
if (!state) return;
const { position, timestamp, ping } = state;
if (position !== undefined) this.position = position;
if (timestamp !== undefined) this.timestamp = timestamp;
if (ping !== undefined) this.ping = ping;
this.aqua.emit("playerUpdate", this, { state });
}
async _handleEvent(payload) {
const handlerName = Player.EVENT_HANDLERS[payload.type];
if (handlerName && typeof this[handlerName] === "function") {
await this[handlerName](this, this.current, payload);
} else {
this.handleUnknownEvent(payload);
}
}
async play() {
if (!this.connected || !this.queue.length) return;
const item = this.queue.shift();
this.current = item.track ? item : await item.resolve(this.aqua);
this.playing = true;
this.position = 0;
this.aqua.emit("debug", this.guildId, `Playing track: ${this.current.track}`);
return this.batchUpdatePlayer({ track: { encoded: this.current.track } }, true);
}
connect({ voiceChannel, deaf = true, mute = false } = {}) {
const payload = {
guild_id: this.guildId,
channel_id: this.voiceChannel,
self_deaf: deaf,
self_mute: mute
};
this.send(payload);
this.connected = true;
this.aqua.emit("debug", this.guildId, `Player connected to voice channel: ${voiceChannel}.`);
return this;
}
destroy() {
if (!this.connected) return this;
if (this._updateTimeout) {
clearTimeout(this._updateTimeout);
this._updateTimeout = null;
this._pendingUpdates = {};
}
this.disconnect();
if (this.nowPlayingMessage) {
this.nowPlayingMessage.delete().catch(() => {});
this.nowPlayingMessage = null;
}
this.isAutoplay = false;
this.off("playerUpdate", this._boundHandlers.playerUpdate);
this.off("event", this._boundHandlers.event);
this.aqua.destroyPlayer(this.guildId);
this.nodes.rest.destroyPlayer(this.guildId);
this.clearData();
this.removeAllListeners();
this._boundHandlers = null;
this.queue = null;
this.previousTracks = null;
this.connection = null;
this.filters = null;
return this;
}
pause(paused) {
if (this.paused === paused) return this;
this.paused = paused;
this.batchUpdatePlayer({ paused });
return this;
}
async getLyrics(options = {}) {
const { query = null, useCurrentTrack = true } = options;
if (query) {
return this.nodes.rest.getLyrics({ track: { info: { title: query }, search: true } }) || null;
}
if (useCurrentTrack && this.playing) {
return this.nodes.rest.getLyrics({ track: { encoded: this.current.track, guild_id: this.guildId } }) || null;
}
return null;
}
async searchLyrics(query) {
return this.getLyrics({ query });
}
async lyrics() {
return this.getLyrics({ useCurrentTrack: true });
}
seek(position) {
if (!this.playing) return this;
this.position += position;
this.batchUpdatePlayer({ position: this.position });
return this;
}
stop() {
if (!this.playing) return this;
this.playing = false;
this.position = 0;
this.batchUpdatePlayer({ track: { encoded: null } }, true);
return this;
}
setVolume(volume) {
if (volume < 0 || volume > 200) throw new Error("Volume must be between 0 and 200.");
this.volume = volume;
this.batchUpdatePlayer({ volume });
return this;
}
setLoop(mode) {
if (!Player.validModes.has(mode)) throw new Error("Loop mode must be 'none', 'track', or 'queue'.");
this.loop = mode;
this.batchUpdatePlayer({ loop: mode });
return this;
}
setTextChannel(channel) {
this.textChannel = channel;
this.batchUpdatePlayer({ text_channel: channel });
return this;
}
setVoiceChannel(channel) {
if (!channel?.length) throw new TypeError("Channel must be a non-empty string.");
if (this.connected && channel === this.voiceChannel) {
throw new ReferenceError(`Player already connected to ${channel}.`);
}
this.voiceChannel = channel;
this.connect({
deaf: this.deaf,
guildId: this.guildId,
voiceChannel: channel,
mute: this.mute
});
return this;
}
disconnect() {
if (!this.connected) return this;
this.connected = false;
this.send({ guild_id: this.guildId, channel_id: null });
this.voiceChannel = null;
this.aqua.emit("debug", this.guildId, "Player disconnected.");
return this;
}
shuffle() {
const queue = this.queue;
const length = queue.length;
for (let i = length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[queue[i], queue[j]] = [queue[j], queue[i]];
}
return this;
}
getQueue() {
return this.queue;
}
replay() {
return this.seek(-this.position);
}
skip() {
this.stop();
return this.playing ? this.play() : undefined;
}
async trackStart(player, track) {
this.playing = true;
this.paused = false;
this.aqua.emit("trackStart", player, track);
}
async trackEnd(player, track, payload) {
this.addToPreviousTrack(track);
if (this.shouldDeleteMessage && this.nowPlayingMessage) {
try {
await this.nowPlayingMessage.delete();
} catch (error) {
console.error("Error deleting now playing message:", error);
} finally {
this.nowPlayingMessage = null;
}
}
const reason = payload.reason;
const failureReasons = new Set(["LOAD_FAILED", "CLEANUP"]);
if (failureReasons.has(reason)) {
if (!player.queue.length) {
this.clearData();
this.aqua.emit("queueEnd", player);
} else {
this.aqua.emit("trackEnd", player, track, reason);
await player.play();
}
return;
}
switch (this.loop) {
case Player.LOOP_MODES.TRACK:
player.queue.unshift(track);
break;
case Player.LOOP_MODES.QUEUE:
player.queue.push(track);
break;
}
if (player.queue.isEmpty()) {
if (this.isAutoplayEnabled) {
await player.autoplay(player);
} else {
this.playing = false;
if (this.leaveOnEnd) {
this.clearData();
this.cleanup();
}
this.aqua.emit("queueEnd", player);
}
} else {
this.aqua.emit("trackEnd", player, track, reason);
await player.play();
}
}
async trackError(player, track, payload) {
this.aqua.emit("trackError", player, track, payload);
return this.stop();
}
async trackStuck(player, track, payload) {
this.aqua.emit("trackStuck", player, track, payload);
return this.stop();
}
async socketClosed(player, payload) {
const { code, guildId } = payload || {};
const reconnectCodes = new Set([4015, 4009]);
if (reconnectCodes.has(code)) {
this.send({
guild_id: guildId,
channel_id: this.voiceChannel,
self_mute: this.mute,
self_deaf: this.deaf,
});
}
this.aqua.emit("socketClosed", player, payload);
this.pause(true);
this.aqua.emit("debug", this.guildId, "Player paused due to socket closure.");
}
send(data) {
this.aqua.send({ op: 4, d: data });
}
set(key, value) {
this._dataStore.set(key, value);
}
get(key) {
return this._dataStore.get(key);
}
clearData() {
if (this.previousTracks) this.previousTracksCount = 0;
this._dataStore.clear();
return this;
}
updatePlayer(data) {
return this.nodes.rest.updatePlayer({ guildId: this.guildId, data });
}
handleUnknownEvent(payload) {
const error = new Error(`Node encountered an unknown event: '${payload.type}'`);
this.aqua.emit("nodeError", this, error);
}
async cleanup() {
if (!this.playing && !this.paused && this.queue.isEmpty()) {
this.destroy();
}
}
}
module.exports = Player;