@notreallyr8/mp3
Version:
CLI music player with playlists, shuffle, multi-format support
154 lines (134 loc) • 3.88 kB
JavaScript
import MP3 from '@notreallyr8/mp3';
import fs from 'fs';
import path from 'path';
import ffmpeg from 'fluent-ffmpeg';
import Speaker from 'speaker';
const AUDIO_EXTENSIONS = ['.mp3', '.flac', '.wav', '.ogg', '.m4a'];
function shuffleArray(arr) {
const array = arr.slice();
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
export class PlaylistPlayer {
constructor(name, folder) {
this.name = name;
this.folder = folder;
this.songs = [];
this.shuffleOrder = [];
this.currentIndex = 0;
this.isShuffle = true;
this.isPlaying = false;
this.currentProcess = null;
this.currentPlayer = null;
}
loadSongs() {
if (!fs.existsSync(this.folder)) {
console.error(`Folder "${this.folder}" does not exist!`);
return false;
}
this.songs = fs.readdirSync(this.folder)
.filter(f => AUDIO_EXTENSIONS.includes(path.extname(f).toLowerCase()));
if (this.songs.length === 0) {
console.error(`No audio files found in "${this.folder}"`);
return false;
}
this.shuffleOrder = shuffleArray([...Array(this.songs.length).keys()]);
this.currentIndex = 0;
return true;
}
getCurrentSong() {
const index = this.isShuffle
? this.shuffleOrder[this.currentIndex]
: this.currentIndex;
return this.songs[index];
}
stop() {
if (this.currentProcess) {
this.currentProcess.kill();
this.currentProcess = null;
}
if (this.currentPlayer) {
// MP3 player does not support stop, so just nullify
this.currentPlayer = null;
}
this.isPlaying = false;
}
async play() {
this.stop();
if (this.songs.length === 0) {
console.log('No songs loaded.');
return;
}
const song = this.getCurrentSong();
const fullPath = path.join(this.folder, song);
console.log(`Playing [${this.name}] - ${song}`);
const ext = path.extname(song).toLowerCase();
if (ext === '.mp3') {
this.currentPlayer = new MP3(fullPath);
this.isPlaying = true;
try {
await this.currentPlayer.play();
this.isPlaying = false;
await this.next();
} catch (err) {
console.error('Playback error:', err);
}
} else {
// Use ffmpeg + speaker for other formats
this.isPlaying = true;
return new Promise((resolve) => {
this.currentProcess = ffmpeg(fullPath)
.format('s16le')
.audioChannels(2)
.audioFrequency(44100)
.on('error', err => {
console.error('Playback error:', err);
this.isPlaying = false;
resolve();
})
.on('end', async () => {
this.isPlaying = false;
await this.next();
resolve();
})
.pipe();
const speaker = new Speaker({
channels: 2,
bitDepth: 16,
sampleRate: 44100,
});
this.currentProcess.pipe(speaker);
});
}
}
async next() {
if (this.songs.length === 0) return;
this.currentIndex++;
if (this.currentIndex >= this.songs.length) {
this.currentIndex = 0;
if (this.isShuffle) {
this.shuffleOrder = shuffleArray([...Array(this.songs.length).keys()]);
}
}
await this.play();
}
async previous() {
if (this.songs.length === 0) return;
this.currentIndex--;
if (this.currentIndex < 0) {
this.currentIndex = this.songs.length - 1;
}
await this.play();
}
toggleShuffle() {
this.isShuffle = !this.isShuffle;
if (this.isShuffle) {
this.shuffleOrder = shuffleArray([...Array(this.songs.length).keys()]);
this.currentIndex = 0;
}
console.log(`Shuffle mode: ${this.isShuffle ? 'ON' : 'OFF'}`);
}
}