ziplayer
Version:
A modular Discord voice player with plugin system
1,280 lines • 49.7 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Player = void 0;
const events_1 = require("events");
const voice_1 = require("@discordjs/voice");
const Queue_1 = require("./Queue");
const plugins_1 = require("../plugins");
const timeout_1 = require("../utils/timeout");
/**
* Represents a music player for a specific Discord guild.
*
* @example
* // Create and configure player
* const player = await manager.create(guildId, {
* tts: { interrupt: true, volume: 1 },
* leaveOnEnd: true,
* leaveTimeout: 30000
* });
*
* // Connect to voice channel
* await player.connect(voiceChannel);
*
* // Play different types of content
* await player.play("Never Gonna Give You Up", userId); // Search query
* await player.play("https://youtube.com/watch?v=dQw4w9WgXcQ", userId); // Direct URL
* await player.play("tts: Hello everyone!", userId); // Text-to-Speech
*
* // Player controls
* player.pause(); // Pause current track
* player.resume(); // Resume paused track
* player.skip(); // Skip to next track
* player.stop(); // Stop and clear queue
* player.setVolume(0.5); // Set volume to 50%
*
* // Event handling
* player.on("trackStart", (player, track) => {
* console.log(`Now playing: ${track.title}`);
* });
*
* player.on("queueEnd", (player) => {
* console.log("Queue finished");
* });
*
*/
class Player extends events_1.EventEmitter {
/**
* Attach an extension to the player
*
* @param {BaseExtension} extension - The extension to attach
* @example
* player.attachExtension(new MyExtension());
*/
attachExtension(extension) {
if (this.extensions.includes(extension))
return;
if (!extension.player)
extension.player = this;
this.extensions.push(extension);
this.invokeExtensionLifecycle(extension, "onRegister");
}
/**
* Detach an extension from the player
*
* @param {BaseExtension} extension - The extension to detach
* @example
* player.detachExtension(new MyExtension());
*/
detachExtension(extension) {
const index = this.extensions.indexOf(extension);
if (index === -1)
return;
this.extensions.splice(index, 1);
this.invokeExtensionLifecycle(extension, "onDestroy");
if (extension.player === this) {
extension.player = null;
}
}
/**
* Get all extensions attached to the player
*
* @returns {readonly BaseExtension[]} All attached extensions
* @example
* const extensions = player.getExtensions();
* console.log(`Extensions: ${extensions.length}`);
*/
getExtensions() {
return this.extensions;
}
invokeExtensionLifecycle(extension, hook) {
const fn = extension[hook];
if (typeof fn !== "function")
return;
try {
const result = fn.call(extension, this.extensionContext);
if (result && typeof result.then === "function") {
result.catch((err) => this.debug(`[Player] Extension ${extension.name} ${hook} error:`, err));
}
}
catch (err) {
this.debug(`[Player] Extension ${extension.name} ${hook} error:`, err);
}
}
async runBeforePlayHooks(initial) {
const request = { ...initial };
const response = {};
for (const extension of this.extensions) {
const hook = extension.beforePlay;
if (typeof hook !== "function")
continue;
try {
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
if (!result)
continue;
if (result.query !== undefined) {
request.query = result.query;
response.query = result.query;
}
if (result.requestedBy !== undefined) {
request.requestedBy = result.requestedBy;
response.requestedBy = result.requestedBy;
}
if (Array.isArray(result.tracks)) {
response.tracks = result.tracks;
}
if (typeof result.isPlaylist === "boolean") {
response.isPlaylist = result.isPlaylist;
}
if (typeof result.success === "boolean") {
response.success = result.success;
}
if (result.error instanceof Error) {
response.error = result.error;
}
if (typeof result.handled === "boolean") {
response.handled = result.handled;
if (result.handled)
break;
}
}
catch (err) {
this.debug(`[Player] Extension ${extension.name} beforePlay error:`, err);
}
}
return { request, response };
}
async runAfterPlayHooks(payload) {
if (this.extensions.length === 0)
return;
const safeTracks = payload.tracks ? [...payload.tracks] : undefined;
if (safeTracks) {
Object.freeze(safeTracks);
}
const immutablePayload = Object.freeze({ ...payload, tracks: safeTracks });
for (const extension of this.extensions) {
const hook = extension.afterPlay;
if (typeof hook !== "function")
continue;
try {
await Promise.resolve(hook.call(extension, this.extensionContext, immutablePayload));
}
catch (err) {
this.debug(`[Player] Extension ${extension.name} afterPlay error:`, err);
}
}
}
async extensionsProvideSearch(query, requestedBy) {
const request = { query, requestedBy };
for (const extension of this.extensions) {
const hook = extension.provideSearch;
if (typeof hook !== "function")
continue;
try {
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
if (result && Array.isArray(result.tracks) && result.tracks.length > 0) {
this.debug(`[Player] Extension ${extension.name} handled search for query: ${query}`);
return result;
}
}
catch (err) {
this.debug(`[Player] Extension ${extension.name} provideSearch error:`, err);
}
}
return null;
}
async extensionsProvideStream(track) {
const request = { track };
for (const extension of this.extensions) {
const hook = extension.provideStream;
if (typeof hook !== "function")
continue;
try {
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
if (result && result.stream) {
this.debug(`[Player] Extension ${extension.name} provided stream for track: ${track.title}`);
return result;
}
}
catch (err) {
this.debug(`[Player] Extension ${extension.name} provideStream error:`, err);
}
}
return null;
}
/**
* Start playing a specific track immediately, replacing the current resource.
*/
async startTrack(track) {
try {
let streamInfo = await this.extensionsProvideStream(track);
let plugin;
if (!streamInfo) {
plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
if (!plugin) {
this.debug(`[Player] No plugin found for track: ${track.title}`);
throw new Error(`No plugin found for track: ${track.title}`);
}
this.debug(`[Player] Getting stream for track: ${track.title}`);
this.debug(`[Player] Using plugin: ${plugin.name}`);
this.debug(`[Track] Track Info:`, track);
try {
streamInfo = await (0, timeout_1.withTimeout)(plugin.getStream(track), this.options.extractorTimeout ?? 15000, "getStream timed out");
}
catch (streamError) {
this.debug(`[Player] getStream failed, trying getFallback:`, streamError);
const allplugs = this.pluginManager.getAll();
for (const p of allplugs) {
if (typeof p.getFallback !== "function") {
continue;
}
try {
streamInfo = await (0, timeout_1.withTimeout)(p.getFallback(track), this.options.extractorTimeout ?? 15000, `getFallback timed out for plugin ${p.name}`);
if (!streamInfo?.stream)
continue;
this.debug(`[Player] getFallback succeeded with plugin ${p.name} for track: ${track.title}`);
break;
}
catch (fallbackError) {
this.debug(`[Player] getFallback failed with plugin ${p.name}:`, fallbackError);
}
}
if (!streamInfo?.stream) {
throw new Error(`All getFallback attempts failed for track: ${track.title}`);
}
}
}
else {
this.debug(`[Player] Using extension-provided stream for track: ${track.title}`);
}
if (plugin) {
this.debug(streamInfo);
}
// Kiểm tra nếu có stream thực sự để tạo AudioResource
if (streamInfo && streamInfo.stream) {
function mapToStreamType(type) {
switch (type) {
case "webm/opus":
return voice_1.StreamType.WebmOpus;
case "ogg/opus":
return voice_1.StreamType.OggOpus;
case "arbitrary":
default:
return voice_1.StreamType.Arbitrary;
}
}
const stream = streamInfo.stream;
const inputType = mapToStreamType(streamInfo.type);
this.currentResource = (0, voice_1.createAudioResource)(stream, {
metadata: track,
inputType,
inlineVolume: true,
});
// Apply initial volume using the resource's VolumeTransformer
if (this.volumeInterval) {
clearInterval(this.volumeInterval);
this.volumeInterval = null;
}
this.currentResource.volume?.setVolume(this.volume / 100);
this.debug(`[Player] Playing resource for track: ${track.title}`);
this.audioPlayer.play(this.currentResource);
await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 5000);
return true;
}
else if (streamInfo && !streamInfo.stream) {
// Extension đang xử lý phát nhạc (như Lavalink) - chỉ đánh dấu đang phát
this.debug(`[Player] Extension is handling playback for track: ${track.title}`);
this.isPlaying = true;
this.isPaused = false;
this.emit("trackStart", track);
return true;
}
else {
throw new Error(`No stream available for track: ${track.title}`);
}
}
catch (error) {
this.debug(`[Player] startTrack error:`, error);
this.emit("playerError", error, track);
return false;
}
}
clearLeaveTimeout() {
if (this.leaveTimeout) {
clearTimeout(this.leaveTimeout);
this.leaveTimeout = null;
this.debug(`[Player] Cleared leave timeout`);
}
}
debug(message, ...optionalParams) {
if (this.listenerCount("debug") > 0) {
this.emit("debug", message, ...optionalParams);
}
}
constructor(guildId, options = {}, manager) {
super();
this.connection = null;
this.volume = 100;
this.isPlaying = false;
this.isPaused = false;
this.leaveTimeout = null;
this.currentResource = null;
this.volumeInterval = null;
this.skipLoop = false;
this.extensions = [];
// TTS support
this.ttsPlayer = null;
this.ttsQueue = [];
this.ttsActive = false;
this.debug(`[Player] Constructor called for guildId: ${guildId}`);
this.guildId = guildId;
this.queue = new Queue_1.Queue();
this.manager = manager;
this.audioPlayer = (0, voice_1.createAudioPlayer)({
behaviors: {
noSubscriber: voice_1.NoSubscriberBehavior.Pause,
maxMissedFrames: 100,
},
});
this.pluginManager = new plugins_1.PluginManager();
this.options = {
leaveOnEnd: true,
leaveOnEmpty: true,
leaveTimeout: 100000,
volume: 100,
quality: "high",
extractorTimeout: 50000,
selfDeaf: true,
selfMute: false,
...options,
tts: {
createPlayer: false,
interrupt: true,
volume: 100,
Max_Time_TTS: 60000,
...(options?.tts || {}),
},
};
this.volume = this.options.volume || 100;
this.userdata = this.options.userdata;
this.setupEventListeners();
this.extensionContext = Object.freeze({ player: this, manager });
// Optionally pre-create the TTS AudioPlayer
if (this.options?.tts?.createPlayer) {
this.ensureTTSPlayer();
}
}
setupEventListeners() {
this.audioPlayer.on("stateChange", (oldState, newState) => {
this.debug(`[Player] AudioPlayer stateChange from ${oldState.status} to ${newState.status}`);
if (newState.status === voice_1.AudioPlayerStatus.Idle && oldState.status !== voice_1.AudioPlayerStatus.Idle) {
// Track ended
const track = this.queue.currentTrack;
if (track) {
this.debug(`[Player] Track ended: ${track.title}`);
this.emit("trackEnd", track);
}
this.playNext();
}
else if (newState.status === voice_1.AudioPlayerStatus.Playing &&
(oldState.status === voice_1.AudioPlayerStatus.Idle || oldState.status === voice_1.AudioPlayerStatus.Buffering)) {
// Track started
this.clearLeaveTimeout();
this.isPlaying = true;
this.isPaused = false;
const track = this.queue.currentTrack;
if (track) {
this.debug(`[Player] Track started: ${track.title}`);
this.emit("trackStart", track);
}
}
else if (newState.status === voice_1.AudioPlayerStatus.Paused && oldState.status !== voice_1.AudioPlayerStatus.Paused) {
// Track paused
this.isPaused = true;
const track = this.queue.currentTrack;
if (track) {
this.debug(`[Player] Player paused on track: ${track.title}`);
this.emit("playerPause", track);
}
}
else if (newState.status !== voice_1.AudioPlayerStatus.Paused && oldState.status === voice_1.AudioPlayerStatus.Paused) {
// Track resumed
this.isPaused = false;
const track = this.queue.currentTrack;
if (track) {
this.debug(`[Player] Player resumed on track: ${track.title}`);
this.emit("playerResume", track);
}
}
else if (newState.status === voice_1.AudioPlayerStatus.AutoPaused) {
this.debug(`[Player] AudioPlayerStatus.AutoPaused`);
}
else if (newState.status === voice_1.AudioPlayerStatus.Buffering) {
this.debug(`[Player] AudioPlayerStatus.Buffering`);
}
});
this.audioPlayer.on("error", (error) => {
this.debug(`[Player] AudioPlayer error:`, error);
this.emit("playerError", error, this.queue.currentTrack || undefined);
this.playNext();
});
this.audioPlayer.on("debug", (...args) => {
if (this.manager.debugEnabled) {
this.emit("debug", ...args);
}
});
}
ensureTTSPlayer() {
if (this.ttsPlayer)
return this.ttsPlayer;
this.ttsPlayer = (0, voice_1.createAudioPlayer)({
behaviors: {
noSubscriber: voice_1.NoSubscriberBehavior.Pause,
maxMissedFrames: 100,
},
});
this.ttsPlayer.on("error", (e) => this.debug("[TTS] error:", e));
return this.ttsPlayer;
}
addPlugin(plugin) {
this.debug(`[Player] Adding plugin: ${plugin.name}`);
this.pluginManager.register(plugin);
}
removePlugin(name) {
this.debug(`[Player] Removing plugin: ${name}`);
return this.pluginManager.unregister(name);
}
/**
* Connect to a voice channel
*
* @param {VoiceChannel} channel - Discord voice channel
* @returns {Promise<VoiceConnection>} The voice connection
* @example
* await player.connect(voiceChannel);
*/
async connect(channel) {
try {
this.debug(`[Player] Connecting to voice channel: ${channel.id}`);
const connection = (0, voice_1.joinVoiceChannel)({
channelId: channel.id,
guildId: channel.guildId,
adapterCreator: channel.guild.voiceAdapterCreator,
selfDeaf: this.options.selfDeaf ?? true,
selfMute: this.options.selfMute ?? false,
});
await (0, voice_1.entersState)(connection, voice_1.VoiceConnectionStatus.Ready, 50000);
this.connection = connection;
connection.on(voice_1.VoiceConnectionStatus.Disconnected, () => {
this.debug(`[Player] VoiceConnectionStatus.Disconnected`);
this.destroy();
});
connection.on("error", (error) => {
this.debug(`[Player] Voice connection error:`, error);
this.emit("connectionError", error);
});
connection.subscribe(this.audioPlayer);
this.clearLeaveTimeout();
return this.connection;
}
catch (error) {
this.debug(`[Player] Connection error:`, error);
this.emit("connectionError", error);
this.connection?.destroy();
throw error;
}
}
/**
* Search for tracks using the player's extensions and plugins
*
* @param {string} query - The query to search for
* @param {string} requestedBy - The user ID who requested the search
* @returns {Promise<SearchResult>} The search result
* @example
* const result = await player.search("Never Gonna Give You Up", userId);
* console.log(`Search result: ${result.tracks.length} tracks`);
*/
async search(query, requestedBy) {
this.debug(`[Player] Search called with query: ${query}, requestedBy: ${requestedBy}`);
const extensionResult = await this.extensionsProvideSearch(query, requestedBy);
if (extensionResult && Array.isArray(extensionResult.tracks) && extensionResult.tracks.length > 0) {
this.debug(`[Player] Extension handled search for query: ${query}`);
return extensionResult;
}
const plugins = this.pluginManager.getAll();
let lastError = null;
for (const p of plugins) {
try {
this.debug(`[Player] Trying plugin for search: ${p.name}`);
const res = await (0, timeout_1.withTimeout)(p.search(query, requestedBy), this.options.extractorTimeout ?? 15000, `Search operation timed out for ${p.name}`);
if (res && Array.isArray(res.tracks) && res.tracks.length > 0) {
this.debug(`[Player] Plugin '${p.name}' returned ${res.tracks.length} tracks`);
return res;
}
this.debug(`[Player] Plugin '${p.name}' returned no tracks`);
}
catch (error) {
lastError = error;
this.debug(`[Player] Search via plugin '${p.name}' failed:`, error);
// Continue to next plugin
}
}
this.debug(`[Player] No plugins returned results for query: ${query}`);
if (lastError)
this.emit("playerError", lastError);
throw new Error(`No plugin found to handle: ${query}`);
}
/**
* Play a track or search query
*
* @param {string | Track} query - Track URL, search query, or Track object
* @param {string} requestedBy - User ID who requested the track
* @returns {Promise<boolean>} True if playback started successfully
* @example
* await player.play("Never Gonna Give You Up", userId);
* await player.play("https://youtube.com/watch?v=dQw4w9WgXcQ", userId);
* await player.play("tts: Hello everyone!", userId);
*/
async play(query, requestedBy) {
this.debug(`[Player] Play called with query: ${typeof query === "string" ? query : query?.title}`);
this.clearLeaveTimeout();
let tracksToAdd = [];
let isPlaylist = false;
let effectiveRequest = { query, requestedBy };
let hookResponse = {};
try {
const hookOutcome = await this.runBeforePlayHooks(effectiveRequest);
effectiveRequest = hookOutcome.request;
hookResponse = hookOutcome.response;
if (effectiveRequest.requestedBy === undefined) {
effectiveRequest.requestedBy = requestedBy;
}
const hookTracks = Array.isArray(hookResponse.tracks) ? hookResponse.tracks : undefined;
if (hookResponse.handled && (!hookTracks || hookTracks.length === 0)) {
const handledPayload = {
success: hookResponse.success ?? true,
query: effectiveRequest.query,
requestedBy: effectiveRequest.requestedBy,
tracks: [],
isPlaylist: hookResponse.isPlaylist ?? false,
error: hookResponse.error,
};
await this.runAfterPlayHooks(handledPayload);
if (hookResponse.error) {
this.emit("playerError", hookResponse.error);
}
return hookResponse.success ?? true;
}
if (hookTracks && hookTracks.length > 0) {
tracksToAdd = hookTracks;
isPlaylist = hookResponse.isPlaylist ?? hookTracks.length > 1;
}
else if (typeof effectiveRequest.query === "string") {
const searchResult = await this.search(effectiveRequest.query, effectiveRequest.requestedBy || "Unknown");
tracksToAdd = searchResult.tracks;
if (searchResult.playlist) {
isPlaylist = true;
this.debug(`[Player] Added playlist: ${searchResult.playlist.name} (${tracksToAdd.length} tracks)`);
}
}
else if (effectiveRequest.query) {
tracksToAdd = [effectiveRequest.query];
}
if (tracksToAdd.length === 0) {
this.debug(`[Player] No tracks found for play`);
throw new Error("No tracks found");
}
const isTTS = (t) => {
if (!t)
return false;
try {
return typeof t.source === "string" && t.source.toLowerCase().includes("tts");
}
catch {
return false;
}
};
const queryLooksTTS = typeof effectiveRequest.query === "string" && effectiveRequest.query.trim().toLowerCase().startsWith("tts");
if (!isPlaylist &&
tracksToAdd.length > 0 &&
this.options?.tts?.interrupt !== false &&
(isTTS(tracksToAdd[0]) || queryLooksTTS)) {
this.debug(`[Player] Interrupting with TTS: ${tracksToAdd[0].title}`);
await this.interruptWithTTSTrack(tracksToAdd[0]);
await this.runAfterPlayHooks({
success: true,
query: effectiveRequest.query,
requestedBy: effectiveRequest.requestedBy,
tracks: tracksToAdd,
isPlaylist,
});
return true;
}
if (isPlaylist) {
this.queue.addMultiple(tracksToAdd);
this.emit("queueAddList", tracksToAdd);
}
else {
this.queue.add(tracksToAdd[0]);
this.emit("queueAdd", tracksToAdd[0]);
}
const started = !this.isPlaying ? await this.playNext() : true;
await this.runAfterPlayHooks({
success: started,
query: effectiveRequest.query,
requestedBy: effectiveRequest.requestedBy,
tracks: tracksToAdd,
isPlaylist,
});
return started;
}
catch (error) {
await this.runAfterPlayHooks({
success: false,
query: effectiveRequest.query,
requestedBy: effectiveRequest.requestedBy,
tracks: tracksToAdd,
isPlaylist,
error: error,
});
this.debug(`[Player] Play error:`, error);
this.emit("playerError", error);
return false;
}
}
/**
* Interrupt current music with a TTS track. Pauses music, swaps the
* subscription to a dedicated TTS player, plays TTS, then resumes.
*
* @param {Track} track - The track to interrupt with
* @returns {Promise<void>}
* @example
* await player.interruptWithTTSTrack(track);
*/
async interruptWithTTSTrack(track) {
this.ttsQueue.push(track);
if (!this.ttsActive) {
void this.playNextTTS();
}
}
/**
* Play queued TTS items sequentially
*
* @returns {Promise<void>}
* @example
* await player.playNextTTS();
*/
async playNextTTS() {
const next = this.ttsQueue.shift();
if (!next)
return;
this.ttsActive = true;
try {
if (!this.connection)
throw new Error("No voice connection for TTS");
const ttsPlayer = this.ensureTTSPlayer();
// Build resource from plugin stream
const resource = await this.resourceFromTrack(next);
if (resource.volume) {
resource.volume.setVolume((this.options?.tts?.volume ?? this?.volume ?? 100) / 100);
}
const wasPlaying = this.audioPlayer.state.status === voice_1.AudioPlayerStatus.Playing ||
this.audioPlayer.state.status === voice_1.AudioPlayerStatus.Buffering;
// Pause current music if any
try {
this.audioPlayer.pause(true);
}
catch { }
// Swap subscription and play TTS
this.connection.subscribe(ttsPlayer);
this.emit("ttsStart", { track: next });
ttsPlayer.play(resource);
// Wait until TTS starts then finishes
await (0, voice_1.entersState)(ttsPlayer, voice_1.AudioPlayerStatus.Playing, 5000).catch(() => null);
// Derive timeout from resource/track duration when available, with a sensible cap
const md = resource?.metadata ?? {};
const declared = typeof md.duration === "number" ? md.duration
: typeof next?.duration === "number" ? next.duration
: undefined;
const declaredMs = declared ?
declared > 1000 ?
declared
: declared * 1000
: undefined;
const cap = this.options?.tts?.Max_Time_TTS ?? 60000;
const idleTimeout = declaredMs ? Math.min(cap, Math.max(1000, declaredMs + 1500)) : cap;
await (0, voice_1.entersState)(ttsPlayer, voice_1.AudioPlayerStatus.Idle, idleTimeout).catch(() => null);
// Swap back and resume if needed
this.connection.subscribe(this.audioPlayer);
if (wasPlaying) {
try {
this.audioPlayer.unpause();
}
catch { }
}
this.emit("ttsEnd");
}
catch (err) {
this.debug("[TTS] error while playing:", err);
this.emit("playerError", err);
}
finally {
this.ttsActive = false;
if (this.ttsQueue.length > 0) {
await this.playNextTTS();
}
}
}
/** Build AudioResource for a given track using the plugin pipeline */
async resourceFromTrack(track) {
// Resolve plugin similar to playNext
const plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
if (!plugin)
throw new Error(`No plugin found for track: ${track.title}`);
let streamInfo;
try {
streamInfo = await (0, timeout_1.withTimeout)(plugin.getStream(track), this.options.extractorTimeout ?? 15000, "getStream timed out");
}
catch (streamError) {
// try fallbacks
const allplugs = this.pluginManager.getAll();
for (const p of allplugs) {
if (typeof p.getFallback !== "function")
continue;
try {
streamInfo = await (0, timeout_1.withTimeout)(p.getFallback(track), this.options.extractorTimeout ?? 15000, `getFallback timed out for plugin ${p.name}`);
if (!streamInfo?.stream)
continue;
break;
}
catch { }
}
if (!streamInfo?.stream)
throw new Error(`All getFallback attempts failed for track: ${track.title}`);
}
const mapToStreamType = (type) => {
switch (type) {
case "webm/opus":
return voice_1.StreamType.WebmOpus;
case "ogg/opus":
return voice_1.StreamType.OggOpus;
case "arbitrary":
default:
return voice_1.StreamType.Arbitrary;
}
};
const inputType = mapToStreamType(streamInfo.type);
return (0, voice_1.createAudioResource)(streamInfo.stream, {
// Prefer plugin-provided metadata (e.g., precise duration), fallback to track fields
metadata: {
...track,
...(streamInfo?.metadata || {}),
},
inputType,
inlineVolume: true,
});
}
async generateWillNext() {
const lastTrack = this.queue.previousTracks[this.queue.previousTracks.length - 1] ?? this.queue.currentTrack;
if (!lastTrack)
return;
// Build list of candidate plugins: preferred first, then others with getRelatedTracks
const preferred = this.pluginManager.findPlugin(lastTrack.url) || this.pluginManager.get(lastTrack.source);
const all = this.pluginManager.getAll();
const candidates = [...(preferred ? [preferred] : []), ...all.filter((p) => p !== preferred)].filter((p) => typeof p.getRelatedTracks === "function");
for (const p of candidates) {
try {
this.debug(`[Player] Trying related from plugin: ${p.name}`);
const related = await (0, timeout_1.withTimeout)(p.getRelatedTracks(lastTrack.url, {
limit: 10,
history: this.queue.previousTracks,
}), this.options.extractorTimeout ?? 15000, `getRelatedTracks timed out for ${p.name}`);
if (Array.isArray(related) && related.length > 0) {
const randomchoice = Math.floor(Math.random() * related.length);
const nextTrack = this.queue.nextTrack ? this.queue.nextTrack : related[randomchoice];
this.queue.willNextTrack(nextTrack);
this.queue.relatedTracks(related);
this.debug(`[Player] Will next track if autoplay: ${nextTrack?.title} (via ${p.name})`);
this.emit("willPlay", nextTrack, related);
return; // success
}
this.debug(`[Player] ${p.name} returned no related tracks`);
}
catch (err) {
this.debug(`[Player] getRelatedTracks error from ${p.name}:`, err);
// try next candidate
}
}
}
async playNext() {
this.debug(`[Player] playNext called`);
const track = this.queue.next(this.skipLoop);
this.skipLoop = false;
if (!track) {
if (this.queue.autoPlay()) {
const willnext = this.queue.willNextTrack();
if (willnext) {
this.debug(`[Player] Auto-playing next track: ${willnext.title}`);
this.queue.addMultiple([willnext]);
return this.playNext();
}
}
this.debug(`[Player] No next track in queue`);
this.isPlaying = false;
this.emit("queueEnd");
if (this.options.leaveOnEnd) {
this.scheduleLeave();
}
return false;
}
this.generateWillNext();
// A new track is about to play; ensure we don't leave mid-playback
this.clearLeaveTimeout();
try {
return await this.startTrack(track);
}
catch (error) {
this.debug(`[Player] playNext error:`, error);
this.emit("playerError", error, track);
return this.playNext();
}
}
/**
* Pause the current track
*
* @returns {boolean} True if paused successfully
* @example
* const paused = player.pause();
* console.log(`Paused: ${paused}`);
*/
pause() {
this.debug(`[Player] pause called`);
if (this.isPlaying && !this.isPaused) {
return this.audioPlayer.pause();
}
return false;
}
/**
* Resume the current track
*
* @returns {boolean} True if resumed successfully
* @example
* const resumed = player.resume();
* console.log(`Resumed: ${resumed}`);
*/
resume() {
this.debug(`[Player] resume called`);
if (this.isPaused) {
const result = this.audioPlayer.unpause();
if (result) {
const track = this.queue.currentTrack;
if (track) {
this.debug(`[Player] Player resumed on track: ${track.title}`);
this.emit("playerResume", track);
}
}
return result;
}
return false;
}
/**
* Stop the current track
*
* @returns {boolean} True if stopped successfully
* @example
* const stopped = player.stop();
* console.log(`Stopped: ${stopped}`);
*/
stop() {
this.debug(`[Player] stop called`);
this.queue.clear();
const result = this.audioPlayer.stop();
this.isPlaying = false;
this.isPaused = false;
this.emit("playerStop");
return result;
}
/**
* Skip to the next track
*
* @returns {boolean} True if skipped successfully
* @example
* const skipped = player.skip();
* console.log(`Skipped: ${skipped}`);
*/
skip() {
this.debug(`[Player] skip called`);
if (this.isPlaying || this.isPaused) {
this.skipLoop = true;
return this.audioPlayer.stop();
}
return !!this.playNext();
}
/**
* Go back to the previous track in history and play it.
*
* @returns {Promise<boolean>} True if previous track was played successfully
* @example
* const previous = await player.previous();
* console.log(`Previous: ${previous}`);
*/
async previous() {
this.debug(`[Player] previous called`);
const track = this.queue.previous();
if (!track)
return false;
if (this.queue.currentTrack)
this.insert(this.queue.currentTrack, 0);
this.clearLeaveTimeout();
return this.startTrack(track);
}
/**
* Loop the current track
*
* @param {LoopMode} mode - The loop mode to set
* @returns {LoopMode} The loop mode
* @example
* const loopMode = player.loop("track");
* console.log(`Loop mode: ${loopMode}`);
*/
loop(mode) {
return this.queue.loop(mode);
}
/**
* Set the auto-play mode
*
* @param {boolean} mode - The auto-play mode to set
* @returns {boolean} The auto-play mode
* @example
* const autoPlayMode = player.autoPlay(true);
* console.log(`Auto-play mode: ${autoPlayMode}`);
*/
autoPlay(mode) {
return this.queue.autoPlay(mode);
}
/**
* Set the volume of the current track
*
* @param {number} volume - The volume to set
* @returns {boolean} True if volume was set successfully
* @example
* const volumeSet = player.setVolume(50);
* console.log(`Volume set: ${volumeSet}`);
*/
setVolume(volume) {
this.debug(`[Player] setVolume called: ${volume}`);
if (volume < 0 || volume > 200)
return false;
const oldVolume = this.volume;
this.volume = volume;
const resourceVolume = this.currentResource?.volume;
if (resourceVolume) {
if (this.volumeInterval)
clearInterval(this.volumeInterval);
const start = resourceVolume.volume;
const target = this.volume / 100;
const steps = 10;
let currentStep = 0;
this.volumeInterval = setInterval(() => {
currentStep++;
const value = start + ((target - start) * currentStep) / steps;
resourceVolume.setVolume(value);
if (currentStep >= steps) {
clearInterval(this.volumeInterval);
this.volumeInterval = null;
}
}, 300);
}
this.emit("volumeChange", oldVolume, volume);
return true;
}
/**
* Shuffle the queue
*
* @returns {void}
* @example
* player.shuffle();
*/
shuffle() {
this.debug(`[Player] shuffle called`);
this.queue.shuffle();
}
/**
* Clear the queue
*
* @returns {void}
* @example
* player.clearQueue();
*/
clearQueue() {
this.debug(`[Player] clearQueue called`);
this.queue.clear();
}
/**
* Insert a track or list of tracks into the upcoming queue at a specific position (0 = play after current).
* - If `query` is a string, performs a search and inserts resulting tracks (playlist supported).
* - If a Track or Track[] is provided, inserts directly.
* Does not auto-start playback; it only modifies the queue.
*
* @param {string | Track | Track[]} query - The track or tracks to insert
* @param {number} index - The index to insert the tracks at
* @param {string} requestedBy - The user ID who requested the insert
* @returns {Promise<boolean>} True if the tracks were inserted successfully
* @example
* const inserted = await player.insert("Song Name", 0, userId);
* console.log(`Inserted: ${inserted}`);
*/
async insert(query, index, requestedBy) {
try {
this.debug(`[Player] insert called at index ${index} with type: ${typeof query}`);
let tracksToAdd = [];
let isPlaylist = false;
if (typeof query === "string") {
const searchResult = await this.search(query, requestedBy || "Unknown");
tracksToAdd = searchResult.tracks || [];
isPlaylist = !!searchResult.playlist;
}
else if (Array.isArray(query)) {
tracksToAdd = query;
isPlaylist = query.length > 1;
}
else if (query) {
tracksToAdd = [query];
}
if (!tracksToAdd || tracksToAdd.length === 0) {
this.debug(`[Player] insert: no tracks resolved`);
throw new Error("No tracks to insert");
}
if (tracksToAdd.length === 1) {
this.queue.insert(tracksToAdd[0], index);
this.emit("queueAdd", tracksToAdd[0]);
this.debug(`[Player] Inserted track at index ${index}: ${tracksToAdd[0].title}`);
}
else {
this.queue.insertMultiple(tracksToAdd, index);
this.emit("queueAddList", tracksToAdd);
this.debug(`[Player] Inserted ${tracksToAdd.length} ${isPlaylist ? "playlist " : ""}tracks at index ${index}`);
}
return true;
}
catch (error) {
this.debug(`[Player] insert error:`, error);
this.emit("playerError", error);
return false;
}
}
/**
* Remove a track from the queue
*
* @param {number} index - The index of the track to remove
* @returns {Track | null} The removed track or null
* @example
* const removed = player.remove(0);
* console.log(`Removed: ${removed?.title}`);
*/
remove(index) {
this.debug(`[Player] remove called for index: ${index}`);
const track = this.queue.remove(index);
if (track) {
this.emit("queueRemove", track, index);
}
return track;
}
/**
* Get the progress bar of the current track
*
* @param {ProgressBarOptions} options - The options for the progress bar
* @returns {string} The progress bar
* @example
* const progressBar = player.getProgressBar();
* console.log(`Progress bar: ${progressBar}`);
*/
getProgressBar(options = {}) {
const { size = 20, barChar = "▬", progressChar = "🔘" } = options;
const track = this.queue.currentTrack;
const resource = this.currentResource;
if (!track || !resource)
return "";
const total = track.duration > 1000 ? track.duration : track.duration * 1000;
if (!total)
return this.formatTime(resource.playbackDuration);
const current = resource.playbackDuration;
const ratio = Math.min(current / total, 1);
const progress = Math.round(ratio * size);
const bar = barChar.repeat(progress) + progressChar + barChar.repeat(size - progress);
return `${this.formatTime(current)} | ${bar} | ${this.formatTime(total)}`;
}
/**
* Get the time of the current track
*
* @returns {Object} The time of the current track
* @example
* const time = player.getTime();
* console.log(`Time: ${time.current}`);
*/
getTime() {
const resource = this.currentResource;
const track = this.queue.currentTrack;
if (!track || !resource)
return {
current: 0,
total: 0,
format: "00:00",
};
const total = track.duration > 1000 ? track.duration : track.duration * 1000;
return {
current: resource?.playbackDuration,
total: total,
format: this.formatTime(resource.playbackDuration),
};
}
/**
* Format the time in the format of HH:MM:SS
*
* @param {number} ms - The time in milliseconds
* @returns {string} The formatted time
* @example
* const formattedTime = player.formatTime(1000);
* console.log(`Formatted time: ${formattedTime}`);
*/
formatTime(ms) {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const parts = [];
if (hours > 0)
parts.push(String(hours).padStart(2, "0"));
parts.push(String(minutes).padStart(2, "0"));
parts.push(String(seconds).padStart(2, "0"));
return parts.join(":");
}
scheduleLeave() {
this.debug(`[Player] scheduleLeave called`);
if (this.leaveTimeout) {
clearTimeout(this.leaveTimeout);
}
if (this.options.leaveOnEmpty && this.options.leaveTimeout) {
this.leaveTimeout = setTimeout(() => {
this.debug(`[Player] Leaving voice channel after timeout`);
this.destroy();
}, this.options.leaveTimeout);
}
}
/**
* Destroy the player
*
* @returns {void}
* @example
* player.destroy();
*/
destroy() {
this.debug(`[Player] destroy called`);
if (this.leaveTimeout) {
clearTimeout(this.leaveTimeout);
this.leaveTimeout = null;
}
this.audioPlayer.stop(true);
if (this.ttsPlayer) {
try {
this.ttsPlayer.stop(true);
}
catch { }
this.ttsPlayer = null;
}
if (this.connection) {
this.connection.destroy();
this.connection = null;
}
this.queue.clear();
this.pluginManager.clear();
for (const extension of [...this.extensions]) {
this.invokeExtensionLifecycle(extension, "onDestroy");
if (extension.player === this) {
extension.player = null;
}
}
this.extensions = [];
this.isPlaying = false;
this.isPaused = false;
this.emit("playerDestroy");
this.removeAllListeners();
}
/**
* Get the size of the queue
*
* @returns {number} The size of the queue
* @example
* const queueSize = player.queueSize;
* console.log(`Queue size: ${queueSize}`);
*/
get queueSize() {
return this.queue.size;
}
/**
* Get the current track
*
* @returns {Track | null} The current track or null
* @example
* const currentTrack = player.currentTrack;
* console.log(`Current track: ${currentTrack?.title}`);
*/
get currentTrack() {
return this.queue.currentTrack;
}
/**
* Get the previous track
*
* @returns {Track | null} The previous track or null
* @example
* const previousTrack = player.previousTrack;
* console.log(`Previous track: ${previousTrack?.title}`);
*/
get previousTrack() {
return this.queue.previousTracks?.at(-1) ?? null;
}
/**
* Get the upcoming tracks
*
* @returns {Track[]} The upcoming tracks
* @example
* const upcomingTracks = player.upcomingTracks;
* console.log(`Upcoming tracks: ${upcomingTracks.length}`);
*/
get upcomingTracks() {
return this.queue.getTracks();
}
/**
* Get the previous tracks
*
* @returns {Track[]} The previous tracks
* @example
* const previousTracks = player.previousTracks;
* console.log(`Previous tracks: ${previousTracks.length}`);
*/
get previousTracks() {
return this.queue.previousTracks;
}
/**
* Get the available plugins
*
* @returns {string[]} The available plugins
* @example
* const availablePlugins = player.availablePlugins;
* console.log(`Available plugins: ${availablePlugins.length}`);
*/
get availablePlugins() {
return this.pluginManager.getAll().map((p) => p.name);
}
/**
* Get the related tracks
*
* @returns {Track[] | null} The related tracks or null
* @example
* const relatedTracks = player.relatedTracks;
* console.log(`Related tracks: ${relatedTracks?.length}`);
*/
get relatedTracks() {
return this.queue.relatedTracks();
}
}
exports.Player = Player;
//# sourceMappingURL=Player.js.map