jellyfin-tui-asio/src/tui.rs
2025-06-20 15:45:52 +02:00

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);
}
}