Initial commit

* Working UI for minimal MPD client
* Header, Now Playing and Playlist panels
* ? toggles keyboard shortcut help
This commit is contained in:
2025-07-30 21:57:27 -07:00
commit e0ee903f1a
5 changed files with 1019 additions and 0 deletions

359
src/main.rs Normal file
View File

@@ -0,0 +1,359 @@
// src/main.rs
use crossterm::{
event::{self, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use mpd::{Client, Song, State};
use ratatui::{
backend::Backend,
layout::{Constraint, Direction, Layout, Margin, Alignment},
style::{Color, Modifier, Style},
widgets::{Block, Borders, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
Frame, Terminal,
};
use ratatui::backend::CrosstermBackend;
use std::{io, time::Duration};
/// Application state
struct App {
client: Client,
current_song: Option<Song>,
playlist: Vec<Song>,
status: mpd::Status,
selected_index: usize, // Currently selected song in the playlist
scroll_offset: usize, // Offset for playlist scrolling
show_help: bool, // Whether to show the help panel
}
impl App {
/// Create a new App instance, connecting to MPD
fn new() -> Result<Self, anyhow::Error> {
let mut client = Client::connect("127.0.0.1:6600")?;
let status = client.status()?;
let playlist = client.queue()?;
let current_song = client.currentsong()?;
Ok(Self {
client,
current_song,
playlist,
status,
selected_index: 0,
scroll_offset: 0,
show_help: false,
})
}
/// Refresh the application state from MPD
fn refresh(&mut self, playlist_height: usize) -> Result<(), anyhow::Error> {
self.status = self.client.status()?;
self.current_song = self.client.currentsong()?;
// Get the initial queue
if let Ok(queue) = self.client.queue() {
// Process the queue with a temporary vector to avoid mutations while iterating
let mut processed: Vec<Song> = Vec::with_capacity(queue.len());
// Only add songs we haven't seen before
for song in queue {
let duplicate = processed.iter().any(|s: &Song| {
// Two songs are the same if they have the same file path
song.file == s.file
});
if !duplicate {
processed.push(song);
}
}
self.playlist = processed;
} else {
self.playlist.clear();
}
// Update scroll position
self.update_scroll(playlist_height);
Ok(())
}
/// Move selection up, adjusting scroll if necessary
fn move_up(&mut self) {
if self.selected_index > 0 {
self.selected_index -= 1;
}
}
/// Move selection down, adjusting scroll if necessary
fn move_down(&mut self) {
if self.selected_index + 1 < self.playlist.len() {
self.selected_index += 1;
}
}
/// Update scroll offset to ensure selected item is visible
fn update_scroll(&mut self, height: usize) {
if self.selected_index >= self.scroll_offset + height {
self.scroll_offset = self.selected_index.saturating_sub(height) + 1;
} else if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
}
}
}
fn main() -> anyhow::Result<()> {
// Setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Create app and get initial height
let mut app = App::new()?;
let height = terminal.size()?.height.saturating_sub(12) as usize;
app.refresh(height)?;
// Run the main loop
run_app(&mut terminal, &mut app)?;
// Restore terminal
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen,)?;
terminal.show_cursor()?;
Ok(())
}
fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> anyhow::Result<()> {
loop {
// Get the available height for the playlist
let height = terminal.size()?.height.saturating_sub(12) as usize;
app.refresh(height)?;
terminal.draw(|f| ui(f, app))?;
if event::poll(Duration::from_millis(250))? {
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Char('p') => {
if app.status.state == State::Play {
app.client.pause(true)?;
} else {
app.client.play()?;
}
}
KeyCode::Char('s') => app.client.stop()?,
KeyCode::Char('c') => {
app.client.clear()?;
app.client.update()?;
app.client.play()?;
}
// Navigation
KeyCode::Up => app.move_up(),
KeyCode::Down => app.move_down(),
// Play selected song
KeyCode::Enter => {
if !app.playlist.is_empty() {
// Play the selected song by its position in the playlist
app.client.switch(app.selected_index as u32)?;
app.client.play()?;
}
}
// Volume control
KeyCode::Char('+') | KeyCode::Char('=') => {
let new_vol = (app.status.volume + 5).min(100);
app.client.volume(new_vol)?;
}
KeyCode::Char('-') => {
let new_vol = app.status.volume.saturating_sub(5);
app.client.volume(new_vol)?;
}
KeyCode::Char('?') => {
app.show_help = !app.show_help;
}
_ => ()
}
}
}
}
}
/// Helper function to create a centered rect using up certain percentage of the available rect
fn centered_rect(percent_x: u16, percent_y: u16, r: ratatui::layout::Rect) -> ratatui::layout::Rect {
// Calculate the width and height of the popup
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
].as_ref())
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
].as_ref())
.split(popup_layout[1])[1]
}
pub(crate) fn ui(f: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Length(10), // Status
Constraint::Min(0) // Playlist
].as_ref())
.split(f.size());
// --- Header Widget ---
let header = Paragraph::new("Sonnet MPD")
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.alignment(ratatui::layout::Alignment::Center)
.block(Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)));
f.render_widget(header, chunks[0]);
// --- Status and Current Song Widget ---
let status_text = format!(
"State: {:?}\nVolume: {}%\nRepeat: {:?}, Random: {:?}",
app.status.state, app.status.volume, app.status.repeat, app.status.random
);
let song_title = if let Some(song) = &app.current_song {
song.title.as_deref().unwrap_or("Unknown Title")
} else {
"None"
};
let status_block = Block::default().title("Status").borders(Borders::ALL);
let status_paragraph = Paragraph::new(status_text).block(status_block);
f.render_widget(status_paragraph, chunks[1]);
let current_song_block = Block::default()
.title("Now Playing")
.borders(Borders::ALL);
let song_paragraph = Paragraph::new(song_title)
.style(Style::default().fg(Color::Yellow))
.block(current_song_block);
// We can render one widget on top of another by rendering it on the same area
f.render_widget(song_paragraph, chunks[1]);
// --- Playlist Widget ---
// Get visible portion of the playlist
let visible_height = chunks[2].height.saturating_sub(2) as usize;
let playlist_items: Vec<ListItem> = app
.playlist
.iter()
.enumerate()
.skip(app.scroll_offset)
.take(visible_height)
.map(|(i, song)| {
let title = song.title.as_deref().unwrap_or("Unknown");
let artist = song.tags.iter()
.find(|(key, _)| key == "Artist")
.map(|(_, val)| val.as_str())
.unwrap_or("Unknown");
let album = song.tags.iter()
.find(|(key, _)| key == "Album")
.map(|(_, val)| val.as_str())
.unwrap_or("Unknown");
let line = format!("{}. {} - {} ({})", i + 1, title, artist, album);
let mut list_item = ListItem::new(line);
// Style for the selected item
if i == app.selected_index {
list_item = list_item.style(Style::default().add_modifier(Modifier::REVERSED));
}
// Additional highlight for the currently playing song
if let Some(current) = &app.current_song {
if current.file == song.file {
list_item = list_item.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
}
}
list_item
})
.collect();
let playlist_list = List::new(playlist_items)
.block(Block::default().title(format!("Playlist ({} songs)", app.playlist.len())).borders(Borders::ALL))
.highlight_style(Style::default().add_modifier(Modifier::BOLD))
.highlight_symbol(">> ");
f.render_widget(playlist_list.clone(), chunks[2]);
// Add a scrollbar if there are more items than visible space
if app.playlist.len() > visible_height {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.track_style(Style::default().fg(Color::DarkGray))
.thumb_style(Style::default().fg(Color::White));
let scrollbar_area = chunks[2].inner(Margin {
vertical: 1,
horizontal: 0
});
// Calculate maximum scroll position (total items - visible items)
let max_scroll = app.playlist.len().saturating_sub(visible_height);
// Normalize scroll position to be between 0 and max_scroll
let scroll_pos = app.scroll_offset.min(max_scroll);
let mut scrollbar_state = ScrollbarState::default()
.content_length(max_scroll + 1) // +1 because we need to include the last position
.position(scroll_pos);
f.render_stateful_widget(
scrollbar,
scrollbar_area,
&mut scrollbar_state
);
}
// Show help panel if enabled
if app.show_help {
let help_text = vec![
"Keyboard Shortcuts",
"─────────────────",
"",
"q Quit",
"p Play/Pause",
"s Stop",
"c Clear playlist",
"↑ Move selection up",
"↓ Move selection down",
"⏎ Play selected song",
"+/= Volume up",
"- Volume down",
"? Toggle this help panel",
].join("\n");
let area = centered_rect(50, 60, f.size());
let help_paragraph = Paragraph::new(help_text)
.block(Block::default()
.title("Help")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)))
.style(Style::default().fg(Color::White))
.alignment(Alignment::Left);
f.render_widget(ratatui::widgets::Clear, area); // Clear the background
f.render_widget(help_paragraph, area);
}
}