Player de Musica CLI em Rust

Construa um player de musica CLI em Rust com rodio: playlist, play/pause/skip, barra de progresso, shuffle e interface no terminal.

Neste projeto vamos construir um player de musica para o terminal em Rust usando a crate rodio para reproduzao de audio. O player tera uma interface interativa no terminal com controles de play/pause, avanco e retrocesso de faixas, barra de progresso visual, modo shuffle e listagem da playlist. Voce podera apontar para um diretorio com arquivos de audio e ouvir suas musicas sem sair do terminal.

Este projeto combina manipulacao de audio, gerenciamento de arquivos, interface de terminal e concorrencia entre threads (a reproducao de audio roda em background enquanto a interface responde ao teclado). E uma excelente forma de praticar Rust em um projeto que voce realmente vai usar no dia a dia.

O Que Vamos Construir

Um player de musica CLI com as seguintes funcionalidades:

  • Reproducao de arquivos WAV, MP3, FLAC e OGG via rodio
  • Playlist construida a partir de um diretorio de musicas
  • Controles de play, pause, stop, proximo e anterior
  • Barra de progresso visual da faixa atual
  • Modo shuffle (ordem aleatoria)
  • Interface no terminal com informacoes da faixa atual
  • Controle de volume
  • Avanco automatico para a proxima faixa

Estrutura do Projeto

music-player/
├── Cargo.toml
└── src/
    └── main.rs

Configurando o Projeto

Crie o projeto com o Cargo:

cargo new music-player
cd music-player

Edite o Cargo.toml:

[package]
name = "music-player"
version = "0.1.0"
edition = "2021"

[dependencies]
rodio = "0.19"
crossterm = "0.28"
rand = "0.8"

Usamos rodio para reproducao de audio (suporta WAV, MP3, FLAC, OGG), crossterm para a interface do terminal e rand para o modo shuffle.

Passo 1: Gerenciamento da Playlist

Vamos comecar com a estrutura que gerencia a lista de musicas e a navegacao entre faixas.

use crossterm::{
    cursor, event::{self, Event, KeyCode, KeyEvent},
    execute, queue,
    style::{self, Stylize},
    terminal::{self, ClearType},
};
use rand::seq::SliceRandom;
use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink};
use std::fs;
use std::io::{self, BufReader, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

/// Extensoes de arquivo de audio suportadas
const EXTENSOES_AUDIO: &[&str] = &["wav", "mp3", "flac", "ogg"];

/// Representa uma faixa de audio
#[derive(Clone)]
struct Faixa {
    caminho: PathBuf,
    nome: String,
}

/// Gerencia a playlist e a posicao atual
struct Playlist {
    faixas: Vec<Faixa>,
    indice_atual: usize,
    modo_shuffle: bool,
    ordem_shuffle: Vec<usize>,
}

impl Playlist {
    /// Cria uma playlist a partir de um diretorio
    fn do_diretorio(caminho: &Path) -> Result<Self, String> {
        let mut faixas = Vec::new();

        let entradas = fs::read_dir(caminho)
            .map_err(|e| format!("Erro ao ler diretorio '{}': {}", caminho.display(), e))?;

        for entrada in entradas {
            let entrada = entrada.map_err(|e| format!("Erro ao ler entrada: {}", e))?;
            let path = entrada.path();

            if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
                if EXTENSOES_AUDIO.contains(&ext.to_lowercase().as_str()) {
                    let nome = path
                        .file_stem()
                        .and_then(|n| n.to_str())
                        .unwrap_or("Desconhecida")
                        .to_string();

                    faixas.push(Faixa { caminho: path, nome });
                }
            }
        }

        // Ordena por nome
        faixas.sort_by(|a, b| a.nome.cmp(&b.nome));

        if faixas.is_empty() {
            return Err("Nenhum arquivo de audio encontrado no diretorio.".into());
        }

        let total = faixas.len();
        let ordem_shuffle: Vec<usize> = (0..total).collect();

        Ok(Playlist {
            faixas,
            indice_atual: 0,
            modo_shuffle: false,
            ordem_shuffle,
        })
    }

    /// Retorna a faixa atual
    fn faixa_atual(&self) -> &Faixa {
        let idx = if self.modo_shuffle {
            self.ordem_shuffle[self.indice_atual]
        } else {
            self.indice_atual
        };
        &self.faixas[idx]
    }

    /// Avanca para a proxima faixa
    fn proxima(&mut self) {
        self.indice_atual = (self.indice_atual + 1) % self.faixas.len();
    }

    /// Retorna para a faixa anterior
    fn anterior(&mut self) {
        if self.indice_atual == 0 {
            self.indice_atual = self.faixas.len() - 1;
        } else {
            self.indice_atual -= 1;
        }
    }

    /// Ativa/desativa o modo shuffle
    fn alternar_shuffle(&mut self) {
        self.modo_shuffle = !self.modo_shuffle;
        if self.modo_shuffle {
            let mut rng = rand::thread_rng();
            self.ordem_shuffle = (0..self.faixas.len()).collect();
            self.ordem_shuffle.shuffle(&mut rng);
        }
    }

    fn total(&self) -> usize {
        self.faixas.len()
    }
}

