lowfi
Version:
🎵 Play Lofi music through your terminal.
393 lines (369 loc) • 11.1 kB
JavaScript
// src/index.ts
import { Command } from "commander";
// src/commands/play.ts
import inquirer from "inquirer";
import chalk2 from "chalk";
// src/data/playlists.ts
var playlists = [
{
title: "Lofi Girl \u2014 Dark Ambient Playlist",
url: "https://soundcloud.com/lofi_girl/sets/dark-ambient-music-to-escape"
},
{
title: "Lofi Girl \u2014 Peaceful Piano Playlist",
url: "https://soundcloud.com/lofi_girl/sets/peaceful-piano-music-to-focus"
},
{
title: "Lofi Girl \u2014 Synthwave Ambient Playlist",
url: "https://soundcloud.com/lofi_girl/sets/synthwave-ambient-chill-music"
},
{
title: "Lofi Girl \u2014 Lofi Reading Playlist",
url: "https://soundcloud.com/lofi_girl/sets/lofi-reading-2024"
},
{
title: "Chillhop Music \u2014 Night Time Cruise Playlist",
url: "https://soundcloud.com/chillhopdotcom/sets/night-time-cruise"
},
{
title: "Chillhop Music \u2014 Golden Hour Playlist",
url: "https://soundcloud.com/chillhopdotcom/sets/golden-hour"
},
{
title: "Studio Ghibli \u2014 Lofi (Study, Sleep, Relax)",
url: "https://soundcloud.com/luvemenot/sets/studio-ghibli-lofi-study-sleep-relax"
}
];
// src/lib/stream.ts
import ora2 from "ora";
import { Soundcloud } from "soundcloud.ts";
// src/lib/logger.ts
import logSymbols from "log-symbols";
function info(message) {
console.log(logSymbols.info, message);
}
function error(message) {
console.error(logSymbols.error, message);
}
// src/lib/play.ts
import ora from "ora";
import chalk from "chalk";
// src/helpers/number.ts
function padNumber(num) {
return num.toString().padStart(2, "0");
}
// src/helpers/time.ts
function formatTime(seconds) {
const mins = padNumber(Math.floor(seconds / 60));
const secs = padNumber(seconds % 60);
return `${mins}:${secs}`;
}
// src/lib/middleware.ts
import { Transform } from "stream";
var MiddlewareStream = class extends Transform {
constructor(onFirstData, options) {
super(options);
this.firstChunkLogged = false;
this.onFirstData = onFirstData;
}
_transform(chunk, encoding, callback) {
if (!this.firstChunkLogged) {
this.onFirstData();
this.firstChunkLogged = true;
}
this.push(chunk);
callback();
}
};
// src/lib/ffmpeg.ts
import ffmpeg from "fluent-ffmpeg";
function createFfmpegStream(stream2, onEnd, onError) {
const ffmpegStream = ffmpeg(stream2).toFormat("s16le").audioFrequency(44100).audioChannels(2).on("error", onError).on("end", onEnd);
return ffmpegStream;
}
// src/lib/speaker.ts
import Speaker from "speaker";
function createSpeaker() {
const speaker = new Speaker({
bitDepth: 16,
channels: 2,
sampleRate: 44100
});
return speaker;
}
// src/lib/volume.ts
import Volume from "pcm-volume";
function createVolume(amount) {
const volume = new Volume();
volume.setVolume(amount);
return volume;
}
// src/lib/play.ts
function play(title, volumeAmount, stream2) {
return new Promise((resolve, reject) => {
let timer = null;
const spinner = ora(`Now playing "${chalk.bold.white(title)}" (00:00)`);
let startTime;
try {
const speaker = createSpeaker();
const volume = createVolume(volumeAmount);
const middleware = new MiddlewareStream(() => {
startTime = Date.now();
spinner.start();
timer = setInterval(() => {
const elapsedTime = Math.floor((Date.now() - startTime) / 1e3);
spinner.text = `Now playing "${chalk.bold.white(title)}" (${formatTime(elapsedTime)})`;
}, 1e3);
});
const ffmpegStream = createFfmpegStream(
stream2,
() => {
if (timer)
clearInterval(timer);
const elapsedTime = Math.floor((Date.now() - startTime) / 1e3);
spinner.succeed(
`Finished "${chalk.bold.white(title)}" (${formatTime(elapsedTime)})`
);
resolve(true);
},
(error2) => {
if (timer)
clearInterval(timer);
reject(error2);
}
);
ffmpegStream.pipe(middleware).pipe(volume).pipe(speaker);
} catch (error2) {
reject(error2);
}
});
}
// src/helpers/random.ts
function random(min, max) {
return Math.floor(Math.random() * (max - min) + min);
}
function pick(array) {
const randomIndex = random(0, array.length);
return array[randomIndex];
}
function shuffle(array) {
return array.map((value) => ({ sort: Math.random(), value })).sort((a, b) => a.sort - b.sort).map(({ value }) => value);
}
// src/lib/key.ts
import sckey from "soundcloud-key-fetch";
async function fetchKey() {
return sckey.fetchKey();
}
// src/lib/stream.ts
async function stream(volume, url) {
let spinner;
try {
spinner = ora2("Fetching the playlist from SoundCloud").start();
const clientId = await fetchKey();
const soundcloud = new Soundcloud(clientId);
const playlist = await soundcloud.playlists.get(url);
if (playlist?.tracks) {
spinner.succeed(
`Fetched the playlist from SoundCloud (${playlist.track_count} Tracks)`
);
console.log("");
const { tracks } = playlist;
const shuffled = shuffle(tracks);
let index = 0;
while (true) {
const track = shuffled[index];
const stream2 = await soundcloud.util.streamTrack(track.permalink_url);
await play(track.title, volume, stream2);
index = (index + 1) % shuffled.length;
}
}
} catch (err) {
spinner?.stop();
if (err instanceof Error) {
error(`Error: ${err.message}`);
} else {
error(`Something went wrong.`);
}
}
}
// src/lib/banner.ts
import figlet from "figlet";
import gradient from "gradient-string";
async function printBanner() {
return new Promise((resolve, reject) => {
figlet("Lowfi", { font: "O8" }, (err, data) => {
if (err)
return reject(err);
console.log(gradient(["blue", "purple"]).multiline(data));
resolve(true);
});
});
}
// src/commands/play.ts
async function play2({ random: random2, volume }) {
await printBanner();
if (volume) {
const volumeNumber = Number(volume);
if (volumeNumber < 0 || volumeNumber > 1) {
return error("Volume should be between 0 and 1");
}
}
if (random2) {
const { title, url } = pick(playlists);
info(`Selected playlist: ${chalk2.bold.white(title)}
`);
return stream(Number(volume), url);
}
inquirer.prompt([
{
choices: playlists.map((playlist) => playlist.title),
message: "Select a lofi playlist to play:",
name: "playlist",
type: "list"
}
]).then((answers) => {
const playlist = playlists.filter(
(playlist2) => playlist2.title === answers.playlist
)[0];
stream(Number(volume), playlist.url);
});
}
// src/commands/donate.ts
import open from "open";
import inquirer2 from "inquirer";
import chalk3 from "chalk";
function donate() {
console.log(`${chalk3.green("[\u2022\u1D17\u2022]")} Hello There!`);
console.log("Enjoyed Lowfi? Please consider buying me a coffee!");
console.log("Link: https://buymeacoffee.com/remvze");
inquirer2.prompt([
{
message: "Open the link in your browser?",
name: "open",
type: "confirm"
}
]).then((answers) => {
if (answers.open)
open("https://buymeacoffee.com/remvze");
});
}
// src/commands/open.ts
import inquirer3 from "inquirer";
import open2 from "open";
import ora3 from "ora";
async function openCommand() {
await printBanner();
inquirer3.prompt([
{
choices: playlists.map((playlist) => playlist.title),
message: "Select a lofi playlist to open:",
name: "playlist",
type: "list"
}
]).then((answers) => {
const playlist = playlists.filter(
(playlist2) => playlist2.title === answers.playlist
)[0];
const spinner = ora3("Opening the playlist in your browser.").start();
open2(playlist.url).then(
() => spinner.succeed("Opened the playlist in your browser.")
);
});
}
// package.json
var package_default = {
name: "lowfi",
version: "0.9.1",
description: "\u{1F3B5} Play Lofi music through your terminal.",
main: "dist/bin/lowfi.js",
type: "module",
bin: {
lowfi: "dist/bin/lowfi.js"
},
files: [
"dist/bin/lowfi.js"
],
scripts: {
test: 'echo "Error: no test specified" && exit 1',
lint: "eslint . --ext .js,.ts",
"lint:fix": "npm run lint -- --fix",
format: "prettier . --write",
prepare: "husky",
commit: "git-cz",
release: "standard-version --no-verify",
"release:major": "npm run release -- --release-as major",
"release:minor": "npm run release -- --release-as minor",
"release:patch": "npm run release -- --release-as patch",
build: "tsup-node",
dev: "tsup-node --watch",
prepublishOnly: "npm run build"
},
keywords: [
"lofi",
"cli"
],
author: "MAZE",
repository: {
type: "git",
url: "git+https://github.com/remvze/lowfi.git"
},
bugs: {
url: "https://github.com/remvze/lowfi/issues"
},
homepage: "https://github.com/remvze/lowfi#readme",
license: "MIT",
devDependencies: {
"@commitlint/cli": "19.3.0",
"@commitlint/config-conventional": "19.2.2",
"@tsconfig/node16": "16.1.3",
"@types/figlet": "1.5.8",
"@types/fluent-ffmpeg": "2.1.24",
"@types/gradient-string": "1.1.6",
"@types/inquirer": "9.0.7",
"@types/node": "20.12.12",
"@types/pcm-volume": "1.0.2",
"@typescript-eslint/eslint-plugin": "7.9.0",
"@typescript-eslint/parser": "7.9.0",
commitizen: "4.3.0",
"cz-conventional-changelog": "3.3.0",
eslint: "8.57.0",
"eslint-config-prettier": "9.1.0",
"eslint-import-resolver-alias": "1.1.2",
"eslint-import-resolver-typescript": "3.6.1",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-node": "11.1.0",
"eslint-plugin-prettier": "5.1.3",
"eslint-plugin-sort-destructure-keys": "2.0.0",
"eslint-plugin-sort-keys-fix": "1.1.2",
"eslint-plugin-typescript-sort-keys": "3.2.0",
husky: "9.0.11",
"lint-staged": "15.2.2",
prettier: "3.2.5",
"standard-version": "9.5.0",
tsup: "8.0.2"
},
dependencies: {
chalk: "5.3.0",
commander: "12.0.0",
figlet: "1.7.0",
"fluent-ffmpeg": "2.1.3",
"gradient-string": "2.0.2",
inquirer: "9.2.21",
"log-symbols": "6.0.0",
open: "10.1.0",
ora: "8.0.1",
"pcm-volume": "1.0.0",
"soundcloud-key-fetch": "1.0.13",
"soundcloud.ts": "0.6.5",
speaker: "0.5.5"
}
};
// src/index.ts
var program = new Command();
program.name("lowfi").description("A CLI tool to play lofi music").version(package_default.version);
program.command("play").description("Play a lofi playlist").option("-r, --random", "Select a playlist randomly").option("-v, --volume <number>", "Set the volume", "0.5").action(play2);
program.command("donate").description("Donate to the creator of Lowfi").action(donate);
program.command("open").description("Open a lofi playlist in your browser").action(openCommand);
// bin/lowfi.ts
program.parse(process.argv);