|
|
|
@ -5,218 +5,363 @@ const Discord = require("discord.js"),
|
|
|
|
|
ytapiKey = "AIzaSyBLF20r-c4mXoAT2qBFB5YlCgT0D-izOaU";
|
|
|
|
|
/* Variable Definition */
|
|
|
|
|
let logger = require('winston');
|
|
|
|
|
let client = null;
|
|
|
|
|
let djs = {};
|
|
|
|
|
let connections = {};
|
|
|
|
|
|
|
|
|
|
/* Function Definition */
|
|
|
|
|
// TODO: initCommands function that takes the cmd.js module as variable and uses it to create commands
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Getting the logger;
|
|
|
|
|
* @param {Object} newLogger
|
|
|
|
|
*/
|
|
|
|
|
exports.setLogger = function (newLogger) {
|
|
|
|
|
logger = newLogger;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Sets the discord Client for the module
|
|
|
|
|
* @param newClient
|
|
|
|
|
*/
|
|
|
|
|
exports.setClient = function(newClient) {
|
|
|
|
|
client = newClient;
|
|
|
|
|
};
|
|
|
|
|
class DJ {
|
|
|
|
|
constructor(voiceChannel) {
|
|
|
|
|
this.conn = null;
|
|
|
|
|
this.disp = null;
|
|
|
|
|
this.queue = [];
|
|
|
|
|
this.playing = false;
|
|
|
|
|
this.current = null;
|
|
|
|
|
this.volume = 0.5;
|
|
|
|
|
this.voiceChannel = voiceChannel;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Connects to a voicechannel
|
|
|
|
|
* @param voiceChannel
|
|
|
|
|
*/
|
|
|
|
|
exports.connect = function(voiceChannel) {
|
|
|
|
|
logger.debug(JSON.stringify());
|
|
|
|
|
logger.verbose(`Connecting to voiceChannel ${voiceChannel.name}`);
|
|
|
|
|
if (client !== null) {
|
|
|
|
|
voiceChannel.join().then(connection => {
|
|
|
|
|
logger.info(`Connected to Voicechannel ${voiceChannel.name}`);
|
|
|
|
|
connections[voiceChannel.guild.id] = {
|
|
|
|
|
'conn': connection,
|
|
|
|
|
'disp': null,
|
|
|
|
|
'queue': [],
|
|
|
|
|
'playing': false,
|
|
|
|
|
current: null
|
|
|
|
|
};
|
|
|
|
|
/**
|
|
|
|
|
* Connects to the given voice channel. Disconnects from the previous one if it exists.
|
|
|
|
|
* When the bot was moved and connect is executed again, it connects to the initial VoiceChannel because the
|
|
|
|
|
* VoiceChannel is saved as object variable.
|
|
|
|
|
* @returns {Promise<T | never>}
|
|
|
|
|
*/
|
|
|
|
|
connect() {
|
|
|
|
|
if (this.conn) {
|
|
|
|
|
this.stop();
|
|
|
|
|
}
|
|
|
|
|
logger.verbose(`Connecting to voiceChannel ${this.voiceChannel.name}`);
|
|
|
|
|
return this.voiceChannel.join().then(connection => {
|
|
|
|
|
logger.info(`Connected to Voicechannel ${this.voiceChannel.name}`);
|
|
|
|
|
this.conn = connection;
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
logger.error("Client is null");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Plays a file
|
|
|
|
|
* @param filename
|
|
|
|
|
*/
|
|
|
|
|
exports.playFile = function(voiceChannel, filename) {
|
|
|
|
|
let gid = voiceChannel.guild.id;
|
|
|
|
|
let conn = connections[gid].conn;
|
|
|
|
|
if (conn !== null) {
|
|
|
|
|
connections[gid].disp = conn.playFile(filename);
|
|
|
|
|
connections[gid].playing = true;
|
|
|
|
|
} else {
|
|
|
|
|
this.connect(voiceChannel);
|
|
|
|
|
logger.warn("Not connected to a voicechannel");
|
|
|
|
|
/**
|
|
|
|
|
* Plays a file for the given filename.
|
|
|
|
|
* TODO: Implement queue
|
|
|
|
|
* @param filename
|
|
|
|
|
*/
|
|
|
|
|
playFile(filename) {
|
|
|
|
|
if (this.conn !== null) {
|
|
|
|
|
this.disp = this.conn.playFile(filename);
|
|
|
|
|
this.playing = true;
|
|
|
|
|
} else {
|
|
|
|
|
logger.warn("Not connected to a voicechannel. Connection now.");
|
|
|
|
|
this.connect(this.voiceChannel).then(() => {
|
|
|
|
|
this.playFile(filename);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
exports.play = function(voiceChannel, url) {
|
|
|
|
|
let gid = voiceChannel.guild.id;
|
|
|
|
|
if (!connections[gid]) this.connect(voiceChannel);
|
|
|
|
|
let conn = connections[gid].conn;
|
|
|
|
|
if (conn !== null) {
|
|
|
|
|
/**
|
|
|
|
|
* Plays the url of the current song if there is no song playing or puts it in the queue.
|
|
|
|
|
* If the url is a playlist (regex match), the videos of the playlist are fetched and put
|
|
|
|
|
* in the queue. For each song the title is saved in the queue too.
|
|
|
|
|
* @param url
|
|
|
|
|
*/
|
|
|
|
|
playYouTube(url, playnext) {
|
|
|
|
|
if (!this.conn) this.connect(this.voiceChannel).then(this.playYouTube(url));
|
|
|
|
|
let plist = url.match(/(?<=\?list=)[\w\-]+/g);
|
|
|
|
|
if (plist) {
|
|
|
|
|
logger.debug(`Adding playlist ${plist} to queue`);
|
|
|
|
|
ypi(ytapiKey, plist).then(items => {
|
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
|
|
|
let vurl = `https://www.youtube.com/watch?v=${items[i].resourceId.videoId}`;
|
|
|
|
|
connections[gid].queue.push(vurl);
|
|
|
|
|
this.queue.push({'url': vurl, 'title': null});
|
|
|
|
|
yttl(vurl.replace(/http(s)?:\/\/(www.)?youtube.com\/watch\?v=/g, ''), (err, title) => {
|
|
|
|
|
if (err) {
|
|
|
|
|
logger.debug(JSON.stringify(err));
|
|
|
|
|
} else {
|
|
|
|
|
try {
|
|
|
|
|
logger.debug(`Found title: ${title} for ${vurl}`);
|
|
|
|
|
this.queue.find((el) => {
|
|
|
|
|
return (el.url === vurl);
|
|
|
|
|
}).title = title;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.verbose(JSON.stringify(error));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
this.play(voiceChannel, connections[gid].queue.shift());
|
|
|
|
|
this.current = this.queue.shift();
|
|
|
|
|
this.playYouTube(this.current.url);
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!connections[gid].playing) {
|
|
|
|
|
if (!this.playing) {
|
|
|
|
|
logger.debug(`Playing ${url}`);
|
|
|
|
|
connections[gid].disp = conn.playStream(ytdl(url, {
|
|
|
|
|
this.disp = this.conn.playStream(ytdl(url, {
|
|
|
|
|
filter: "audioonly"
|
|
|
|
|
}), {seek: 0, volume: 0.5});
|
|
|
|
|
connections[gid].disp.on('end', () => {
|
|
|
|
|
connections[gid].playing = false;
|
|
|
|
|
connections[gid].current = null;
|
|
|
|
|
if (connections[gid].queue.length > 0) {
|
|
|
|
|
this.play(voiceChannel, connections[gid].queue.shift());
|
|
|
|
|
}), {seek: 0, volume: this.volume});
|
|
|
|
|
this.disp.on('end', () => {
|
|
|
|
|
this.playing = false;
|
|
|
|
|
this.current = null;
|
|
|
|
|
if (this.queue.length > 0) {
|
|
|
|
|
this.current = this.queue.shift();
|
|
|
|
|
this.playYouTube(this.current.url);
|
|
|
|
|
} else {
|
|
|
|
|
this.stop();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
connections[gid].playing = true;
|
|
|
|
|
connections[gid].current = url;
|
|
|
|
|
this.playing = true;
|
|
|
|
|
} else {
|
|
|
|
|
logger.debug(`Added ${url} to the queue`);
|
|
|
|
|
connections[gid].queue.push(url);
|
|
|
|
|
if (playnext) {
|
|
|
|
|
this.queue.unshift({'url': url, 'title': null});
|
|
|
|
|
} else {
|
|
|
|
|
this.queue.push({'url': url, 'title': null});
|
|
|
|
|
}
|
|
|
|
|
yttl(url.replace(/http(s)?:\/\/(www.)?youtube.com\/watch\?v=/g, ''), (err, title) => {
|
|
|
|
|
if (err) {
|
|
|
|
|
logger.debug(JSON.stringify(err));
|
|
|
|
|
} else {
|
|
|
|
|
try {
|
|
|
|
|
logger.debug(`Found title: ${title} for ${url}`);
|
|
|
|
|
this.queue.find((el) => {
|
|
|
|
|
return (el.url === url);
|
|
|
|
|
}).title = title;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.verbose(JSON.stringify(error));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
logger.warn("Not connected to a voicechannel");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Sets the volume of the dispatcher to the given value
|
|
|
|
|
* @param percentage
|
|
|
|
|
*/
|
|
|
|
|
setVolume(percentage) {
|
|
|
|
|
logger.verbose(`Setting volume to ${percentage}`);
|
|
|
|
|
if (this.disp !== null) {
|
|
|
|
|
this.volume = percentage;
|
|
|
|
|
this.disp.setVolume(percentage);
|
|
|
|
|
} else {
|
|
|
|
|
logger.warn("No dispatcher found.")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Pauses if a dispatcher exists
|
|
|
|
|
*/
|
|
|
|
|
pause() {
|
|
|
|
|
logger.verbose("Pausing music...");
|
|
|
|
|
if (this.disp !== null) {
|
|
|
|
|
this.disp.pause();
|
|
|
|
|
} else {
|
|
|
|
|
logger.warn("No dispatcher found");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Resumes if a dispatcher exists
|
|
|
|
|
*/
|
|
|
|
|
resume() {
|
|
|
|
|
logger.verbose("Resuming music...");
|
|
|
|
|
if (this.disp !== null) {
|
|
|
|
|
this.disp.resume();
|
|
|
|
|
} else {
|
|
|
|
|
logger.warn("No dispatcher found");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Stops playing music by ending the Dispatcher and disconnecting
|
|
|
|
|
*/
|
|
|
|
|
stop() {
|
|
|
|
|
this.queue = [];
|
|
|
|
|
logger.verbose("Stopping music...");
|
|
|
|
|
if (this.disp !== null) {
|
|
|
|
|
this.disp.end();
|
|
|
|
|
logger.debug("Ended dispatcher");
|
|
|
|
|
}
|
|
|
|
|
if (this.conn !== null) {
|
|
|
|
|
this.conn.disconnect();
|
|
|
|
|
logger.debug("Ended connection");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Skips to the next song by ending the current StreamDispatcher and thereby triggering the
|
|
|
|
|
* end event of the dispatcher that automatically plays the next song.
|
|
|
|
|
*/
|
|
|
|
|
skip () {
|
|
|
|
|
logger.debug("Skipping song");
|
|
|
|
|
if (this.disp !== null) {
|
|
|
|
|
this.disp.end();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Returns the title for each song saved in the queue
|
|
|
|
|
* @returns {Array}
|
|
|
|
|
*/
|
|
|
|
|
get playlist() {
|
|
|
|
|
let songs = [];
|
|
|
|
|
this.queue.forEach((entry) => {
|
|
|
|
|
songs.push(entry.title);
|
|
|
|
|
});
|
|
|
|
|
return songs;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Returns the song saved in the private variable 'current'
|
|
|
|
|
* @returns {null|*}
|
|
|
|
|
*/
|
|
|
|
|
get song() {
|
|
|
|
|
return this.current;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Shuffles the queue
|
|
|
|
|
*/
|
|
|
|
|
shuffle() {
|
|
|
|
|
this.queue = shuffleArray(this.queue);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Clears the playlist
|
|
|
|
|
*/
|
|
|
|
|
clear() {
|
|
|
|
|
this.queue = [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Getting the logger;
|
|
|
|
|
* @param {Object} newLogger
|
|
|
|
|
*/
|
|
|
|
|
exports.setLogger = function (newLogger) {
|
|
|
|
|
logger = newLogger;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Sets the volume of the music
|
|
|
|
|
* @param percentage
|
|
|
|
|
* Connects to a voicechannel
|
|
|
|
|
* @param voiceChannel
|
|
|
|
|
*/
|
|
|
|
|
exports.setVolume = function(voiceChannel, percentage) {
|
|
|
|
|
let disp = connections[voiceChannel.guild.id].disp;
|
|
|
|
|
logger.verbose(`Setting volume to ${percentage}`);
|
|
|
|
|
if (disp !== null) {
|
|
|
|
|
disp.setVolume(percentage);
|
|
|
|
|
exports.connect = function(voiceChannel) {
|
|
|
|
|
let gid = voiceChannel.guild.id;
|
|
|
|
|
let voiceDJ = new DJ(voiceChannel);
|
|
|
|
|
djs[gid] = voiceDJ;
|
|
|
|
|
return voiceDJ.connect();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Plays a file
|
|
|
|
|
* @param filename
|
|
|
|
|
* @param guildId
|
|
|
|
|
*/
|
|
|
|
|
exports.playFile = function(guildId, filename) {
|
|
|
|
|
djs[guildId].playFile(filename);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Plays a YT Url
|
|
|
|
|
* @param voiceChannel
|
|
|
|
|
* @param url
|
|
|
|
|
*/
|
|
|
|
|
exports.play = function(voiceChannel, url) {
|
|
|
|
|
let guildId = voiceChannel.guild.id;
|
|
|
|
|
if (!djs[guildId]) {
|
|
|
|
|
this.connect(voiceChannel).then(() => {
|
|
|
|
|
djs[guildId].playYouTube(url);
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
logger.warn("No dispatcher found.")
|
|
|
|
|
djs[guildId].playYouTube(url);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* pauses the music
|
|
|
|
|
* plays the given url as next song
|
|
|
|
|
* @param voiceChannel
|
|
|
|
|
* @param url
|
|
|
|
|
*/
|
|
|
|
|
exports.pause = function(voiceChannel) {
|
|
|
|
|
let disp = connections[voiceChannel.guild.id].disp;
|
|
|
|
|
logger.verbose("Pausing music...");
|
|
|
|
|
if (disp !== null) {
|
|
|
|
|
disp.pause();
|
|
|
|
|
exports.playnext = function(voiceChannel, url) {
|
|
|
|
|
let guildId = voiceChannel.guild.id;
|
|
|
|
|
if (!djs[guildId]) {
|
|
|
|
|
this.connect(voiceChannel).then(() => {
|
|
|
|
|
djs[guildId].playYouTube(url, true);
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
logger.warn("No dispatcher found");
|
|
|
|
|
djs[guildId].playYouTube(url, true);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Sets the volume of the music
|
|
|
|
|
* @param percentage
|
|
|
|
|
* @param guildId
|
|
|
|
|
*/
|
|
|
|
|
exports.setVolume = function(guildId, percentage) {
|
|
|
|
|
djs[guildId].setVolume(percentage);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* pauses the music
|
|
|
|
|
*/
|
|
|
|
|
exports.pause = function(guildId) {
|
|
|
|
|
djs[guildId].pause();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Resumes the music
|
|
|
|
|
* @param guildId
|
|
|
|
|
*/
|
|
|
|
|
exports.resume = function(voiceChannel) {
|
|
|
|
|
let disp = connections[voiceChannel.guild.id].disp;
|
|
|
|
|
logger.verbose("Resuming music...");
|
|
|
|
|
if (disp !== null) {
|
|
|
|
|
disp.resume();
|
|
|
|
|
} else {
|
|
|
|
|
logger.warn("No dispatcher found");
|
|
|
|
|
}
|
|
|
|
|
exports.resume = function(guildId) {
|
|
|
|
|
djs[guildId].resume();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Stops the music
|
|
|
|
|
* @param guildId
|
|
|
|
|
*/
|
|
|
|
|
exports.stop = function(voiceChannel) {
|
|
|
|
|
let gid = voiceChannel.guild.id;
|
|
|
|
|
let disp = connections[gid].disp;
|
|
|
|
|
let conn = connections[gid].conn;
|
|
|
|
|
logger.verbose("Stopping music...");
|
|
|
|
|
if (disp !== null) {
|
|
|
|
|
disp.end();
|
|
|
|
|
logger.debug("Ended dispatcher");
|
|
|
|
|
}
|
|
|
|
|
if (conn !== null) {
|
|
|
|
|
conn.disconnect();
|
|
|
|
|
logger.debug("Ended connection");
|
|
|
|
|
}
|
|
|
|
|
connections[gid].playing = false;
|
|
|
|
|
exports.stop = function(guildId) {
|
|
|
|
|
djs[guildId].stop();
|
|
|
|
|
delete djs[guildId];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Skips the song
|
|
|
|
|
* @param guildId
|
|
|
|
|
*/
|
|
|
|
|
exports.skip = function(voiceChannel) {
|
|
|
|
|
let disp = connections[voiceChannel.guild.id].disp;
|
|
|
|
|
logger.debug("Skipping song");
|
|
|
|
|
if (disp !== null) {
|
|
|
|
|
disp.end();
|
|
|
|
|
}
|
|
|
|
|
exports.skip = function(guildId) {
|
|
|
|
|
djs[guildId].skip();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Clears the playlist
|
|
|
|
|
* @param guildId
|
|
|
|
|
*/
|
|
|
|
|
exports.clearQueue = function(guildId) {
|
|
|
|
|
djs[guildId].clear();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* executes the callback when the titlelist is finished
|
|
|
|
|
* Returns the queue
|
|
|
|
|
* @param guildId
|
|
|
|
|
*/
|
|
|
|
|
exports.getQueue = function(voiceChannel, callback) {
|
|
|
|
|
let titles = [];
|
|
|
|
|
connections[voiceChannel.guild.id].queue.forEach((url) => {
|
|
|
|
|
yttl(url.replace(/http(s)?:\/\/(www.)?youtube.com\/watch\?v=/g, ''), (err, title) => {
|
|
|
|
|
if (err) {
|
|
|
|
|
logger.error(err);
|
|
|
|
|
} else {
|
|
|
|
|
titles.push(title);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
setTimeout(() => callback(titles), 2000 );
|
|
|
|
|
exports.getQueue = function(guildId) {
|
|
|
|
|
return djs[guildId].playlist;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* evokes the callback function with the title of the current song
|
|
|
|
|
* @param callback
|
|
|
|
|
* @param voiceChannel
|
|
|
|
|
* @param guildId
|
|
|
|
|
*/
|
|
|
|
|
exports.nowPlaying = function(voiceChannel, callback) {
|
|
|
|
|
let gid = voiceChannel.guild.id;
|
|
|
|
|
if (connections[gid].queue.length > 0) {
|
|
|
|
|
yttl(connections[gid].current.replace(/http(s)?:\/\/(www.)?youtube.com\/watch\?v=/g, ''), (err, title) => {
|
|
|
|
|
if (err) {
|
|
|
|
|
logger.error(err);
|
|
|
|
|
} else {
|
|
|
|
|
callback(title, connections[gid].current);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
exports.nowPlaying = function(guildId) {
|
|
|
|
|
return djs[guildId].song;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* shuffles the queue
|
|
|
|
|
* @param guildId
|
|
|
|
|
*/
|
|
|
|
|
exports.shuffle = function(voiceChannel) {
|
|
|
|
|
connections[voiceChannel.guild.id].queue = shuffle(connections[voiceChannel.guild.id].queue);
|
|
|
|
|
exports.shuffle = function(guildId) {
|
|
|
|
|
djs[guildId].shuffle();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
@ -224,7 +369,7 @@ exports.shuffle = function(voiceChannel) {
|
|
|
|
|
* @param array
|
|
|
|
|
* @returns {Array}
|
|
|
|
|
*/
|
|
|
|
|
function shuffle(array) {
|
|
|
|
|
function shuffleArray(array) {
|
|
|
|
|
let currentIndex = array.length, temporaryValue, randomIndex;
|
|
|
|
|
|
|
|
|
|
// While there remain elements to shuffle...
|
|
|
|
|