@terron/djs-music
Version:
New simple voice player with yt-dlp supporting, filters & events for discord.js v14.
526 lines (457 loc) • 19.3 kB
JavaScript
const ytSearch = require('yt-search');
const { joinVoiceChannel, createAudioPlayer, createAudioResource, getVoiceConnection, StreamType, AudioPlayerStatus } = require('@discordjs/voice');
const { EventEmitter } = require('events');
const prism = require('prism-media');
const { PlayerEvents, LoopStatuses } = require('./constants.js');
const { Resolvable, shuffleArray, getFullStream } = require('./utils.js');
const basePrismOptions = [
'-analyzeduration', '0',
'-loglevel', '0',
'-f', 's16le',
'-ar', '48000',
'-ac', '2',
];
class Player extends EventEmitter {
/**
* @param {Object} options - Configuration options for the player.
* @param {number} [options.defaultVolume=100] - The default volume percentage.
* @param {boolean} [options.leaveAfterEnd=true] - Whether to leave the voice channel after playback ends.
* @param {boolean} [options.keepVoiceIfExists=true] - Whether to reuse existing voice connections.
* @param {Discord.Client} [options.client=null] - The Discord.js client instance.
* @param {string} [options.ytDlpOptions='...'] - Your options for yt-dlp scrapping.
*/
constructor({
defaultVolume = 100, leaveAfterEnd = true, keepVoiceIfExists = true, client = null,
ytDlpOptions = '-f bestaudio/best --extract-audio --audio-quality 0 --concurrent-fragments 16 --buffer-size 16M --no-check-certificate'
} = {}) {
super();
this.defaultVolume = defaultVolume;
this.leaveAfterEnd = leaveAfterEnd;
this.keepVoiceIfExists = keepVoiceIfExists;
this.client = client;
this.ytDlpOptions = ytDlpOptions
this._queues = new Map();
this._players = new Map();
this._volumes = new Map();
this._filters = new Map();
this._queueStatuses = new Map();
this._lastPlayed = new Map();
if (this.client) {
this.client.on?.('voiceStateUpdate', this._handleVoiceStateUpdate.bind(this));
}
}
/**
* Handles voice state updates for the bot user.
* @param {VoiceState} oldState - The previous voice state.
* @param {VoiceState} newState - The updated voice state.
* @private
*/
_handleVoiceStateUpdate(oldState, newState) {
if (newState.id !== this.client.user.id) return;
if (!oldState.channelId && newState.channelId) {
this.emit(PlayerEvents.VoiceJoin, newState.channel);
} else if (oldState.channelId && !newState.channelId) {
this.emit(PlayerEvents.VoiceLeft, oldState.channel);
} else if (oldState.channelId !== newState.channelId) {
this.emit(PlayerEvents.VoiceUpdate, oldState.channel, newState.channel);
}
}
/**
* Searches YouTube for videos based on a query.
* @param {string} query - The search query.
* @param {number} [trackLimit=10] - Maximum number of tracks to return.
* @returns {Promise<Array<Object>>} - Array of video objects.
*/
async search(query, trackLimit = 10) {
try {
const result = await ytSearch(query);
return result.videos.slice(0, trackLimit);
} catch (error) {
console.error("Search error:", error);
return [];
}
}
/**
* Force connects to a specified voice channel.
* @param {string|Discord.Snowflake} channelResolvable - Channel ID or channel object.
* @param {Object} [voiceJoinOptions={}] - Options for joining the voice channel.
* @returns {Promise<VoiceConnection>} - The voice connection object.
* @throws {Error} - If the channel is invalid or not a voice channel.
*/
async connect(channelResolvable, voiceJoinOptions = {}) {
const channel = await this.client.channels.fetch(Resolvable.from(channelResolvable)).catch(() => null);
if (!channel || !channel.guildId || channel.type !== 2) {
throw new Error('Invalid channel or not a voice type');
}
const connectionOptions = {
channelId: channel.id,
guildId: channel.guildId,
adapterCreator: channel.guild.voiceAdapterCreator,
...voiceJoinOptions,
};
const connection = joinVoiceChannel(connectionOptions);
this._initializeGuildData(channel.guildId);
return connection;
}
/**
* Plays a track in the specified voice channel.
* @param {string|Discord.Snowflake} channelResolvable - Channel ID or channel object.
* @param {string} query - The search query for the track.
* @param {Object} [voiceJoinOptions={}] - Options for joining the voice channel.
* @returns {Promise<Object>} - The video object that is played.
*/
async play(channelResolvable, query, voiceJoinOptions = {}, skipSearching = false) {
const channel = await this.client.channels.fetch(Resolvable.from(channelResolvable)).catch(() => {});
let connection = this.getVoiceConnectionChannel(channel?.guildId);
connection = this.keepVoiceIfExists && connection ? connection : await this.connect(channelResolvable, voiceJoinOptions);
const guildId = connection.joinConfig.guildId;
const videos = skipSearching ? [query] : (await this.search(query));
if (videos.length === 0) {
throw new Error('No video results found');
}
const queue = this._queues.get(guildId);
const track = videos[0];
queue.push(track);
this.emit(PlayerEvents.TrackAdd, track, guildId);
const player = this._players.get(guildId);
if (player.state.status !== AudioPlayerStatus.Playing) {
await this._playNext(guildId);
}
connection.subscribe(player);
return track;
}
/**
* Initializes guild-specific data maps.
* @param {string} guildId - The ID of the guild.
* @private
*/
_initializeGuildData(guildId) {
if (!this._queues.has(guildId)) this._queues.set(guildId, []);
if (!this._filters.has(guildId)) this._filters.set(guildId, []);
if (!this._players.has(guildId)) this._players.set(guildId, createAudioPlayer());
if (!this._queueStatuses.has(guildId)) this._queueStatuses.set(guildId, LoopStatuses.Nothing);
if (!this._lastPlayed.has(guildId)) this._lastPlayed.set(guildId, null);
}
/**
* Plays the next track in the queue for a guild.
* @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object.
* @private
*/
async _playNext(guildResolvable) {
const guildId = Resolvable.from(guildResolvable);
const queue = this._queues.get(guildId);
const player = this._players.get(guildId);
const filters = this._filters.get(guildId);
const volume = this._volumes.get(guildId) || (this.defaultVolume);
const queueStatus = this._queueStatuses.get(guildId);
const next = this._getNextTrack(queue, queueStatus, guildId);
if (!player || !next) {
player?.stop();
this._cleanUpResources(guildId);
this.emit(PlayerEvents.QueueEnd, guildId);
return;
}
const transcoder = new prism.FFmpeg({
args: [
...basePrismOptions,
'-filter:a', filters?.map(x => x.ffmpeg).join() || 'atempo=1.0'
]
});
const stream = await getFullStream(next.url, this.ytDlpOptions.split(' '));
const transcodedStream = stream.pipe(transcoder);
const resource = createAudioResource(transcodedStream, {
inputType: StreamType.Raw,
inlineVolume: true,
});
resource.volume.setVolume(volume / 100);
resource.ytData = next;
this._lastPlayed.set(guildId, next);
player.play(resource);
this.emit(PlayerEvents.TrackStart, next, guildId);
player.once(AudioPlayerStatus.Idle, async () => await this._playNext(guildId));
return next;
}
/**
* Determines the next track based on queue status.
* @param {Array} queue - The current queue of tracks.
* @param {string} queueStatus - Current loop status (Nothing, Track, Queue).
* @param {string} guildId - Guild ID.
* @returns {Object|null} - The next track to play.
* @private
*/
_getNextTrack(queue, queueStatus, guildId) {
switch (queueStatus) {
case LoopStatuses.Nothing:
return queue.shift();
case LoopStatuses.Track:
return { ...(this._lastPlayed.get(guildId)) };
case LoopStatuses.Queue:
this._queues.set(guildId, [...queue, { ...(this._lastPlayed.get(guildId)) } ]);
return this._queues.get(guildId).shift();
default:
return null;
}
}
/**
* Cleans up all resources associated with a guild.
* @param {string} guildId - Guild ID.
* @private
*/
_cleanUpResources(guildId) {
this._queues.delete(guildId);
this._players.delete(guildId);
this._volumes.delete(guildId);
this._filters.delete(guildId);
this._queueStatuses.delete(guildId);
this._lastPlayed.delete(guildId)
}
/**
* Pauses playback in a guild's voice channel.
* @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object.
* @returns {boolean} - True if successfully paused, false otherwise.
*/
pause(guildResolvable) {
const guildId = Resolvable.from(guildResolvable);
const player = this._players.get(guildId);
if (player?.state.status === AudioPlayerStatus.Playing) {
player.pause();
}
return this.paused(guildId);
}
/**
* Resumes playback in a guild's voice channel.
* @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object.
* @returns {boolean} - True if successfully resumed, false otherwise.
*/
unpause(guildResolvable) {
const guildId = Resolvable.from(guildResolvable);
const player = this._players.get(guildId);
if (player?.state.status === AudioPlayerStatus.Paused) {
player.unpause();
}
return this.paused(guildId);
}
/**
* Sets the volume for playback in a guild's voice channel.
* @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object.
* @param {number} volume - The volume percentage.
*/
setVolume(guildResolvable, volume) {
const guildId = Resolvable.from(guildResolvable);
const player = this._players.get(guildId);
if (!this.nowPlaying(guildId)) return;
this._volumes.set(guildId, isFinite(volume) ? volume : 100);
player?.state?.resource?.volume?.setVolume(volume / 100);
}
/**
* Skips the current track and plays the next one in the queue.
* @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object.
* @returns {Object|null} - The video object of the next track, or null if no next track.
*/
async skip(guildResolvable) {
return await this._playNext(guildResolvable)
}
/**
* Stops playback in a guild's voice channel.
* @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object.
*/
async stop(guildResolvable) {
const guildId = Resolvable.from(guildResolvable);
const player = this._players.get(guildId);
if (player) {
this._cleanUpResources(guildId)
player?.stop();
}
}
/**
* Checks if playback is currently paused in a guild's voice channel.
* @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object.
* @returns {boolean} - True if playback is paused, false otherwise.
*/
paused(guildResolvable) {
const guildId = Resolvable.from(guildResolvable);
const player = this._players.get(guildId);
return player?.state.status === AudioPlayerStatus.Paused;
}
/**
* Sets the loop status for the queue in a guild's voice channel.
* @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object.
* @param {number} status - The loop status to set: Nothing (0), Track (1), Queue (2).
*/
loopQueue(guildResolvable, status) {
const guildId = Resolvable.from(guildResolvable);
const validStatuses = [LoopStatuses.Nothing, LoopStatuses.Track, LoopStatuses.Queue];
const finalStatus = validStatuses.includes(status) ? status : LoopStatuses.Nothing;
this._queueStatuses.set(guildId, finalStatus);
this.emit(PlayerEvents.UpdateLoopStatus, guildId, finalStatus);
}
/**
* Retrieves the current loop status for the queue in a guild's voice channel.
* @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object.
* @returns {number} - The current loop status.
*/
getLoopQueueStatus(guildResolvable) {
const guildId = Resolvable.from(guildResolvable);
return this._queueStatuses.get(guildId) || LoopStatuses.Nothing;
}
/**
* Retrieves the current playback queue for a guild.
* @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object.
* @returns {Array<Object>} - Array of video objects in the queue.
*/
getQueue(guildResolvable) {
const guildId = Resolvable.from(guildResolvable);
return this._queues.get(guildId) || [];
}
/**
* Shuffles the current playback queue for a guild.
* @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object.
*/
shuffleQueue(guildResolvable) {
const guildId = Resolvable.from(guildResolvable);
const queue = this.getQueue(guildId);
this._queues.set(guildId, shuffleArray([...queue]));
}
/**
* Retrieves the currently playing track in a guild's voice channel.
* @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object.
* @returns {Object|null} - The currently playing track, or null if none.
*/
nowPlaying(guildResolvable) {
const guildId = Resolvable.from(guildResolvable);
const player = this._players.get(guildId);
if (player?.state.status === AudioPlayerStatus.Playing) {
return player.state.resource?.ytData || null;
}
return null;
}
/**
* Adds an FFmpeg filter to the audio stream.
* @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object.
* @param {string} ffmpegFilter - The FFmpeg filter to add.
*/
addFilter(guildResolvable, ffmpegFilter) {
const guildId = Resolvable.from(guildResolvable);
const filters = this._filters.get(guildId) || [];
this._filters.set(guildId, [...filters, ffmpegFilter]);
this.restartCurrentTrack(guildId);
}
/**
* Sets an FFmpeg filter for the audio stream, replacing existing ones.
* @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object.
* @param {string} ffmpegFilter - The FFmpeg filter to set.
*/
setFilter(guildResolvable, ffmpegFilter) {
const guildId = Resolvable.from(guildResolvable);
this._filters.set(guildId, [ffmpegFilter]);
this.restartCurrentTrack(guildId);
}
/**
* Removes an FFmpeg filter from the audio stream.
* @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object.
* @param {string} ffmpegFilter - The FFmpeg filter to remove.
*/
removeFilter(guildResolvable, ffmpegFilter) {
const guildId = Resolvable.from(guildResolvable);
const filters = this._filters.get(guildId) || [];
this._filters.set(guildId, filters.filter(filter => filter !== ffmpegFilter));
this.restartCurrentTrack(guildId);
}
/**
* Resets all FFmpeg filters for the audio stream.
* @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object.
*/
resetFilters(guildResolvable) {
const guildId = Resolvable.from(guildResolvable);
this._filters.delete(guildId);
this.restartCurrentTrack(guildId);
}
/**
* Restarts the current track, applying changes (e.g., filters).
* @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object.
*/
restartCurrentTrack(guildResolvable) {
const guildId = Resolvable.from(guildResolvable);
const queue = this.getQueue(guildId);
const currentTrack = this.nowPlaying(guildId);
if (currentTrack) {
queue.unshift(currentTrack); // Re-add the current track at the start.
this._playNext(guildId);
}
}
/**
* Disconnects from the guild's voice channel and cleans up resources.
* @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object.
*/
disconnect(guildResolvable) {
const guildId = Resolvable.from(guildResolvable);
const connection = getVoiceConnection(guildId);
if (connection) {
connection.destroy();
}
this._cleanUpResources(guildId);
this.emit(PlayerEvents.Disconnected, guildId);
}
/**
* Gets the voice connection in the guild.
* @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object.
* @returns {VoiceConnection|null} - The voice connection, or null if none.
*/
getVoiceConnection(guildResolvable) {
return getVoiceConnection(Resolvable.from(guildResolvable)) || null;
}
/**
* Gets the voice channel associated with the current connection.
* @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object.
* @returns {Promise<Object|null>} - The voice channel object, or null if none.
*/
async getVoiceConnectionChannel(guildResolvable) {
const connection = this.getVoiceConnection(guildResolvable);
if (!connection) return null;
return await this.client.channels.fetch(connection.joinConfig?.channelId).catch(() => null);
}
/**
* Removes a specific track from the queue.
* @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object.
* @param {number} index - Index of the track to remove (starting from 0).
* @returns {Object|null} - The removed track, or null if the index is invalid.
*/
removeTrack(guildResolvable, index) {
const guildId = Resolvable.from(guildResolvable);
const queue = this.getQueue(guildId);
if (index < 0 || index >= queue.length) return null;
const removedTrack = queue.splice(index, 1)[0];
this._queues.set(guildId, queue);
return removedTrack;
}
/**
* Moves a track to a different position in the queue.
* @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object.
* @param {number} fromIndex - Current index of the track.
* @param {number} toIndex - Desired index to move the track to.
* @returns {boolean} - True if the move was successful, false otherwise.
*/
moveTrack(guildResolvable, fromIndex, toIndex) {
const guildId = Resolvable.from(guildResolvable);
const queue = this.getQueue(guildId);
if (fromIndex < 0 || fromIndex >= queue.length || toIndex < 0 || toIndex >= queue.length) {
return false;
}
const [track] = queue.splice(fromIndex, 1);
queue.splice(toIndex, 0, track);
this._queues.set(guildId, queue);
return true;
}
/**
* Updates the voice status of a specified channel.
* @param {string|Discord.Snowflake} channelResolvable - Channel ID or channel object.
* @param {string} status - The voice status to set (e.g., "muted", "unmuted").
*/
async setChannelStatus(channelResolvable, status) {
const channelId = Resolvable.from(channelResolvable);
await this.client.rest.put(`/channels/${channelId}/voice-status`, {
body: { status }
});
}
}
module.exports = Player;