903 lines
33 KiB
Rust
903 lines
33 KiB
Rust
use anyhow::Result;
|
|
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
|
|
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
|
|
use crossterm::ExecutableCommand;
|
|
use ratatui::backend::{Backend, CrosstermBackend};
|
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
|
use ratatui::style::{Color, Modifier, Style};
|
|
use ratatui::text::{Line, Span, Text};
|
|
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs};
|
|
use ratatui::{Frame, Terminal};
|
|
use std::io::{self, Stdout};
|
|
use std::sync::mpsc::{channel, Receiver, Sender};
|
|
use std::time::{Duration, Instant};
|
|
use std::thread;
|
|
use tracing::{debug, error, info};
|
|
|
|
use crate::jellyfin::{JellyfinClient, LibraryItem};
|
|
use crate::player::{Player, PlayerCommand, PlaybackState};
|
|
|
|
const TAB_ARTISTS: usize = 0;
|
|
const TAB_ALBUMS: usize = 1;
|
|
const TAB_SONGS: usize = 2;
|
|
const TAB_QUEUE: usize = 3;
|
|
const TAB_SEARCH: usize = 4;
|
|
|
|
enum UiEvent {
|
|
Tick,
|
|
Input(KeyEvent),
|
|
Quit,
|
|
}
|
|
|
|
struct UiData {
|
|
active_tab: usize,
|
|
show_help: bool,
|
|
artists: Vec<String>,
|
|
artists_state: ListState,
|
|
albums: Vec<LibraryItem>,
|
|
albums_state: ListState,
|
|
songs: Vec<LibraryItem>,
|
|
songs_state: ListState,
|
|
queue: Vec<LibraryItem>,
|
|
queue_state: ListState,
|
|
search_query: String,
|
|
search_results: Vec<LibraryItem>,
|
|
search_state: ListState,
|
|
current_artist_filter: Option<String>,
|
|
current_album_filter: Option<String>,
|
|
}
|
|
|
|
pub struct Tui {
|
|
terminal: Terminal<CrosstermBackend<Stdout>>,
|
|
events: Receiver<UiEvent>,
|
|
_event_sender: Sender<UiEvent>,
|
|
jellyfin: JellyfinClient,
|
|
player: Player,
|
|
|
|
active_tab: usize,
|
|
artists: Vec<String>,
|
|
artists_state: ListState,
|
|
all_albums: Vec<LibraryItem>,
|
|
albums: Vec<LibraryItem>,
|
|
albums_state: ListState,
|
|
all_songs: Vec<LibraryItem>,
|
|
songs: Vec<LibraryItem>,
|
|
songs_state: ListState,
|
|
queue: Vec<LibraryItem>,
|
|
queue_state: ListState,
|
|
search_query: String,
|
|
search_results: Vec<LibraryItem>,
|
|
search_state: ListState,
|
|
show_help: bool,
|
|
current_artist_filter: Option<String>,
|
|
current_album_filter: Option<String>,
|
|
last_key_time: Option<Instant>,
|
|
last_key_code: Option<KeyCode>,
|
|
}
|
|
|
|
impl Tui {
|
|
pub fn new(jellyfin: JellyfinClient, player: Player) -> Result<Self> {
|
|
enable_raw_mode()?;
|
|
io::stdout().execute(EnterAlternateScreen)?;
|
|
let backend = CrosstermBackend::new(io::stdout());
|
|
let terminal = Terminal::new(backend)?;
|
|
|
|
let tick_rate = Duration::from_millis(250);
|
|
let (tx, rx) = channel();
|
|
let event_tx = tx.clone();
|
|
|
|
thread::spawn(move || {
|
|
let tick_rate = tick_rate;
|
|
loop {
|
|
if event::poll(tick_rate).unwrap() {
|
|
if let Event::Key(key) = event::read().unwrap() {
|
|
if tx.send(UiEvent::Input(key)).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
if tx.send(UiEvent::Tick).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
Ok(Self {
|
|
terminal,
|
|
events: rx,
|
|
_event_sender: event_tx,
|
|
jellyfin,
|
|
player,
|
|
active_tab: 0,
|
|
artists: Vec::new(),
|
|
artists_state: ListState::default(),
|
|
all_albums: Vec::new(),
|
|
albums: Vec::new(),
|
|
albums_state: ListState::default(),
|
|
all_songs: Vec::new(),
|
|
songs: Vec::new(),
|
|
songs_state: ListState::default(),
|
|
queue: Vec::new(),
|
|
queue_state: ListState::default(),
|
|
search_query: String::new(),
|
|
search_results: Vec::new(),
|
|
search_state: ListState::default(),
|
|
show_help: false,
|
|
current_artist_filter: None,
|
|
current_album_filter: None,
|
|
last_key_time: None,
|
|
last_key_code: None,
|
|
})
|
|
}
|
|
|
|
pub async fn run(&mut self) -> Result<()> {
|
|
self.load_library_data().await?;
|
|
|
|
if !self.artists.is_empty() {
|
|
self.artists_state.select(Some(0));
|
|
}
|
|
if !self.albums.is_empty() {
|
|
self.albums_state.select(Some(0));
|
|
}
|
|
if !self.songs.is_empty() {
|
|
self.songs_state.select(Some(0));
|
|
}
|
|
self.clear_filters();
|
|
|
|
let debounce_duration = Duration::from_millis(150);
|
|
loop {
|
|
let ui_data = self.prepare_ui_data();
|
|
self.terminal.draw(|f| {
|
|
Self::draw_ui_static(f, &ui_data, &self.player);
|
|
})?;
|
|
match self.events.recv()? {
|
|
UiEvent::Input(event) => {
|
|
let now = Instant::now();
|
|
let is_repeat = self.last_key_code == Some(event.code)
|
|
&& self.last_key_time.map_or(false, |t| now.duration_since(t) < debounce_duration);
|
|
if !is_repeat {
|
|
self.last_key_time = Some(now);
|
|
self.last_key_code = Some(event.code);
|
|
if self.handle_input(event).await? {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
UiEvent::Tick => {
|
|
// Just tick, no action needed
|
|
}
|
|
UiEvent::Quit => break,
|
|
}
|
|
}
|
|
disable_raw_mode()?;
|
|
io::stdout().execute(LeaveAlternateScreen)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn load_library_data(&mut self) -> Result<()> {
|
|
let items = self.jellyfin.get_music_library().await?;
|
|
|
|
let mut artist_set = std::collections::HashSet::new();
|
|
for item in &items {
|
|
if let Some(artist) = item.artist_name() {
|
|
artist_set.insert(artist.clone());
|
|
}
|
|
}
|
|
self.artists = artist_set.into_iter().collect();
|
|
self.artists.sort();
|
|
|
|
self.all_albums = items.iter()
|
|
.filter(|item| item.item_type == "MusicAlbum")
|
|
.cloned()
|
|
.collect();
|
|
self.all_albums.sort_by(|a, b| a.name.cmp(&b.name));
|
|
|
|
self.all_songs = items.iter()
|
|
.filter(|item| item.item_type == "Audio")
|
|
.cloned()
|
|
.collect();
|
|
self.all_songs.sort_by(|a, b| a.name.cmp(&b.name));
|
|
|
|
// Initially show all albums and songs
|
|
self.albums = self.all_albums.clone();
|
|
self.songs = self.all_songs.clone();
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn filter_albums_by_artist(&mut self, artist: &str) {
|
|
self.albums = self.all_albums.iter()
|
|
.filter(|album| {
|
|
album.artist_name().map_or(false, |a| a == artist)
|
|
})
|
|
.cloned()
|
|
.collect();
|
|
|
|
// Reset album selection
|
|
self.albums_state.select(if self.albums.is_empty() { None } else { Some(0) });
|
|
|
|
// Clear album filter and update songs
|
|
self.current_album_filter = None;
|
|
self.filter_songs();
|
|
}
|
|
|
|
fn filter_songs_by_album(&mut self, album: &LibraryItem) {
|
|
self.current_album_filter = Some(album.name.clone());
|
|
self.filter_songs();
|
|
}
|
|
|
|
fn filter_songs(&mut self) {
|
|
self.songs = self.all_songs.iter()
|
|
.filter(|song| {
|
|
// Filter by artist if set
|
|
if let Some(ref artist_filter) = self.current_artist_filter {
|
|
if song.artist_name().map_or(true, |a| a != *artist_filter) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Filter by album if set
|
|
if let Some(ref album_filter) = self.current_album_filter {
|
|
if song.album_name().map_or(true, |a| a != *album_filter) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
true
|
|
})
|
|
.cloned()
|
|
.collect();
|
|
|
|
// Reset song selection
|
|
self.songs_state.select(if self.songs.is_empty() { None } else { Some(0) });
|
|
}
|
|
|
|
fn clear_filters(&mut self) {
|
|
self.current_artist_filter = None;
|
|
self.current_album_filter = None;
|
|
self.albums = self.all_albums.clone();
|
|
self.songs = self.all_songs.clone();
|
|
|
|
// Reset selections
|
|
self.albums_state.select(if self.albums.is_empty() { None } else { Some(0) });
|
|
self.songs_state.select(if self.songs.is_empty() { None } else { Some(0) });
|
|
}
|
|
|
|
async fn handle_input(&mut self, key: KeyEvent) -> Result<bool> {
|
|
// Global shortcuts
|
|
match (key.code, key.modifiers) {
|
|
(KeyCode::Char('q'), _) => return Ok(true),
|
|
(KeyCode::Char('?'), _) => {
|
|
self.show_help = !self.show_help;
|
|
return Ok(false);
|
|
},
|
|
(KeyCode::Char('c'), KeyModifiers::CONTROL) => return Ok(true),
|
|
(KeyCode::Tab, _) => {
|
|
self.next_tab();
|
|
return Ok(false);
|
|
},
|
|
(KeyCode::BackTab, _) => {
|
|
self.prev_tab();
|
|
return Ok(false);
|
|
},
|
|
(KeyCode::Char('1'), _) => {
|
|
self.active_tab = TAB_ARTISTS;
|
|
return Ok(false);
|
|
},
|
|
(KeyCode::Char('2'), _) => {
|
|
self.active_tab = TAB_ALBUMS;
|
|
return Ok(false);
|
|
},
|
|
(KeyCode::Char('3'), _) => {
|
|
self.active_tab = TAB_SONGS;
|
|
return Ok(false);
|
|
}
|
|
(KeyCode::Char('4'), _) => {
|
|
self.active_tab = TAB_QUEUE;
|
|
return Ok(false);
|
|
},
|
|
(KeyCode::Char('5'), _) => {
|
|
self.active_tab = TAB_SEARCH;
|
|
return Ok(false);
|
|
},
|
|
(KeyCode::Char('r'), _) => {
|
|
// Reset filters
|
|
self.clear_filters();
|
|
return Ok(false);
|
|
},
|
|
_ => {}
|
|
}
|
|
|
|
// Player controls (only if not showing help and not in search mode)
|
|
if !self.show_help && self.active_tab != TAB_SEARCH {
|
|
match key.code {
|
|
KeyCode::Char(' ') => {
|
|
self.player.send_command(PlayerCommand::PlayPause).await?;
|
|
return Ok(false);
|
|
},
|
|
KeyCode::Char('s') => {
|
|
self.player.send_command(PlayerCommand::Stop).await?;
|
|
return Ok(false);
|
|
},
|
|
KeyCode::Char('n') => {
|
|
self.player.send_command(PlayerCommand::Next).await?;
|
|
return Ok(false);
|
|
},
|
|
KeyCode::Char('p') => {
|
|
self.player.send_command(PlayerCommand::Previous).await?;
|
|
return Ok(false);
|
|
},
|
|
KeyCode::Char('+') | KeyCode::Char('=') => {
|
|
let vol = (self.player.get_volume() + 0.05).min(1.0);
|
|
self.player.send_command(PlayerCommand::SetVolume(vol)).await?;
|
|
return Ok(false);
|
|
},
|
|
KeyCode::Char('-') => {
|
|
let vol = (self.player.get_volume() - 0.05).max(0.0);
|
|
self.player.send_command(PlayerCommand::SetVolume(vol)).await?;
|
|
return Ok(false);
|
|
},
|
|
KeyCode::Left => {
|
|
let pos = (self.player.get_position() - 0.05).max(0.0);
|
|
self.player.send_command(PlayerCommand::Seek(pos)).await?;
|
|
return Ok(false);
|
|
},
|
|
KeyCode::Right => {
|
|
let pos = (self.player.get_position() + 0.05).min(1.0);
|
|
self.player.send_command(PlayerCommand::Seek(pos)).await?;
|
|
return Ok(false);
|
|
},
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// Tab-specific input handling
|
|
if !self.show_help {
|
|
match self.active_tab {
|
|
TAB_ARTISTS => self.handle_artists_input(key).await?,
|
|
TAB_ALBUMS => self.handle_albums_input(key).await?,
|
|
TAB_SONGS => self.handle_songs_input(key).await?,
|
|
TAB_QUEUE => self.handle_queue_input(key).await?,
|
|
TAB_SEARCH => self.handle_search_input(key).await?,
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
Ok(false)
|
|
}
|
|
|
|
async fn handle_artists_input(&mut self, key: KeyEvent) -> Result<()> {
|
|
match key.code {
|
|
KeyCode::Up => {
|
|
if let Some(selected) = self.artists_state.selected() {
|
|
if selected > 0 {
|
|
self.artists_state.select(Some(selected - 1));
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Down => {
|
|
if let Some(selected) = self.artists_state.selected() {
|
|
if selected < self.artists.len().saturating_sub(1) {
|
|
self.artists_state.select(Some(selected + 1));
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Enter => {
|
|
if let Some(selected) = self.artists_state.selected() {
|
|
if selected < self.artists.len() {
|
|
let artist = self.artists[selected].clone();
|
|
self.current_artist_filter = Some(artist.clone());
|
|
self.filter_albums_by_artist(&artist);
|
|
self.active_tab = TAB_ALBUMS;
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_albums_input(&mut self, key: KeyEvent) -> Result<()> {
|
|
match key.code {
|
|
KeyCode::Up => {
|
|
if let Some(selected) = self.albums_state.selected() {
|
|
if selected > 0 {
|
|
self.albums_state.select(Some(selected - 1));
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Down => {
|
|
if let Some(selected) = self.albums_state.selected() {
|
|
if selected < self.albums.len().saturating_sub(1) {
|
|
self.albums_state.select(Some(selected + 1));
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Enter => {
|
|
if let Some(selected) = self.albums_state.selected() {
|
|
if selected < self.albums.len() {
|
|
let album = self.albums[selected].clone();
|
|
self.filter_songs_by_album(&album);
|
|
self.active_tab = TAB_SONGS;
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_songs_input(&mut self, key: KeyEvent) -> Result<()> {
|
|
match key.code {
|
|
KeyCode::Up => {
|
|
if let Some(selected) = self.songs_state.selected() {
|
|
if selected > 0 {
|
|
self.songs_state.select(Some(selected - 1));
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Down => {
|
|
if let Some(selected) = self.songs_state.selected() {
|
|
if selected < self.songs.len().saturating_sub(1) {
|
|
self.songs_state.select(Some(selected + 1));
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Enter => {
|
|
if let Some(selected) = self.songs_state.selected() {
|
|
if selected < self.songs.len() {
|
|
let song = &self.songs[selected];
|
|
let url = self.jellyfin.get_stream_url(&song.id)?;
|
|
self.player.send_command(PlayerCommand::Play(url)).await?;
|
|
|
|
if !self.queue.iter().any(|item| item.id == song.id) {
|
|
self.queue.push(song.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Char('a') => {
|
|
if let Some(selected) = self.songs_state.selected() {
|
|
if selected < self.songs.len() {
|
|
let song = &self.songs[selected];
|
|
if !self.queue.iter().any(|item| item.id == song.id) {
|
|
self.queue.push(song.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_queue_input(&mut self, key: KeyEvent) -> Result<()> {
|
|
match key.code {
|
|
KeyCode::Up => {
|
|
if let Some(selected) = self.queue_state.selected() {
|
|
if selected > 0 {
|
|
self.queue_state.select(Some(selected - 1));
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Down => {
|
|
if let Some(selected) = self.queue_state.selected() {
|
|
if selected < self.queue.len().saturating_sub(1) {
|
|
self.queue_state.select(Some(selected + 1));
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Enter => {
|
|
if let Some(selected) = self.queue_state.selected() {
|
|
if selected < self.queue.len() {
|
|
let song = &self.queue[selected];
|
|
let url = self.jellyfin.get_stream_url(&song.id)?;
|
|
self.player.send_command(PlayerCommand::Play(url)).await?;
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Delete | KeyCode::Char('d') => {
|
|
if let Some(selected) = self.queue_state.selected() {
|
|
if selected < self.queue.len() {
|
|
self.queue.remove(selected);
|
|
if self.queue.is_empty() {
|
|
self.queue_state.select(None);
|
|
} else if selected >= self.queue.len() {
|
|
self.queue_state.select(Some(self.queue.len() - 1));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Char('c') => {
|
|
self.queue.clear();
|
|
self.queue_state.select(None);
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_search_input(&mut self, key: KeyEvent) -> Result<()> {
|
|
match key.code {
|
|
KeyCode::Char(c) => {
|
|
self.search_query.push(c);
|
|
self.perform_search().await?;
|
|
}
|
|
KeyCode::Backspace => {
|
|
self.search_query.pop();
|
|
self.perform_search().await?;
|
|
}
|
|
KeyCode::Up => {
|
|
if let Some(selected) = self.search_state.selected() {
|
|
if selected > 0 {
|
|
self.search_state.select(Some(selected - 1));
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Down => {
|
|
if let Some(selected) = self.search_state.selected() {
|
|
if selected < self.search_results.len().saturating_sub(1) {
|
|
self.search_state.select(Some(selected + 1));
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Enter => {
|
|
if let Some(selected) = self.search_state.selected() {
|
|
if selected < self.search_results.len() {
|
|
let song = &self.search_results[selected];
|
|
if song.item_type == "Audio" {
|
|
let url = self.jellyfin.get_stream_url(&song.id)?;
|
|
self.player.send_command(PlayerCommand::Play(url)).await?;
|
|
|
|
if !self.queue.iter().any(|item| item.id == song.id) {
|
|
self.queue.push(song.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn perform_search(&mut self) -> Result<()> {
|
|
if self.search_query.is_empty() {
|
|
self.search_results.clear();
|
|
self.search_state.select(None);
|
|
return Ok(());
|
|
}
|
|
|
|
let query = self.search_query.to_lowercase();
|
|
let mut results = Vec::new();
|
|
|
|
// Search in all songs
|
|
for item in &self.all_songs {
|
|
if item.name.to_lowercase().contains(&query) ||
|
|
item.artist_name().map_or(false, |a| a.to_lowercase().contains(&query)) ||
|
|
item.album_name().map_or(false, |a| a.to_lowercase().contains(&query)) {
|
|
results.push(item.clone());
|
|
}
|
|
}
|
|
|
|
// Search in all albums
|
|
for item in &self.all_albums {
|
|
if item.name.to_lowercase().contains(&query) ||
|
|
item.artist_name().map_or(false, |a| a.to_lowercase().contains(&query)) {
|
|
results.push(item.clone());
|
|
}
|
|
}
|
|
|
|
self.search_results = results;
|
|
|
|
if !self.search_results.is_empty() {
|
|
self.search_state.select(Some(0));
|
|
} else {
|
|
self.search_state.select(None);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn next_tab(&mut self) {
|
|
self.active_tab = (self.active_tab + 1) % 5;
|
|
}
|
|
|
|
fn prev_tab(&mut self) {
|
|
self.active_tab = (self.active_tab + 4) % 5;
|
|
}
|
|
|
|
fn prepare_ui_data(&self) -> UiData {
|
|
UiData {
|
|
active_tab: self.active_tab,
|
|
show_help: self.show_help,
|
|
artists: self.artists.clone(),
|
|
artists_state: self.artists_state.clone(),
|
|
albums: self.albums.clone(),
|
|
albums_state: self.albums_state.clone(),
|
|
songs: self.songs.clone(),
|
|
songs_state: self.songs_state.clone(),
|
|
queue: self.queue.clone(),
|
|
queue_state: self.queue_state.clone(),
|
|
search_query: self.search_query.clone(),
|
|
search_results: self.search_results.clone(),
|
|
search_state: self.search_state.clone(),
|
|
current_artist_filter: self.current_artist_filter.clone(),
|
|
current_album_filter: self.current_album_filter.clone(),
|
|
}
|
|
}
|
|
|
|
fn draw_ui_static(f: &mut Frame, ui_data: &UiData, player: &Player) {
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.margin(1)
|
|
.constraints([
|
|
Constraint::Length(3), // Tabs
|
|
Constraint::Min(0), // Content
|
|
Constraint::Length(3), // Status bar
|
|
])
|
|
.split(f.size());
|
|
|
|
// Draw tabs
|
|
let tab_titles: Vec<Span> = vec!["Artists", "Albums", "Songs", "Queue", "Search"]
|
|
.iter()
|
|
.map(|t| Span::styled(*t, Style::default().fg(Color::White)))
|
|
.collect();
|
|
|
|
let mut title = "Jellyfin ASIO Client".to_string();
|
|
if let Some(ref artist) = ui_data.current_artist_filter {
|
|
title.push_str(&format!(" - Artist: {}", artist));
|
|
}
|
|
if let Some(ref album) = ui_data.current_album_filter {
|
|
title.push_str(&format!(" - Album: {}", album));
|
|
}
|
|
|
|
let tabs = Tabs::new(tab_titles)
|
|
.block(Block::default().borders(Borders::ALL).title(title))
|
|
.highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
|
|
.select(ui_data.active_tab);
|
|
|
|
f.render_widget(tabs, chunks[0]);
|
|
|
|
if ui_data.show_help {
|
|
Self::draw_help(f, chunks[1]);
|
|
} else {
|
|
match ui_data.active_tab {
|
|
TAB_ARTISTS => Self::draw_artists(f, chunks[1], ui_data),
|
|
TAB_ALBUMS => Self::draw_albums(f, chunks[1], ui_data),
|
|
TAB_SONGS => Self::draw_songs(f, chunks[1], ui_data),
|
|
TAB_QUEUE => Self::draw_queue(f, chunks[1], ui_data),
|
|
TAB_SEARCH => Self::draw_search(f, chunks[1], ui_data),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
Self::draw_status_bar(f, chunks[2], player);
|
|
}
|
|
|
|
fn draw_artists(f: &mut Frame, area: Rect, ui_data: &UiData) {
|
|
let items: Vec<ListItem> = ui_data.artists
|
|
.iter()
|
|
.map(|artist| {
|
|
ListItem::new(Line::from(vec![Span::raw(artist.clone())]))
|
|
})
|
|
.collect();
|
|
|
|
let list = List::new(items)
|
|
.block(Block::default().borders(Borders::ALL).title("Artists"))
|
|
.highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
|
|
|
let mut state = ui_data.artists_state.clone();
|
|
f.render_stateful_widget(list, area, &mut state);
|
|
}
|
|
|
|
fn draw_albums(f: &mut Frame, area: Rect, ui_data: &UiData) {
|
|
let items: Vec<ListItem> = ui_data.albums
|
|
.iter()
|
|
.map(|album| {
|
|
let artist = album.artist_name()
|
|
.map(|s| s.to_string())
|
|
.unwrap_or_else(|| "Unknown Artist".to_string());
|
|
ListItem::new(Line::from(vec![
|
|
Span::raw(album.name.clone()),
|
|
Span::raw(" - ".to_string()),
|
|
Span::raw(artist),
|
|
]))
|
|
})
|
|
.collect();
|
|
|
|
let mut title = "Albums".to_string();
|
|
if let Some(ref artist) = ui_data.current_artist_filter {
|
|
title.push_str(&format!(" ({})", artist));
|
|
}
|
|
|
|
let list = List::new(items)
|
|
.block(Block::default().borders(Borders::ALL).title(title))
|
|
.highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
|
|
|
let mut state = ui_data.albums_state.clone();
|
|
f.render_stateful_widget(list, area, &mut state);
|
|
}
|
|
|
|
fn draw_songs(f: &mut Frame, area: Rect, ui_data: &UiData) {
|
|
let items: Vec<ListItem> = ui_data.songs
|
|
.iter()
|
|
.map(|song| {
|
|
let artist = song.artist_name()
|
|
.map(|s| s.to_string())
|
|
.unwrap_or_else(|| "Unknown Artist".to_string());
|
|
let album = song.album_name()
|
|
.map(|s| s.to_string())
|
|
.unwrap_or_else(|| "Unknown Album".to_string());
|
|
ListItem::new(Line::from(vec![
|
|
Span::raw(song.name.clone()),
|
|
Span::raw(" - ".to_string()),
|
|
Span::raw(artist),
|
|
Span::raw(" (Album: ".to_string()),
|
|
Span::raw(album),
|
|
Span::raw(")".to_string()),
|
|
]))
|
|
})
|
|
.collect();
|
|
|
|
let mut title = "Songs".to_string();
|
|
if let Some(ref album) = ui_data.current_album_filter {
|
|
title.push_str(&format!(" ({})", album));
|
|
} else if let Some(ref artist) = ui_data.current_artist_filter {
|
|
title.push_str(&format!(" ({})", artist));
|
|
}
|
|
|
|
let list = List::new(items)
|
|
.block(Block::default().borders(Borders::ALL).title(title))
|
|
.highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
|
|
|
let mut state = ui_data.songs_state.clone();
|
|
f.render_stateful_widget(list, area, &mut state);
|
|
}
|
|
|
|
fn draw_queue(f: &mut Frame, area: Rect, ui_data: &UiData) {
|
|
let items: Vec<ListItem> = ui_data.queue
|
|
.iter()
|
|
.map(|song| {
|
|
let artist = song.artist_name()
|
|
.map(|s| s.to_string())
|
|
.unwrap_or_else(|| "Unknown Artist".to_string());
|
|
ListItem::new(Line::from(vec![
|
|
Span::raw(song.name.clone()),
|
|
Span::raw(" - ".to_string()),
|
|
Span::raw(artist),
|
|
]))
|
|
})
|
|
.collect();
|
|
|
|
let list = List::new(items)
|
|
.block(Block::default().borders(Borders::ALL).title("Queue"))
|
|
.highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
|
|
|
let mut state = ui_data.queue_state.clone();
|
|
f.render_stateful_widget(list, area, &mut state);
|
|
}
|
|
|
|
fn draw_search(f: &mut Frame, area: Rect, ui_data: &UiData) {
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(3), // Search input
|
|
Constraint::Min(0), // Search results
|
|
])
|
|
.split(area);
|
|
|
|
// Search input
|
|
let search_text = Paragraph::new(ui_data.search_query.clone())
|
|
.block(Block::default().borders(Borders::ALL).title("Search"));
|
|
f.render_widget(search_text, chunks[0]);
|
|
|
|
// Search results
|
|
let items: Vec<ListItem> = ui_data.search_results
|
|
.iter()
|
|
.map(|item| {
|
|
// Fixed: Clone the artist string to avoid temporary value issues
|
|
let artist = item.artist_name()
|
|
.map(|s| s.to_string())
|
|
.unwrap_or_else(|| "Unknown Artist".to_string());
|
|
let type_str = match item.item_type.as_str() {
|
|
"Audio" => "Song",
|
|
"MusicAlbum" => "Album",
|
|
_ => &item.item_type,
|
|
};
|
|
|
|
// Fixed: use Line::from instead of Span::from with Vec
|
|
ListItem::new(Line::from(vec![
|
|
Span::raw(format!("[{}] ", type_str)),
|
|
Span::raw(item.name.clone()),
|
|
Span::raw(" - ".to_string()),
|
|
Span::raw(artist),
|
|
]))
|
|
})
|
|
.collect();
|
|
|
|
let list = List::new(items)
|
|
.block(Block::default().borders(Borders::ALL).title("Results"))
|
|
.highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
|
|
|
let mut state = ui_data.search_state.clone();
|
|
f.render_stateful_widget(list, chunks[1], &mut state);
|
|
}
|
|
|
|
fn draw_status_bar(f: &mut Frame, area: Rect, player: &Player) {
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Percentage(70), // Now playing
|
|
Constraint::Percentage(30), // Controls help
|
|
])
|
|
.split(area);
|
|
|
|
// Now playing info
|
|
let now_playing = match player.get_current_item() {
|
|
Some(item) => {
|
|
// Fixed: Clone the artist string to avoid temporary value issues
|
|
let artist = item.artist_name()
|
|
.map(|s| s.to_string())
|
|
.unwrap_or_else(|| "Unknown Artist".to_string());
|
|
let state = match player.get_playback_state() {
|
|
PlaybackState::Playing => "▶",
|
|
PlaybackState::Paused => "⏸",
|
|
PlaybackState::Stopped => "⏹",
|
|
};
|
|
|
|
let position = player.get_position();
|
|
let position_str = format!("{:.0}%", position * 100.0);
|
|
|
|
format!("{} {} - {} ({})", state, item.name, artist, position_str)
|
|
},
|
|
None => "Not playing".to_string(),
|
|
};
|
|
|
|
let playing_text = Paragraph::new(now_playing)
|
|
.block(Block::default().borders(Borders::ALL).title("Now Playing"));
|
|
f.render_widget(playing_text, chunks[0]);
|
|
|
|
// Controls help
|
|
let controls = "Space: Play/Pause | s: Stop | n/p: Next/Prev";
|
|
let controls_text = Paragraph::new(controls)
|
|
.block(Block::default().borders(Borders::ALL).title("Controls"));
|
|
f.render_widget(controls_text, chunks[1]);
|
|
}
|
|
|
|
fn draw_help(f: &mut Frame, area: Rect) {
|
|
// Fixed: use Text::from_iter or convert to proper Text format
|
|
let help_text = Text::from_iter([
|
|
"Keyboard Controls:",
|
|
"",
|
|
"Tab/Shift+Tab: Switch tabs",
|
|
"1-5: Switch to specific tab",
|
|
"Up/Down: Navigate lists",
|
|
"Enter: Select/Play item",
|
|
"",
|
|
"Space: Play/Pause",
|
|
"s: Stop playback",
|
|
"n: Next track",
|
|
"p: Previous track",
|
|
"Left/Right: Seek backward/forward",
|
|
"+/-: Adjust volume",
|
|
"",
|
|
"In Songs tab:",
|
|
" a: Add to queue",
|
|
"",
|
|
"In Queue tab:",
|
|
" d: Delete selected item",
|
|
" c: Clear queue",
|
|
"",
|
|
"?: Toggle help",
|
|
"q: Quit",
|
|
]);
|
|
|
|
let help = Paragraph::new(help_text)
|
|
.block(Block::default().borders(Borders::ALL).title("Help"));
|
|
|
|
f.render_widget(help, area);
|
|
}
|
|
} |