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
Metadados ID3 – Use a crate
id3para ler tags de artista, album e titulo de arquivos MP3, exibindo informacoes mais ricas na interface.Equalizador visual – Implemente uma visualizacao de espectro de frequencia (barras pulsantes) usando FFT com a crate
rustfft, animada em tempo real no terminal.Playlist persistente – Salve a playlist, posicao atual e volume em um arquivo de configuracao JSON, restaurando o estado automaticamente na proxima execucao.
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.
Streaming HTTP – Integre reproducao de audio via URL usando
reqwestpara baixar o stream erodiopara reproduzir, permitindo tocar radios online ou podcasts.
Veja Tambem
- Vec e Colecoes – Gerenciamento da playlist e faixas
- Path e PathBuf – Manipulacao de caminhos de arquivos de audio
- Modulo fs – Leitura de diretorios e arquivos
- Medicao de Tempo – Controle de tempo de reproducao
- Rust para CLI – Melhores praticas para ferramentas de terminal