Back to home

Building a CLI Music Player Because YouTube is Too Distracting

5 min read

I was staring at my desktop this morning. Green grass wallpaper. Peaceful. Relaxing.

Then I opened my terminal and started working. The flow was good. Everything felt productive.

But something was missing. Music.

The Problem

I wanted to play music. Simple request, right?

But here's what would normally happen:

  1. Open YouTube
  2. Search for "lofi hip hop"
  3. Get immediately distracted by 47 other video recommendations
  4. Click on "Why JavaScript is Actually Good" video
  5. 30 minutes later, still no music playing
  6. What was I doing again?

"Okay, fine. I'll use Spotify."

Spotify's UI is... let's just say it's not for me. Too many clicks. Too much visual noise. I just want to play a song, not navigate a spaceship dashboard.

The Idea

I'm already in my terminal. I feel productive here. Everything I need is here.

Why am I leaving this comfortable space just to play music?

What if... I could just search and play music right here in my terminal?

No thumbnails. No AI recommendations. No "You might also like" sections trying to steal my attention. Just search, select, play.

Quick Start

If you just want the tool without building it yourself:

git clone https://github.com/ahmadrosid/music-cli
cd music-cli
bun install
bun run index.ts

Done. Here's the repo.

The rest of this post walks through how I built it.

How I Built It

So I built it. A CLI music player that:

  1. Searches YouTube for music
  2. Shows me a clean list of results
  3. Lets me select with arrow keys
  4. Streams the audio (no files saved)
  5. Plays it right there in my terminal

That's it. Nothing more, nothing less.

The Stack

I kept it minimal:

  • Bun - Fast JavaScript runtime (because npm install is too slow for my impatience)
  • yt-search - Search YouTube without opening a browser
  • yt-dlp - Get the audio stream URL
  • ffplay - Play the audio
  • @inquirer/prompts - Interactive terminal UI

The Code

The core is surprisingly simple:

#!/usr/bin/env bun
 
import { input, select } from '@inquirer/prompts';
import ytSearch from 'yt-search';
import { spawn } from 'child_process';
import { promisify } from 'util';
import { exec as execCallback } from 'child_process';
 
const exec = promisify(execCallback);
 
// Search YouTube
async function searchYouTube(query: string) {
  const result = await ytSearch(query);
  return result.videos.slice(0, 10);
}
 
// Play audio
async function playAudio(videoUrl: string) {
  console.log('\n🎵 Getting audio stream...\n');
 
  // Get direct audio URL using yt-dlp
  const { stdout } = await exec(`yt-dlp -f bestaudio -g "${videoUrl}"`);
  const audioUrl = stdout.trim();
 
  // Stream and play with ffplay
  const ffplay = spawn('ffplay', [
    '-nodisp',
    '-autoexit',
    '-loglevel', 'quiet',
    audioUrl
  ]);
 
  return new Promise((resolve) => {
    ffplay.on('close', () => {
      console.log('\n✅ Playback finished\n');
      resolve();
    });
 
    process.on('SIGINT', () => {
      ffplay.kill();
      console.log('\n\n⏹️  Playback stopped\n');
      process.exit(0);
    });
  });
}
 
// Main
async function main() {
  console.log('🎵 YouTube Music Player\n');
 
  const query = await input({
    message: 'Search for music:',
  });
 
  console.log('\n🔍 Searching...\n');
  const results = await searchYouTube(query);
 
  if (results.length === 0) {
    console.log('No results found');
    return;
  }
 
  const choices = results.map((video) => ({
    name: `${video.title} - ${video.author.name} [${video.duration.timestamp}]`,
    value: video,
  }));
 
  const selected = await select({
    message: 'Select a track:',
    choices,
  });
 
  console.log(`\n▶️  Now playing: ${selected.title}\n`);
  await playAudio(selected.url);
}
 
main();

That's basically it. Search, select, stream, play.

Why It Matters

It's not about building the "best" music player. There are plenty of those.

It's about removing friction from my workflow.

When I'm in the zone coding, every context switch is expensive. Opening a browser, navigating YouTube's interface, avoiding distractions—each step pulls me out of flow state.

Now I type one command, make a selection, and I'm back to coding with music playing.

The terminal doesn't judge. It doesn't recommend. It doesn't try to engage me. It just does what I ask.

What I Learned

1. Tools should match your workflow

I'm already in the terminal. Building a terminal tool made sense. If you live in VS Code, build a VS Code extension. If you're always on your phone, build a mobile app. Meet yourself where you are.

2. Simple is enough

I don't need playlists, lyrics, social features, or AI recommendations. I just needed search and play. Resisting feature creep is hard, but satisfying.

3. Distractions are expensive

YouTube's recommendations aren't malicious, but they're optimized for engagement, not for my productivity. Sometimes the best solution is to just... not use the interface.

4. Build for yourself first

This tool is probably useless for most people. That's okay. It solves my problem perfectly. If it helps someone else, that's a bonus.

The Result

Now when I want music:

bun run index.ts

Search: "japanese city pop"

Select: "Plastic Love - Mariya Takeuchi"

Done.

No tabs opened. No videos watched. No rabbit holes explored. Just music.

Back to coding.


Want to build your own?

Prerequisites:

  • Bun: curl -fsSL https://bun.sh/install | bash
  • FFmpeg: brew install ffmpeg
  • yt-dlp: brew install yt-dlp

Install:

bun init
bun add @inquirer/prompts yt-search

The whole implementation is about 130 lines of TypeScript. Search YouTube, select from results, pipe stream to ffplay.

Sometimes the best tool is the one you build for yourself.

Now if you'll excuse me, I have a green grass wallpaper to stare at.