Initial commit
* Working UI for minimal MPD client * Header, Now Playing and Playlist panels * ? toggles keyboard shortcut help
This commit is contained in:
359
src/main.rs
Normal file
359
src/main.rs
Normal 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);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user