A playlist escaneia um diretorio buscando arquivos com extensoes de audio reconhecidas. O modo shuffle mantem um vetor de indices embaralhados separado, de forma que podemos alternar entre os modos sem perder a posicao.

Passo 2: Controle de Reproducao

Agora implementamos a camada que controla a reproducao de audio usando rodio.

/// Estado de reproducao
#[derive(PartialEq)]
enum EstadoReproducao {
    Tocando,
    Pausado,
    Parado,
}

/// Controla a reproducao de audio
struct PlayerAudio {
    _stream: OutputStream,
    _handle: OutputStreamHandle,
    sink: Sink,
    estado: EstadoReproducao,
    volume: f32,
    inicio_faixa: Instant,
    tempo_pausado: Duration,
}

impl PlayerAudio {
    fn novo() -> Result<Self, String> {
        let (stream, handle) = OutputStream::try_default()
            .map_err(|e| format!("Erro ao inicializar audio: {}", e))?;

        let sink = Sink::try_new(&handle)
            .map_err(|e| format!("Erro ao criar sink: {}", e))?;

        Ok(PlayerAudio {
            _stream: stream,
            _handle: handle,
            sink,
            estado: EstadoReproducao::Parado,
            volume: 0.5,
            inicio_faixa: Instant::now(),
            tempo_pausado: Duration::ZERO,
        })
    }

    /// Carrega e inicia a reproducao de uma faixa
    fn tocar(&mut self, faixa: &Faixa) -> Result<(), String> {
        // Para a faixa atual, se houver
        self.sink.stop();

        // Recria o sink (necessario apos stop)
        self.sink = Sink::try_new(&self._handle)
            .map_err(|e| format!("Erro ao criar sink: {}", e))?;

        let arquivo = fs::File::open(&faixa.caminho)
            .map_err(|e| format!("Erro ao abrir '{}': {}", faixa.caminho.display(), e))?;

        let leitor = BufReader::new(arquivo);
        let fonte = Decoder::new(leitor)
            .map_err(|e| format!("Erro ao decodificar '{}': {}", faixa.nome, e))?;

        self.sink.set_volume(self.volume);
        self.sink.append(fonte);
        self.estado = EstadoReproducao::Tocando;
        self.inicio_faixa = Instant::now();
        self.tempo_pausado = Duration::ZERO;

        Ok(())
    }

    /// Alterna entre play e pause
    fn alternar_pause(&mut self) {
        match self.estado {
            EstadoReproducao::Tocando => {
                self.sink.pause();
                self.estado = EstadoReproducao::Pausado;
            }
            EstadoReproducao::Pausado => {
                self.sink.play();
                self.estado = EstadoReproducao::Tocando;
            }
            EstadoReproducao::Parado => {}
        }
    }

    /// Aumenta o volume (maximo 1.0)
    fn aumentar_volume(&mut self) {
        self.volume = (self.volume + 0.1).min(1.0);
        self.sink.set_volume(self.volume);
    }

    /// Diminui o volume (minimo 0.0)
    fn diminuir_volume(&mut self) {
        self.volume = (self.volume - 0.1).max(0.0);
        self.sink.set_volume(self.volume);
    }

    /// Verifica se a faixa atual terminou
    fn faixa_terminou(&self) -> bool {
        self.sink.empty() && self.estado == EstadoReproducao::Tocando
    }

    /// Retorna o tempo decorrido da faixa atual
    fn tempo_decorrido(&self) -> Duration {
        if self.estado == EstadoReproducao::Parado {
            Duration::ZERO
        } else {
            self.inicio_faixa.elapsed() - self.tempo_pausado
        }
    }
}

O PlayerAudio encapsula o sistema de audio rodio. O Sink gerencia a fila de reproducao e permite play/pause sem bloquear a thread principal. Note que apos stop(), precisamos recriar o Sink – isso e uma peculiaridade da API do rodio.

Passo 3: Interface do Terminal

Agora implementamos a renderizacao da interface no terminal.

/// Formata duracao em mm:ss
fn formatar_tempo(duracao: Duration) -> String {
    let segundos_total = duracao.as_secs();
    let minutos = segundos_total / 60;
    let segundos = segundos_total % 60;
    format!("{:02}:{:02}", minutos, segundos)
}

/// Gera uma barra de progresso visual
fn barra_progresso(progresso: f64, largura: usize) -> String {
    let preenchido = (progresso * largura as f64) as usize;
    let preenchido = preenchido.min(largura);
    let vazio = largura - preenchido;

    format!("[{}{}]", "=".repeat(preenchido), " ".repeat(vazio))
}

/// Renderiza a interface do player no terminal
fn renderizar_interface(
    stdout: &mut io::Stdout,
    playlist: &Playlist,
    player: &PlayerAudio,
) {
    queue!(stdout, terminal::Clear(ClearType::All), cursor::MoveTo(0, 0)).unwrap();

    // Titulo
    let titulo = " PLAYER DE MUSICA - RUST ";
    queue!(stdout, style::PrintStyledContent(titulo.black().on_cyan())).unwrap();
    queue!(stdout, cursor::MoveToNextLine(2)).unwrap();

    // Faixa atual
    let faixa = playlist.faixa_atual();
    let icone = match player.estado {
        EstadoReproducao::Tocando => ">>",
        EstadoReproducao::Pausado => "||",
        EstadoReproducao::Parado => "[]",
    };

    let info_faixa = format!(
        "  {} {} ({}/{})",
        icone,
        faixa.nome,
        playlist.indice_atual + 1,
        playlist.total()
    );
    queue!(stdout, style::PrintStyledContent(info_faixa.white().bold())).unwrap();
    queue!(stdout, cursor::MoveToNextLine(2)).unwrap();

    // Barra de progresso (estimativa baseada no tempo)
    let tempo = player.tempo_decorrido();
    let tempo_str = formatar_tempo(tempo);
    let barra = barra_progresso(0.0, 40); // Progresso simplificado
    let progresso_info = format!("  {} {}", tempo_str, barra);
    queue!(stdout, style::PrintStyledContent(progresso_info.cyan())).unwrap();
    queue!(stdout, cursor::MoveToNextLine(2)).unwrap();

    // Volume
    let volume_str = format!("  Volume: {}%", (player.volume * 100.0) as u32);
    let volume_barra = barra_progresso(player.volume as f64, 20);
    let volume_info = format!("{} {}", volume_str, volume_barra);
    queue!(stdout, style::PrintStyledContent(volume_info.yellow())).unwrap();
    queue!(stdout, cursor::MoveToNextLine(1)).unwrap();

    // Modo shuffle
    let shuffle_str = if playlist.modo_shuffle {
        "  Shuffle: ATIVADO"
    } else {
        "  Shuffle: desativado"
    };
    queue!(stdout, style::PrintStyledContent(shuffle_str.dark_grey())).unwrap();
    queue!(stdout, cursor::MoveToNextLine(2)).unwrap();

    // Playlist (mostra ate 10 faixas ao redor da atual)
    queue!(stdout, style::PrintStyledContent("  -- Playlist --".white().bold())).unwrap();
    queue!(stdout, cursor::MoveToNextLine(1)).unwrap();

    let total = playlist.total();
    let inicio = if playlist.indice_atual > 4 {
        playlist.indice_atual - 4
    } else {
        0
    };
    let fim = (inicio + 10).min(total);

    for i in inicio..fim {
        let idx = if playlist.modo_shuffle {
            playlist.ordem_shuffle[i]
        } else {
            i
        };
        let nome = &playlist.faixas[idx].nome;
        let marcador = if i == playlist.indice_atual { " >> " } else { "    " };
        let linha = format!("{}{:>3}. {}", marcador, i + 1, nome);

        if i == playlist.indice_atual {
            queue!(stdout, style::PrintStyledContent(linha.green().bold())).unwrap();
        } else {
            queue!(stdout, style::PrintStyledContent(linha.white())).unwrap();
        }
        queue!(stdout, cursor::MoveToNextLine(1)).unwrap();
    }

    // Controles
    queue!(stdout, cursor::MoveToNextLine(1)).unwrap();
    let controles = "  [Espaco]=Play/Pause [N]=Prox [P]=Ant [+/-]=Volume [S]=Shuffle [Q]=Sair";
    queue!(stdout, style::PrintStyledContent(controles.dark_grey())).unwrap();

    stdout.flush().unwrap();
}

A interface mostra a faixa atual com indicador de estado (tocando/pausado/parado), uma barra de progresso, o volume e a playlist com destaque na faixa em reproducao. A lista rola automaticamente para manter a faixa atual visivel.

Passo 4: Loop Principal e main.rs Completo

Agora juntamos tudo no loop principal do player.

fn main() {
    // Le o diretorio dos argumentos
    let args: Vec<String> = std::env::args().collect();
    let diretorio = if args.len() > 1 {
        PathBuf::from(&args[1])
    } else {
        std::env::current_dir().expect("Erro ao obter diretorio atual")
    };

    // Carrega a playlist
    let mut playlist = match Playlist::do_diretorio(&diretorio) {
        Ok(p) => p,
        Err(e) => {
            eprintln!("{}", e);
            eprintln!("Uso: music-player [diretorio-com-musicas]");
            std::process::exit(1);
        }
    };

    println!("{} faixas encontradas em '{}'", playlist.total(), diretorio.display());

    // Inicializa o player de audio
    let mut player = match PlayerAudio::novo() {
        Ok(p) => p,
        Err(e) => {
            eprintln!("{}", e);
            std::process::exit(1);
        }
    };

    // Configura o terminal
    terminal::enable_raw_mode().unwrap();
    let mut stdout = io::stdout();
    execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide).unwrap();

    // Inicia tocando a primeira faixa
    if let Err(e) = player.tocar(playlist.faixa_atual()) {
        execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen).unwrap();
        terminal::disable_raw_mode().unwrap();
        eprintln!("Erro ao tocar: {}", e);
        std::process::exit(1);
    }

    // Loop principal
    loop {
        // Processa entrada do teclado
        if event::poll(Duration::from_millis(100)).unwrap() {
            if let Event::Key(KeyEvent { code, .. }) = event::read().unwrap() {
                match code {
                    KeyCode::Char('q') | KeyCode::Esc => break,
                    KeyCode::Char(' ') => player.alternar_pause(),
                    KeyCode::Char('n') | KeyCode::Right => {
                        playlist.proxima();
                        if let Err(e) = player.tocar(playlist.faixa_atual()) {
                            // Tenta a proxima faixa se houver erro
                            eprintln!("Erro: {}", e);
                            playlist.proxima();
                            let _ = player.tocar(playlist.faixa_atual());
                        }
                    }
                    KeyCode::Char('p') | KeyCode::Left => {
                        playlist.anterior();
                        if let Err(e) = player.tocar(playlist.faixa_atual()) {
                            eprintln!("Erro: {}", e);
                        }
                    }
                    KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Up => {
                        player.aumentar_volume();
                    }
                    KeyCode::Char('-') | KeyCode::Down => {
                        player.diminuir_volume();
                    }
                    KeyCode::Char('s') => {
                        playlist.alternar_shuffle();
                    }
                    _ => {}
                }
            }
        }

        // Avanca automaticamente quando a faixa termina
        if player.faixa_terminou() {
            playlist.proxima();
            let _ = player.tocar(playlist.faixa_atual());
        }

        // Renderiza a interface
        renderizar_interface(&mut stdout, &playlist, &player);
    }

    // Restaura o terminal
    execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen).unwrap();
    terminal::disable_raw_mode().unwrap();
    println!("Player encerrado. Obrigado por ouvir!");
}

O loop principal processa entrada do teclado, verifica se a faixa terminou para avanco automatico e atualiza a interface. A reproducao de audio acontece em uma thread separada gerenciada pelo rodio, entao a interface permanece responsiva.

Como Executar

Compile e execute o projeto:

cargo build --release

# Toca musicas do diretorio atual
./target/release/music-player

# Toca musicas de um diretorio especifico
./target/release/music-player ~/Musicas/

# Toca musicas de qualquer diretorio com arquivos de audio
./target/release/music-player /caminho/para/musicas/

Controles:

  • Espaco: Play/Pause
  • N ou Seta Direita: Proxima faixa
  • P ou Seta Esquerda: Faixa anterior
  • + ou Seta Cima: Aumentar volume
  • - ou Seta Baixo: Diminuir volume
  • S: Alternar modo shuffle
  • Q ou Esc: Sair

Desafios para Expandir

  1. Metadados ID3 – Use a crate id3 para ler tags de artista, album e titulo de arquivos MP3, exibindo informacoes mais ricas na interface.

  2. Equalizador visual – Implemente uma visualizacao de espectro de frequencia (barras pulsantes) usando FFT com a crate rustfft, animada em tempo real no terminal.

  3. Playlist persistente – Salve a playlist, posicao atual e volume em um arquivo de configuracao JSON, restaurando o estado automaticamente na proxima execucao.

  4. Busca e filtro – Adicione um modo de busca (Ctrl+F) que filtra faixas pelo nome em tempo real conforme o usuario digita, com navegacao e selecao.

  5. Streaming HTTP – Integre reproducao de audio via URL usando reqwest para baixar o stream e rodio para reproduzir, permitindo tocar radios online ou podcasts.

Veja Tambem