use anyhow::Result;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{
Block, Borders, Clear, Gauge, Paragraph, Wrap,
},
Frame, Terminal,
};
use std::io;
use std::io::{BufRead, BufReader};
use std::process::{Command as OsCommand, Stdio};
use std::thread;
use std::path::PathBuf;
use std::sync::mpsc;
use crate::commands::{get_command_suggestions, Command};
use crate::playlists::PlaylistsManager;
use crate::types::{LogEntry, PlayerState, TuiMode};
use crate::utils;
pub struct TuiApp {
mode: TuiMode,
input: String,
command_input: String,
command_suggestion_index: usize, help_visible: bool,
help_scroll: u16,
config_visible: bool,
config_section_index: usize, config_item_index: usize, config_sections: Vec<String>, config_editing: bool, config_edit_input: String, command_sender: mpsc::Sender<Command>,
state_receiver: Option<mpsc::Receiver<PlayerState>>,
log_receiver: Option<mpsc::Receiver<Command>>,
logs: Vec<LogEntry>,
status_message: String,
loading: bool,
loading_start: Option<std::time::Instant>,
yt_search_query: String,
yt_search_input_active: bool,
yt_search_results: Vec<YouTubeSearchResult>,
yt_selected_index: usize,
yt_is_searching: bool,
yt_result_rx: Option<mpsc::Receiver<YouTubeSearchResult>>, playlists_visible: bool,
playlists_manager: PlaylistsManager,
pl_selected_playlist_idx: usize,
pl_selected_item_idx: usize,
pl_focus: u8,
now_playing_playlist_id: Option<String>,
now_playing_item_id: Option<String>,
pl_show_ids: bool,
}
#[derive(Debug, Clone)]
struct YouTubeSearchResult {
title: String,
duration: Option<String>,
id: String,
}
impl TuiApp {
pub fn new(
command_sender: mpsc::Sender<Command>,
state_receiver: mpsc::Receiver<PlayerState>,
log_receiver: mpsc::Receiver<Command>,
) -> Self {
let mut playlists_manager = PlaylistsManager::new_or_default();
playlists_manager.ensure_default();
let _ = playlists_manager.save();
Self {
mode: TuiMode::Normal, input: String::new(),
command_input: String::new(),
command_suggestion_index: 0,
help_visible: false,
help_scroll: 0,
config_visible: false,
config_section_index: 0,
config_item_index: 0,
config_sections: vec![
"Player".to_string(),
"Network".to_string(),
"Appearance".to_string(),
"Media".to_string(),
"Advanced".to_string(),
"YouTube".to_string(),
],
config_editing: false,
config_edit_input: String::new(),
command_sender,
state_receiver: Some(state_receiver),
log_receiver: Some(log_receiver),
logs: Vec::new(),
status_message: "VLC started automatically - ready to use!".to_string(),
loading: false,
loading_start: None,
yt_search_query: String::new(),
yt_search_input_active: true,
yt_search_results: Vec::new(),
yt_selected_index: 0,
yt_is_searching: false,
yt_result_rx: None,
playlists_visible: false,
playlists_manager,
pl_selected_playlist_idx: 0,
pl_selected_item_idx: 0,
pl_focus: 0,
pl_show_ids: false,
now_playing_playlist_id: None,
now_playing_item_id: None,
}
}
pub fn run(&mut self, mut terminal: Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
let mut current_state = PlayerState::default();
self.add_log("INFO", "TUI thread started");
loop {
if let Some(start_time) = self.loading_start {
if start_time.elapsed() > std::time::Duration::from_secs(300) {
self.loading = false;
self.loading_start = None;
self.add_log("ERROR", "Loading timed out after 300 seconds");
}
}
if let Some(ref receiver) = self.state_receiver {
if let Ok(new_state) = receiver.try_recv() {
current_state = new_state;
if self.loading {
self.loading = false;
self.loading_start = None;
if current_state.media_info.title.as_deref().unwrap_or("").contains("Error") {
self.add_log("ERROR", "Failed to load media");
} else if !current_state.media_info.filename.is_empty() {
self.add_log("INFO", &format!("Successfully loaded: {}", current_state.media_info.filename));
}
}
}
}
let mut pending_commands = Vec::new();
if let Some(ref receiver) = self.log_receiver {
while let Ok(command) = receiver.try_recv() {
pending_commands.push(command);
}
}
for command in pending_commands {
match command {
Command::Log(log_entry) => {
self.logs.push(log_entry);
if self.logs.len() > 100 {
self.logs.remove(0);
}
}
Command::PlaylistReload => {
let _ = self.playlists_manager.reload();
}
Command::PlaylistNowPlaying { playlist_id, item_id } => {
self.now_playing_playlist_id = Some(playlist_id);
self.now_playing_item_id = Some(item_id);
}
Command::PlaylistClearNowPlaying => {
self.now_playing_playlist_id = None;
self.now_playing_item_id = None;
}
Command::Quit => {
self.add_log("INFO", "Received quit command, exiting TUI");
return Ok(());
}
Command::ShowConfig => {
self.add_log("DEBUG", "Received ShowConfig command, switching to config mode");
self.mode = TuiMode::Config;
self.config_visible = true;
}
Command::HideConfig => {
self.add_log("DEBUG", "Received HideConfig command, switching to normal mode");
self.mode = TuiMode::Normal;
self.config_visible = false;
}
_ => {
}
}
}
if let Some(rx) = self.yt_result_rx.as_ref() {
loop {
match rx.try_recv() {
Ok(item) => {
self.yt_search_results.push(item);
}
Err(mpsc::TryRecvError::Empty) => { break; }
Err(mpsc::TryRecvError::Disconnected) => {
self.yt_is_searching = false;
self.yt_result_rx = None;
break;
}
}
}
}
terminal.draw(|f| self.draw(f, ¤t_state))?;
if event::poll(std::time::Duration::from_millis(16))? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
self.add_log("DEBUG", &format!("Key pressed: {:?} in mode {:?}", key.code, self.mode));
let should_quit = match self.mode {
TuiMode::Normal => self.handle_normal_key(key.code)?,
TuiMode::Input => self.handle_input_key(key.code)?,
TuiMode::Command => self.handle_command_key(key.code)?,
TuiMode::Help => self.handle_help_key(key.code)?,
TuiMode::Config => self.handle_config_key(key.code)?,
TuiMode::YoutubeSearch => self.handle_youtube_search_key(key.code)?,
};
if should_quit {
self.add_log("INFO", "Quit requested from key handler, exiting TUI");
break;
}
}
}
}
}
self.add_log("INFO", "TUI thread ending normally");
Ok(())
}
fn handle_normal_key(&mut self, key: KeyCode) -> Result<bool> {
match key {
KeyCode::Char('p') | KeyCode::Char('P') => {
self.playlists_visible = !self.playlists_visible;
self.add_log("INFO", if self.playlists_visible { "Playlists panel opened" } else { "Playlists panel closed" });
}
KeyCode::Char('I') => {
if self.playlists_visible {
self.pl_show_ids = !self.pl_show_ids;
self.add_log("INFO", if self.pl_show_ids { "Playlist IDs shown" } else { "Playlist IDs hidden" });
}
}
KeyCode::Tab => {
if self.playlists_visible {
self.pl_focus = (self.pl_focus + 1) % 3;
} else {
}
}
KeyCode::Delete => {
if self.playlists_visible {
self.delete_selected_playlist_or_item()?;
}
}
KeyCode::Char('q') => {
self.add_log("INFO", "Quit key pressed, sending quit event");
let _ = self.command_sender.send(Command::Quit);
return Ok(true);
}
KeyCode::Char('h') => {
self.add_log("DEBUG", "Help key pressed, showing help");
let _ = self.command_sender.send(Command::ShowHelp);
self.mode = TuiMode::Help;
self.help_visible = true;
}
KeyCode::Char('C') => {
self.add_log("DEBUG", "Config key pressed, showing config");
let _ = self.command_sender.send(Command::ShowConfig);
}
KeyCode::Char('i') => {
self.add_log("DEBUG", "Input key pressed, entering input mode");
self.mode = TuiMode::Input;
self.input.clear();
}
KeyCode::Char(' ') => {
self.add_log("DEBUG", "Space key pressed, sending play/pause event");
let _ = self.command_sender.send(Command::Toggle);
}
KeyCode::Char('s') => {
self.add_log("DEBUG", "Stop key pressed, sending stop event");
let _ = self.command_sender.send(Command::Stop);
}
KeyCode::Char('m') => {
self.add_log("DEBUG", "Mute key pressed, sending mute event");
let _ = self.command_sender.send(Command::Mute);
}
KeyCode::Up => {
if self.playlists_visible && (self.pl_focus == 1 || self.pl_focus == 2) {
if self.pl_focus == 1 {
if self.pl_selected_playlist_idx > 0 { self.pl_selected_playlist_idx -= 1; self.pl_selected_item_idx = 0; }
} else if self.pl_selected_item_idx > 0 {
self.pl_selected_item_idx -= 1;
}
} else {
self.add_log("DEBUG", "Up arrow pressed, sending volume up event");
let _ = self.command_sender.send(Command::VolumeUp);
}
}
KeyCode::Down => {
if self.playlists_visible && (self.pl_focus == 1 || self.pl_focus == 2) {
if self.pl_focus == 1 {
let len = self.playlists_manager.list().len();
if len > 0 { self.pl_selected_playlist_idx = (self.pl_selected_playlist_idx + 1).min(len.saturating_sub(1)); self.pl_selected_item_idx = 0; }
} else if let Some(pl) = self.playlists_manager.list().get(self.pl_selected_playlist_idx) {
let len = pl.items.len();
if len > 0 { self.pl_selected_item_idx = (self.pl_selected_item_idx + 1).min(len.saturating_sub(1)); }
}
} else {
self.add_log("DEBUG", "Down arrow pressed, sending volume down event");
let _ = self.command_sender.send(Command::VolumeDown);
}
}
KeyCode::Left => {
self.add_log("DEBUG", "Left arrow pressed, sending seek backward event");
let _ = self.command_sender.send(Command::SeekBackward);
}
KeyCode::Right => {
self.add_log("DEBUG", "Right arrow pressed, sending seek forward event");
let _ = self.command_sender.send(Command::SeekForward);
}
KeyCode::Enter => {
if self.playlists_visible && self.pl_focus == 2 {
if let Some(pl) = self.playlists_manager.list().get(self.pl_selected_playlist_idx) {
let _ = self.command_sender.send(Command::PlaylistPlay { playlist_id: pl.id.clone(), index: self.pl_selected_item_idx });
}
}
}
KeyCode::Char('=') | KeyCode::Char('+') => {
self.add_log("DEBUG", "Speed up key pressed, sending speed up event");
let _ = self.command_sender.send(Command::SpeedUp);
}
KeyCode::Char('-') => {
self.add_log("DEBUG", "Speed down key pressed, sending speed down event");
let _ = self.command_sender.send(Command::SpeedDown);
}
KeyCode::Char('r') => {
self.add_log("DEBUG", "Reset speed key pressed, sending reset speed event");
let _ = self.command_sender.send(Command::SpeedReset);
}
KeyCode::Char('f') => {
self.add_log("DEBUG", "Fullscreen key pressed, sending toggle fullscreen event");
let _ = self.command_sender.send(Command::ToggleFullscreen);
}
KeyCode::Char('l') => {
self.add_log("DEBUG", "Loop key pressed, sending loop toggle event");
let _ = self.command_sender.send(Command::ToggleLoop);
}
KeyCode::Char('c') => {
self.add_log("DEBUG", "Connection test key pressed, testing VLC connection");
let _ = self.command_sender.send(Command::TestConnection);
}
KeyCode::Char('y') => {
self.add_log("DEBUG", "YouTube search key pressed, opening search window");
self.mode = TuiMode::YoutubeSearch;
self.yt_search_input_active = true;
}
KeyCode::Char(':') => {
self.add_log("DEBUG", "Colon pressed, entering command mode");
self.mode = TuiMode::Command;
self.command_input.clear();
self.command_suggestion_index = 0;
}
_ => {
self.add_log("DEBUG", &format!("Unhandled key: {:?}", key));
}
}
Ok(false)
}
fn handle_input_key(&mut self, key: KeyCode) -> Result<bool> {
match key {
KeyCode::Enter => {
if !self.input.is_empty() {
let input = self.input.clone();
self.add_log("INFO", &format!("Input entered: {}", input));
self.loading = true;
self.loading_start = Some(std::time::Instant::now());
self.add_log("INFO", &format!("Loading: {}", input));
let _ = self.command_sender.send(Command::Open(input));
self.input.clear();
self.mode = TuiMode::Normal;
self.add_log("DEBUG", "Switched back to normal mode");
} else {
self.add_log("WARN", "Empty input entered, ignoring");
}
}
KeyCode::Esc => {
self.add_log("DEBUG", "Escape pressed in input mode, canceling input");
self.input.clear();
self.mode = TuiMode::Normal;
}
KeyCode::Char(c) => {
self.input.push(c);
self.add_log("DEBUG", &format!("Character added to input: '{}', current input: '{}'", c, self.input));
}
KeyCode::Backspace => {
if !self.input.is_empty() {
let removed = self.input.pop();
self.add_log("DEBUG", &format!("Backspace pressed, removed: '{:?}', current input: '{}'", removed, self.input));
} else {
self.add_log("DEBUG", "Backspace pressed but input is empty");
}
}
_ => {
self.add_log("DEBUG", &format!("Unhandled key in input mode: {:?}", key));
}
}
Ok(false)
}
fn handle_command_key(&mut self, key: KeyCode) -> Result<bool> {
match key {
KeyCode::Esc => {
self.add_log("DEBUG", "Escape pressed, exiting command mode");
self.mode = TuiMode::Normal;
self.command_input.clear();
self.command_suggestion_index = 0;
}
KeyCode::Enter => {
if !self.command_input.is_empty() {
self.add_log("DEBUG", &format!("Executing command: {}", self.command_input));
let _ = self.command_sender.send(Command::ExecuteCommand(self.command_input.clone()));
self.command_input.clear();
self.command_suggestion_index = 0;
self.mode = TuiMode::Normal;
}
}
KeyCode::Tab => {
let suggestions = get_command_suggestions(&self.command_input);
if !suggestions.is_empty() {
if let Some(suggestion) = suggestions.get(self.command_suggestion_index) {
self.command_input = suggestion.command.clone();
self.command_suggestion_index = 0; }
}
}
KeyCode::Up => {
let suggestions = get_command_suggestions(&self.command_input);
if !suggestions.is_empty() {
if self.command_suggestion_index == 0 {
self.command_suggestion_index = suggestions.len() - 1;
} else {
self.command_suggestion_index -= 1;
}
}
}
KeyCode::Down => {
let suggestions = get_command_suggestions(&self.command_input);
if !suggestions.is_empty() {
self.command_suggestion_index = (self.command_suggestion_index + 1) % suggestions.len();
}
}
KeyCode::BackTab => {
let suggestions = get_command_suggestions(&self.command_input);
if !suggestions.is_empty() {
if self.command_suggestion_index == 0 {
self.command_suggestion_index = suggestions.len() - 1;
} else {
self.command_suggestion_index -= 1;
}
}
}
KeyCode::Backspace => {
self.command_input.pop();
self.command_suggestion_index = 0; }
KeyCode::Char(c) => {
self.command_input.push(c);
self.command_suggestion_index = 0; }
_ => {
self.add_log("DEBUG", &format!("Unhandled key in command mode: {:?}", key));
}
}
Ok(false)
}
fn handle_help_key(&mut self, key: KeyCode) -> Result<bool> {
match key {
KeyCode::Esc | KeyCode::Char('h') | KeyCode::Char('q') => {
self.add_log("DEBUG", "Closing help screen");
let _ = self.command_sender.send(Command::HideHelp);
self.mode = TuiMode::Normal;
self.help_visible = false;
self.help_scroll = 0;
}
KeyCode::Up | KeyCode::Char('k') => {
self.help_scroll = self.help_scroll.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
self.help_scroll = self.help_scroll.saturating_add(1);
}
KeyCode::PageUp => {
self.help_scroll = self.help_scroll.saturating_sub(5);
}
KeyCode::PageDown => {
self.help_scroll = self.help_scroll.saturating_add(5);
}
KeyCode::Home => {
self.help_scroll = 0;
}
KeyCode::End => {
self.help_scroll = 50; }
_ => {
self.add_log("DEBUG", &format!("Unhandled key in help mode: {:?}", key));
}
}
Ok(false)
}
fn handle_config_key(&mut self, key: KeyCode) -> Result<bool> {
if self.config_editing {
match key {
KeyCode::Enter => {
match self.save_config_edit() {
Ok(()) => {
self.add_log("DEBUG", "Config value saved successfully");
}
Err(e) => {
self.add_log("ERROR", &format!("Failed to save config: {}", e));
}
}
self.config_editing = false;
self.config_edit_input.clear();
}
KeyCode::Esc => {
self.config_editing = false;
self.config_edit_input.clear();
self.add_log("DEBUG", "Config editing cancelled");
}
KeyCode::Backspace => {
self.config_edit_input.pop();
}
KeyCode::Char(c) => {
self.config_edit_input.push(c);
}
_ => {}
}
} else {
match key {
KeyCode::Char('q') | KeyCode::Esc => {
self.mode = TuiMode::Normal;
self.config_visible = false;
self.add_log("DEBUG", "Exited config mode");
}
KeyCode::Up | KeyCode::Char('k') => {
if self.config_item_index > 0 {
self.config_item_index -= 1;
} else {
self.config_item_index = self.get_config_item_count() - 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
self.config_item_index = (self.config_item_index + 1) % self.get_config_item_count();
}
KeyCode::Left | KeyCode::Char('h') => {
if self.config_section_index > 0 {
self.config_section_index -= 1;
} else {
self.config_section_index = self.config_sections.len() - 1;
}
self.config_item_index = 0; }
KeyCode::Right | KeyCode::Char('l') => {
self.config_section_index = (self.config_section_index + 1) % self.config_sections.len();
self.config_item_index = 0; }
KeyCode::Enter => {
self.start_config_edit()?;
}
KeyCode::Char('r') => {
self.reset_config_section()?;
}
KeyCode::Char('s') => {
self.add_log("INFO", "Saving configuration");
if let Err(e) = self.command_sender.send(Command::Save) {
self.add_log("ERROR", &format!("Failed to send save command: {}", e));
}
}
_ => {
self.add_log("DEBUG", &format!("Unhandled key in config mode: {:?}", key));
}
}
}
Ok(false)
}
pub fn add_log(&mut self, level: &str, message: &str) {
self.logs.push(LogEntry {
level: level.to_string(),
message: message.to_string(),
});
if self.logs.len() > 100 {
self.logs.remove(0);
}
}
fn draw(&self, f: &mut Frame, state: &PlayerState) {
let size = f.size();
if let TuiMode::Help = self.mode {
self.draw_help(f, size);
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Length(4), Constraint::Length(3), Constraint::Length(3), Constraint::Length(3), Constraint::Min(5), Constraint::Length(3), ].as_ref())
.split(size);
let title = Paragraph::new("BCK Player - VLC HTTP Interface")
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(title, chunks[0]);
self.draw_media_info(f, chunks[1], state);
self.draw_controls(f, chunks[2], state);
self.draw_volume(f, chunks[3], state);
self.draw_progress(f, chunks[4], state);
if self.playlists_visible {
self.draw_playlists(f, chunks[5]);
} else {
self.draw_logs(f, chunks[5]);
}
self.draw_status(f, chunks[6], state);
if let TuiMode::Command = self.mode {
self.draw_command_popup(f, size);
}
if let TuiMode::Config = self.mode {
self.draw_config_window(f, size);
}
if let TuiMode::YoutubeSearch = self.mode {
self.draw_youtube_search(f, size);
}
}
fn draw_playlists(&self, f: &mut Frame, area: Rect) {
let mut mgr = self.playlists_manager.clone();
let _ = mgr.reload();
let mut title_text = "Playlists (P: close | Tab: focus | Enter: play | Del: delete".to_string();
if self.pl_show_ids { title_text.push_str(" | I: hide IDs"); } else { title_text.push_str(" | I: show IDs"); }
title_text.push(')');
let outer = Block::default().title(title_text).borders(Borders::ALL);
let inner = outer.inner(area);
f.render_widget(outer, area);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(20), Constraint::Percentage(80)])
.split(inner);
let mut pls_lines: Vec<Line> = Vec::new();
for (i, p) in mgr.list().iter().enumerate() {
let selected = self.pl_focus == 1 && i == self.pl_selected_playlist_idx;
let style = if selected { Style::default().bg(Color::DarkGray).fg(Color::White) } else { Style::default() };
let marker = if i == self.pl_selected_playlist_idx { ">" } else { " " };
if self.pl_show_ids {
pls_lines.push(Line::from(vec![
Span::styled(format!("{} {}", marker, p.name), style),
]));
pls_lines.push(Line::from(vec![Span::styled(format!(" id: {}", p.id), Style::default().fg(Color::Gray))]));
} else {
pls_lines.push(Line::from(vec![
Span::styled(format!("{} {}", marker, p.name), style),
]));
}
}
if pls_lines.is_empty() {
pls_lines.push(Line::from(Span::styled("No playlists. Use :pladd <name> to create.", Style::default().fg(Color::Gray))));
}
let pls = Paragraph::new(pls_lines)
.block(Block::default().title("Playlists").borders(Borders::ALL))
.wrap(Wrap { trim: true });
f.render_widget(pls, cols[0]);
let right_block = Block::default().title("Items (Enter: play | Del: delete | In YouTube: A to add)").borders(Borders::ALL);
let right_inner = right_block.inner(cols[1]);
f.render_widget(right_block, cols[1]);
let mut item_lines: Vec<Line> = Vec::new();
if let Some(pl) = mgr.list().get(self.pl_selected_playlist_idx) {
for (j, item) in pl.items.iter().enumerate() {
let selected = self.pl_focus == 2 && j == self.pl_selected_item_idx;
let mut style = if selected { Style::default().bg(Color::DarkGray).fg(Color::White) } else { Style::default() };
let mut prefix = if j == self.pl_selected_item_idx { ">" } else { " " };
if self.now_playing_playlist_id.as_deref() == Some(&pl.id) && self.now_playing_item_id.as_deref() == Some(&item.id) {
style = style.fg(Color::Green).add_modifier(Modifier::BOLD);
prefix = "▶";
}
item_lines.push(Line::from(vec![
Span::styled(format!("{} {} [{}] (played {}x)", prefix, item.name, item.duration, item.num_played), style)
]));
if self.pl_show_ids {
item_lines.push(Line::from(vec![Span::styled(format!(" id: {}", item.id), Style::default().fg(Color::Gray))]));
}
}
if item_lines.is_empty() {
item_lines.push(Line::from(Span::styled("No items. Add via :plitemadd or from YouTube (A).", Style::default().fg(Color::Gray))));
}
} else {
item_lines.push(Line::from(Span::styled("No playlist selected.", Style::default().fg(Color::Gray))));
}
let items = Paragraph::new(item_lines).wrap(Wrap { trim: true });
f.render_widget(items, right_inner);
}
fn delete_selected_playlist_or_item(&mut self) -> Result<()> {
if self.pl_focus == 1 {
let id_opt = self.playlists_manager.list().get(self.pl_selected_playlist_idx).map(|p| p.id.clone());
if let Some(id) = id_opt {
let _ = self.command_sender.send(Command::PlaylistDelete { playlist_id: id.clone() });
let _ = self.playlists_manager.remove_playlist(&id);
let _ = self.playlists_manager.save();
let _ = self.playlists_manager.reload();
let _ = self.command_sender.send(Command::PlaylistReload);
if self.pl_selected_playlist_idx > 0 { self.pl_selected_playlist_idx -= 1; }
}
} else if self.pl_focus == 2 {
let ids_opt = self.playlists_manager.list().get(self.pl_selected_playlist_idx).and_then(|pl| pl.items.get(self.pl_selected_item_idx).map(|it| (pl.id.clone(), it.id.clone())));
if let Some((pl_id, item_id)) = ids_opt {
let _ = self.command_sender.send(Command::PlaylistItemDelete { playlist_id: pl_id.clone(), item_id: item_id.clone() });
if let Some(pl_mut) = self.playlists_manager.list_mut().get_mut(self.pl_selected_playlist_idx) {
let _ = pl_mut.remove_item(&item_id);
}
let _ = self.playlists_manager.save();
let _ = self.playlists_manager.reload();
let _ = self.command_sender.send(Command::PlaylistReload);
if self.pl_selected_item_idx > 0 { self.pl_selected_item_idx -= 1; }
}
}
Ok(())
}
fn draw_media_info(&self, f: &mut Frame, area: Rect, state: &PlayerState) {
let info_lines = vec![
Line::from(vec![
Span::styled("VLC Connection: ", Style::default().fg(Color::Yellow)),
if state.connection_status.connected {
Span::styled("Connected", Style::default().fg(Color::Green))
} else {
Span::styled("Disconnected", Style::default().fg(Color::Red))
},
Span::raw(format!(" ({}:{})", state.connection_status.host, state.connection_status.port)),
]),
Line::from(vec![
Span::styled("Password: ", Style::default().fg(Color::Yellow)),
if state.connection_status.password_set && !state.connection_status.password.is_empty() {
Span::styled(&state.connection_status.password, Style::default().fg(Color::Cyan))
} else {
Span::styled("Not Set", Style::default().fg(Color::Red))
},
]),
Line::from(vec![
Span::styled("Status: ", Style::default().fg(Color::Yellow)),
Span::raw(state.state.to_string()),
]),
Line::from(vec![
Span::styled("Title: ", Style::default().fg(Color::Yellow)),
Span::raw(state.media_info.title.as_deref().unwrap_or("No media loaded")),
]),
Line::from(vec![
Span::styled("File: ", Style::default().fg(Color::Yellow)),
Span::raw(&state.media_info.filename),
]),
Line::from(vec![
Span::styled("Loop: ", Style::default().fg(Color::Yellow)),
if state.media_info.loop_enabled {
Span::styled("ON", Style::default().fg(Color::Green))
} else {
Span::styled("OFF", Style::default().fg(Color::Red))
},
Span::raw(" "),
Span::styled("Fullscreen: ", Style::default().fg(Color::Yellow)),
if state.fullscreen {
Span::styled("ON", Style::default().fg(Color::Green))
} else {
Span::styled("OFF", Style::default().fg(Color::Red))
},
]),
];
let media_info = Paragraph::new(info_lines)
.block(Block::default().title("Media Info").borders(Borders::ALL))
.wrap(Wrap { trim: true });
f.render_widget(media_info, area);
}
fn draw_controls(&self, f: &mut Frame, area: Rect, _state: &PlayerState) {
let controls_text = vec![
Line::from(vec![
Span::styled("Space", Style::default().fg(Color::Green)),
Span::raw(": Play/Pause "),
Span::styled("S", Style::default().fg(Color::Green)),
Span::raw(": Stop "),
Span::styled("M", Style::default().fg(Color::Green)),
Span::raw(": Mute "),
Span::styled("F", Style::default().fg(Color::Green)),
Span::raw(": Fullscreen "),
Span::styled("L", Style::default().fg(Color::Green)),
Span::raw(": Loop"),
]),
Line::from(vec![
Span::styled("C", Style::default().fg(Color::Yellow)),
Span::raw(": Test Connection "),
Span::styled("Shift+C", Style::default().fg(Color::Magenta)),
Span::raw(": Config "),
Span::styled("P", Style::default().fg(Color::Green)),
Span::raw(": Playlists "),
Span::styled("Y", Style::default().fg(Color::Red)),
Span::raw(": YouTube Search "),
Span::styled("I", Style::default().fg(Color::Green)),
Span::raw(": Input "),
Span::styled("H", Style::default().fg(Color::Green)),
Span::raw(": Help "),
Span::styled("Q", Style::default().fg(Color::Green)),
Span::raw(": Quit"),
]),
];
let controls = Paragraph::new(controls_text)
.block(Block::default().title("Controls").borders(Borders::ALL))
.alignment(Alignment::Center);
f.render_widget(controls, area);
}
fn draw_volume(&self, f: &mut Frame, area: Rect, state: &PlayerState) {
let volume_ratio = (state.media_info.volume / 100.0).clamp(0.0, 1.0);
let volume_text = utils::format_volume(state.media_info.volume, state.media_info.muted);
let volume_gauge = Gauge::default()
.block(Block::default().title("Volume").borders(Borders::ALL))
.gauge_style(Style::default().fg(if state.media_info.muted {
Color::Red
} else {
Color::Blue
}))
.ratio(volume_ratio)
.label(volume_text);
f.render_widget(volume_gauge, area);
}
fn draw_progress(&self, f: &mut Frame, area: Rect, state: &PlayerState) {
let progress_ratio = if let Some(duration) = state.media_info.duration {
if duration > 0.0 {
(state.media_info.position / duration).clamp(0.0, 1.0)
} else {
0.0
}
} else {
0.0
};
let position_text = utils::format_duration(state.media_info.position);
let duration_text = state.media_info.duration
.map(utils::format_duration)
.unwrap_or_else(|| "??:??".to_string());
let speed_text = utils::format_speed(state.media_info.speed);
let progress_text = format!("{} / {} | {}", position_text, duration_text, speed_text);
let progress_gauge = Gauge::default()
.block(Block::default().title("Progress").borders(Borders::ALL))
.gauge_style(Style::default().fg(Color::Green))
.ratio(progress_ratio)
.label(progress_text);
f.render_widget(progress_gauge, area);
}
fn draw_logs(&self, f: &mut Frame, area: Rect) {
let available_height = area.height.saturating_sub(2) as usize;
let start_index = if self.logs.len() > available_height {
self.logs.len() - available_height
} else {
0
};
let log_lines: Vec<Line> = self.logs
.iter()
.skip(start_index)
.map(|log| {
let color = match log.level.as_str() {
"ERROR" => Color::Red,
"WARN" => Color::Yellow,
"INFO" => Color::Green,
"DEBUG" => Color::Blue,
_ => Color::White,
};
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() % 86400;
let hours = timestamp / 3600;
let minutes = (timestamp % 3600) / 60;
let seconds = timestamp % 60;
Line::from(vec![
Span::styled(
format!("[{:02}:{:02}:{:02}] ", hours, minutes, seconds),
Style::default().fg(Color::Gray)
),
Span::styled(format!("[{}] ", log.level), Style::default().fg(color)),
Span::raw(&log.message),
])
})
.collect();
let logs = Paragraph::new(log_lines)
.block(Block::default().title("Logs").borders(Borders::ALL))
.wrap(Wrap { trim: true })
.scroll((0, 0)); f.render_widget(logs, area);
}
fn draw_status(&self, f: &mut Frame, area: Rect, state: &PlayerState) {
let status_text = match self.mode {
TuiMode::Normal => {
if self.loading {
Line::from(vec![
Span::styled("Loading media... ", Style::default().fg(Color::Yellow)),
Span::raw(&self.status_message),
])
} else if let Some(ref error) = state.error_message {
Line::from(vec![
Span::styled("Error: ", Style::default().fg(Color::Red)),
Span::styled(error, Style::default().fg(Color::Red)),
])
} else {
let state_text = format!("State: {} | ", state.state);
Line::from(vec![
Span::styled("Ready - ", Style::default().fg(Color::Green)),
Span::raw(state_text),
Span::raw(&self.status_message),
])
}
}
TuiMode::Input => {
Line::from(vec![
Span::styled("Enter file path or URL: ", Style::default().fg(Color::Yellow)),
Span::raw(&self.input),
Span::styled("█", Style::default().fg(Color::White)),
])
}
TuiMode::Command => {
Line::from(vec![
Span::styled("Command: :", Style::default().fg(Color::Yellow)),
Span::raw(&self.command_input),
Span::styled("█", Style::default().fg(Color::White)),
])
}
TuiMode::Help => {
Line::from(Span::styled("Press ESC or H to close help", Style::default().fg(Color::Cyan)))
}
TuiMode::Config => {
Line::from(vec![
Span::styled("Config Mode - ", Style::default().fg(Color::Magenta)),
Span::raw(format!("Section: {} | ", self.config_sections.get(self.config_section_index).unwrap_or(&"Unknown".to_string()))),
Span::styled("↑↓: Navigate | ←→: Change section | Enter: Edit | R: Reset | S: Save | ESC: Exit", Style::default().fg(Color::Gray)),
])
}
TuiMode::YoutubeSearch => {
let hint = if self.yt_search_input_active {
"Type to search, Enter: run, Esc: close, Tab: toggle focus"
} else {
"↑/↓: select, Enter: open, A: toggle audio/video, R: re-run, Tab: focus input, Esc: close"
};
Line::from(vec![
Span::styled("YouTube Search - ", Style::default().fg(Color::Red)),
Span::raw(hint),
])
}
};
let status = Paragraph::new(status_text)
.block(Block::default().title("Status").borders(Borders::ALL))
.wrap(Wrap { trim: true });
f.render_widget(status, area);
}
fn handle_youtube_search_key(&mut self, key: KeyCode) -> Result<bool> {
if self.yt_search_input_active {
match key {
KeyCode::Esc => {
self.mode = TuiMode::Normal;
}
KeyCode::Enter => {
self.start_youtube_search();
}
KeyCode::Backspace => { self.yt_search_query.pop(); }
KeyCode::Tab => { self.yt_search_input_active = false; }
KeyCode::Char(c) => { self.yt_search_query.push(c); }
_ => {}
}
} else {
match key {
KeyCode::Esc => { self.mode = TuiMode::Normal; }
KeyCode::Tab => { self.yt_search_input_active = true; }
KeyCode::Up | KeyCode::Char('k') => {
if !self.yt_search_results.is_empty() {
if self.yt_selected_index == 0 { self.yt_selected_index = self.yt_search_results.len() - 1; }
else { self.yt_selected_index -= 1; }
}
}
KeyCode::Down | KeyCode::Char('j') => {
if !self.yt_search_results.is_empty() {
self.yt_selected_index = (self.yt_selected_index + 1) % self.yt_search_results.len();
}
}
KeyCode::Char('r') | KeyCode::Char('R') => { self.start_youtube_search(); }
KeyCode::Enter => {
if let Some(sel) = self.yt_search_results.get(self.yt_selected_index).cloned() {
let url_to_open = format!("http://youtube.com/watch?v={}", sel.id);
let _ = self.command_sender.send(Command::Open(url_to_open));
self.mode = TuiMode::Normal;
}
}
KeyCode::Char('a') | KeyCode::Char('A') => {
if let Some(sel) = self.yt_search_results.get(self.yt_selected_index).cloned() {
if let Some(pl) = self.playlists_manager.list().get(self.pl_selected_playlist_idx) {
let item = crate::playlists::PlaylistItem {
name: sel.title.clone(),
id: sel.id.clone(),
url: format!("http://youtube.com/watch?v={}", sel.id),
num_played: 0,
duration: sel.duration.clone().unwrap_or_default(),
time_added: 0,
};
let _ = self.command_sender.send(Command::PlaylistItemAdd { playlist_id: pl.id.clone(), item });
if let Some(pl_mut) = self.playlists_manager.list_mut().get_mut(self.pl_selected_playlist_idx) {
let item_local = crate::playlists::PlaylistItem {
name: sel.title,
id: sel.id.clone(),
url: format!("http://youtube.com/watch?v={}", sel.id.clone()),
num_played: 0,
duration: sel.duration.unwrap_or_default(),
time_added: 0,
};
pl_mut.add_item(item_local);
let _ = self.playlists_manager.save();
let _ = self.playlists_manager.reload();
let _ = self.command_sender.send(Command::PlaylistReload);
}
self.add_log("INFO", "Added to current playlist");
}
}
}
_ => {}
}
}
if let Some(rx) = self.yt_result_rx.as_ref() {
while let Ok(item) = rx.try_recv() {
self.yt_search_results.push(item);
}
}
Ok(false)
}
fn draw_youtube_search(&self, f: &mut Frame, area: Rect) {
let popup = centered_rect(90, 80, area);
f.render_widget(Clear, popup);
let outer = Block::default()
.title("YouTube Search")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red));
let inner = outer.inner(popup);
f.render_widget(outer, popup);
let cols = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(5), Constraint::Length(2), ])
.split(inner);
let input_line = if self.yt_search_input_active { format!("Query: {}█", self.yt_search_query) } else { format!("Query: {}", self.yt_search_query) };
let input = Paragraph::new(input_line)
.block(Block::default().title("Search Query (Enter to run)").borders(Borders::ALL))
.style(Style::default().fg(if self.yt_search_input_active { Color::Yellow } else { Color::Gray }));
f.render_widget(input, cols[0]);
let mut lines: Vec<Line> = Vec::new();
if self.yt_is_searching && self.yt_search_results.is_empty() {
lines.push(Line::from(Span::styled("Searching... results will appear as they arrive", Style::default().fg(Color::Gray))));
}
for (i, r) in self.yt_search_results.iter().enumerate() {
let sel = i == self.yt_selected_index && !self.yt_search_input_active;
let mut spans = vec![
Span::styled(format!("{} ", i + 1), Style::default().fg(Color::Gray)),
Span::styled(r.title.clone(), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
];
if let Some(d) = &r.duration { spans.push(Span::raw(format!(" [{}]", d))); }
if sel {
lines.push(Line::from(spans.into_iter().map(|mut s| { s.style = s.style.bg(Color::DarkGray); s }).collect::<Vec<_>>()));
} else {
lines.push(Line::from(spans));
}
}
if self.yt_search_results.is_empty() && !self.yt_is_searching {
lines.push(Line::from(Span::styled("No results. Type a query and press Enter.", Style::default().fg(Color::Gray))));
}
let results = Paragraph::new(lines)
.block(Block::default().title("Results (↑/↓ to select, Enter to open)").borders(Borders::ALL))
.wrap(Wrap { trim: true });
f.render_widget(results, cols[1]);
let footer_text = if self.yt_is_searching {
"Status: Searching… Esc: Close | Tab: Toggle focus | Enter: Open | A: Add to playlist"
} else {
"Tab: Toggle focus | R: Re-run last query | Enter: Open selected | A: Add to current playlist"
};
let footer = Paragraph::new(footer_text)
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center);
f.render_widget(footer, cols[2]);
}
fn start_youtube_search(&mut self) {
let query = self.yt_search_query.trim().to_string();
if query.is_empty() {
self.add_log("WARN", "YouTube search: empty query");
return;
}
self.yt_search_results.clear();
self.yt_selected_index = 0;
self.yt_is_searching = true;
let (tx, rx) = mpsc::channel::<YouTubeSearchResult>();
self.yt_result_rx = Some(rx);
let query_for_worker = format!("ytsearch10:{}", query);
let logger = self.command_sender.clone();
thread::spawn(move || {
let mut cmd = OsCommand::new("yt-dlp");
cmd.arg(&query_for_worker)
.arg("--no-playlist")
.arg("--get-title")
.arg("--get-id")
.arg("--get-duration")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let _ = logger.send(Command::Log(LogEntry { level: "DEBUG".into(), message: format!("Running: {:?}", cmd) }));
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(e) => {
let _ = logger.send(Command::Log(LogEntry { level: "ERROR".into(), message: format!("Failed to start yt-dlp: {}", e) }));
drop(tx);
return;
}
};
if let Some(stdout) = child.stdout.take() {
let reader = BufReader::new(stdout);
let mut field_step = 0usize; let mut cur_title: Option<String> = None;
let mut cur_id: Option<String> = None;
let mut cur_duration: Option<String> = None;
for line_res in reader.lines() {
match line_res {
Ok(line_raw) => {
let line = line_raw.trim().to_string();
if line.is_empty() {
continue;
}
match field_step {
0 => { cur_title = Some(line);
field_step = 1;
}
1 => { cur_id = Some(line);
field_step = 2;
}
2 => { let result = YouTubeSearchResult {
title: cur_title.take().unwrap_or_default(),
id: cur_id.take().unwrap_or_default(),
duration: Some(line),
};
let _ = logger.send(Command::Log(LogEntry { level: "INFO".into(), message: format!("Found YouTube video: [{}] {}", result.id, result.title) }));
if tx.send(result).is_err() {
let _ = logger.send(Command::Log(LogEntry { level: "DEBUG".into(), message: "Search receiver dropped; stopping worker".into() }));
break;
}
field_step = 0;
}
_ => {
field_step = 0;
}
}
}
Err(e) => {
let _ = logger.send(Command::Log(LogEntry { level: "ERROR".into(), message: format!("yt-dlp read error: {}", e) }));
break;
}
}
}
if field_step != 0 {
let _ = logger.send(Command::Log(LogEntry { level: "INFO".into(), message: format!("Incomplete record at end of output; step {}", field_step) }));
if let (Some(title), Some(id)) = (cur_title.take(), cur_id.take()) {
let _ = tx.send(YouTubeSearchResult { title, id, duration: cur_duration.take() });
}
}
}
if let Some(mut stderr) = child.stderr.take() {
use std::io::Read;
let mut buf = String::new();
if stderr.read_to_string(&mut buf).is_ok() && !buf.trim().is_empty() {
let _ = logger.send(Command::Log(LogEntry { level: "DEBUG".into(), message: format!("yt-dlp stderr: {}", buf.trim()) }));
}
}
if let Err(e) = child.wait() {
let _ = logger.send(Command::Log(LogEntry { level: "ERROR".into(), message: format!("yt-dlp wait failed: {}", e) }));
}
drop(tx);
});
}
fn draw_help(&self, f: &mut Frame, area: Rect) {
let help_text = vec![
Line::from("BCK Player - VLC HTTP Interface"),
Line::from(""),
Line::from("BASIC CONTROLS:"),
Line::from(" Space - Play/Pause"),
Line::from(" S - Stop"),
Line::from(" I - Input mode (load file/URL)"),
Line::from(" : - Command mode (vim-like commands)"),
Line::from(""),
Line::from("VOLUME & NAVIGATION:"),
Line::from(" M - Toggle Mute"),
Line::from(" ↑/↓ - Volume Up/Down"),
Line::from(" ←/→ - Seek Backward/Forward"),
Line::from(""),
Line::from("PLAYBACK:"),
Line::from(" +/- - Speed Up/Down (limited support)"),
Line::from(" R - Reset Speed"),
Line::from(" F - Toggle Fullscreen"),
Line::from(""),
Line::from("OTHER:"),
Line::from(" Y - Open YouTube Search"),
Line::from(" H - Toggle this help"),
Line::from(" Q - Quit"),
Line::from(" C - Test VLC connection"),
Line::from(""),
Line::from("YOUTUBE SEARCH:"),
Line::from(" Press Y to open the search window."),
Line::from(" Type your query, press Enter to run. Results stream in as found."),
Line::from(" Use ↑/↓ to select, Enter to open the selected result in player."),
Line::from(" Tab toggles focus between input and results. Esc closes."),
Line::from(" Example query: lebron james (fetches top 10 via yt-dlp)."),
Line::from(""),
Line::from("COMMAND MODE (vim-like):"),
Line::from(" Press : to enter command mode"),
Line::from(" Smart fuzzy matching with multiple strategies:"),
Line::from(" • Exact matches (highest priority)"),
Line::from(" • Prefix matches (e.g., 'vol' → volume commands)"),
Line::from(" • Keyword matches (e.g., 'louder' → volumeup)"),
Line::from(" • Fuzzy matches (e.g., 'sped' → speed commands)"),
Line::from(" Press Enter to execute, Esc to cancel"),
Line::from(""),
Line::from("PLAYBACK COMMANDS:"),
Line::from(" :play, :pause, :stop, :toggle"),
Line::from(""),
Line::from("MEDIA LOADING:"),
Line::from(" :open /path/to/file.mp4"),
Line::from(" :open https://example.com/video.mp4"),
Line::from(" :load <path> (alias for open)"),
Line::from(""),
Line::from("VOLUME CONTROL:"),
Line::from(" :volume 50, :vol 75 (set volume 0-100)"),
Line::from(" :volumeup, :volup (increase volume)"),
Line::from(" :volumedown, :voldown (decrease volume)"),
Line::from(" :mute, :unmute (toggle/set mute)"),
Line::from(""),
Line::from("SEEKING:"),
Line::from(" :seek 30 (seek forward 30 seconds)"),
Line::from(" :seek -10 (seek backward 10 seconds)"),
Line::from(" :seekto 120 (seek to 2 minutes)"),
Line::from(" :forward 10 (seek forward, default 10s)"),
Line::from(" :backward 5 (seek backward, default 10s)"),
Line::from(""),
Line::from("PLAYBACK SPEED:"),
Line::from(" :speed 1.5 (set speed to 1.5x)"),
Line::from(" :speed 0.5 (set speed to 0.5x)"),
Line::from(" :speedup (increase speed)"),
Line::from(" :speeddown (decrease speed)"),
Line::from(" :speedreset (reset to 1.0x)"),
Line::from(""),
Line::from("DISPLAY:"),
Line::from(" :fullscreen (enter fullscreen)"),
Line::from(" :windowed (exit fullscreen)"),
Line::from(" :togglefullscreen (toggle fullscreen)"),
Line::from(""),
Line::from("SYSTEM:"),
Line::from(" :quit, :exit (quit application)"),
Line::from(" :help (show this help)"),
Line::from(" :test (test VLC connection)"),
Line::from(""),
Line::from("CONNECTION (future):"),
Line::from(" :connect <host> [port] (connect to VLC instance)"),
Line::from(" :disconnect (disconnect from VLC)"),
Line::from(""),
Line::from("CONFIG (future):"),
Line::from(" :config (show configuration)"),
Line::from(" :save (save configuration)"),
Line::from(" :reload (reload configuration)"),
Line::from(""),
Line::from("HELP NAVIGATION:"),
Line::from(" ↑/↓, j/k - Scroll up/down"),
Line::from(" Page Up/Down - Scroll by page"),
Line::from(" Home/End - Go to top/bottom"),
Line::from(" ESC, H, Q - Close help"),
Line::from(""),
Line::from("NOTES:"),
Line::from(" • VLC is started automatically with HTTP interface"),
Line::from(" • Smart fuzzy search with multiple matching strategies"),
Line::from(" • Commands ranked by relevance (exact > prefix > fuzzy)"),
Line::from(" • Keyword aliases supported (e.g., 'louder', 'fs', 'goto')"),
Line::from(" • Parameters are validated with helpful error messages"),
Line::from(" • Real-time suggestions appear as you type"),
Line::from(""),
Line::from("Press ESC, H, or Q to close help"),
];
let clear = Clear;
f.render_widget(clear, area);
let help_area = centered_rect(90, 90, area);
let content_height = help_text.len();
let display_height = help_area.height.saturating_sub(2) as usize; let max_scroll = content_height.saturating_sub(display_height);
let scroll_pos = (self.help_scroll as usize).min(max_scroll);
let visible_text: Vec<Line> = help_text
.into_iter()
.skip(scroll_pos)
.take(display_height)
.collect();
let scroll_info = if max_scroll > 0 {
format!(" - Scroll: {}/{} (↑↓/jk/PgUp/PgDn)", scroll_pos + 1, max_scroll + 1)
} else {
String::new()
};
let help = Paragraph::new(visible_text)
.block(
Block::default()
.title(format!("Help{}", scroll_info))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.style(Style::default().bg(Color::Black))
)
.alignment(Alignment::Left)
.wrap(Wrap { trim: true });
f.render_widget(help, help_area);
}
fn draw_command_popup(&self, f: &mut Frame, area: Rect) {
let suggestions = get_command_suggestions(&self.command_input);
let command_display = format!(":{}", self.command_input);
let popup_height = if suggestions.is_empty() {
3 } else {
let suggestions_height = (suggestions.len() as u16 + 2).max(3); (3 + suggestions_height).min(15) };
let popup_area = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: popup_height.min(area.height.saturating_sub(2)),
};
f.render_widget(Clear, popup_area);
let command_input = Paragraph::new(command_display)
.style(Style::default().fg(Color::Yellow))
.block(
Block::default()
.title("Command")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.style(Style::default().bg(Color::Black))
);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(0), ])
.split(popup_area);
f.render_widget(command_input, chunks[0]);
if !suggestions.is_empty() && chunks.len() > 1 {
let bounded_index = if self.command_suggestion_index >= suggestions.len() {
0
} else {
self.command_suggestion_index
};
let suggestion_lines: Vec<Line> = suggestions
.iter()
.enumerate()
.take(10) .map(|(i, s)| {
let is_selected = i == bounded_index;
Line::from(vec![
Span::styled(
format!(" {} ", s.command),
if is_selected {
Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
}
),
Span::styled(
s.description.clone(),
if is_selected {
Style::default().fg(Color::Black).bg(Color::Yellow)
} else {
Style::default().fg(Color::Gray)
}
),
])
})
.collect();
let title = if suggestions.len() > 10 {
format!("Suggestions (showing 10 of {}) - ↑↓ to navigate, Tab to complete", suggestions.len())
} else if suggestions.len() > 1 {
format!("Suggestions ({}) - ↑↓ to navigate, Tab to complete", suggestions.len())
} else {
"Suggestion - Tab to complete".to_string()
};
let suggestions_widget = Paragraph::new(suggestion_lines)
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green))
.style(Style::default().bg(Color::Black))
)
.wrap(Wrap { trim: true });
f.render_widget(suggestions_widget, chunks[1]);
}
}
fn draw_config_window(&self, f: &mut Frame, area: Rect) {
use crate::config_manager::ConfigManager;
use crate::config::Config;
use crate::config_modules::{NetworkConfig, AppearanceConfig, MediaConfig, AdvancedConfig, YoutubeConfig};
let popup_area = centered_rect(80, 80, area);
f.render_widget(Clear, popup_area);
let block = Block::default()
.title("Configuration Manager")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta));
let inner_area = block.inner(popup_area);
f.render_widget(block, popup_area);
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(25), Constraint::Percentage(75), ])
.split(inner_area);
let mut section_items = Vec::new();
for (i, section) in self.config_sections.iter().enumerate() {
let style = if i == self.config_section_index {
Style::default().fg(Color::Black).bg(Color::Magenta)
} else {
Style::default().fg(Color::White)
};
section_items.push(Line::from(Span::styled(format!(" {} ", section), style)));
}
let sections_block = Block::default()
.title("Sections")
.borders(Borders::ALL);
let sections = Paragraph::new(section_items)
.block(sections_block)
.wrap(Wrap { trim: true });
f.render_widget(sections, chunks[0]);
let content_block = Block::default()
.title("Settings")
.borders(Borders::ALL);
let config_manager = match ConfigManager::new() {
Ok(manager) => manager,
Err(_) => {
let error_content = Paragraph::new("Error: Could not access config manager")
.block(content_block)
.style(Style::default().fg(Color::Red));
f.render_widget(error_content, chunks[1]);
return;
}
};
let content_lines = match self.config_section_index {
0 => { let config: Config = config_manager.get();
vec![
Line::from(format!("Default Volume: {:.1}", config.default_volume)),
Line::from(format!("Default Speed: {:.1}x", config.default_speed)),
Line::from(format!("Fullscreen by Default: {}", config.fullscreen_by_default)),
Line::from(format!("Update Interval: {}ms", config.ui.update_interval_ms)),
Line::from(format!("Volume Step: {:.1}", config.ui.volume_step)),
Line::from(format!("Seek Step: {:.1}s", config.ui.seek_step)),
Line::from(format!("Speed Step: {:.1}", config.ui.speed_step)),
Line::from(format!("Show Milliseconds: {}", config.ui.show_milliseconds)),
]
},
1 => { let config: NetworkConfig = config_manager.get();
vec![
Line::from(format!("Timeout: {}s", config.timeout_seconds)),
Line::from(format!("Retry Attempts: {}", config.retry_attempts)),
Line::from(format!("User Agent: {}", config.user_agent)),
Line::from(format!("Proxy URL: {}", config.proxy_url.as_deref().unwrap_or("None"))),
Line::from(format!("Max Redirects: {}", config.max_redirects)),
]
},
2 => { let config: AppearanceConfig = config_manager.get();
vec![
Line::from(format!("Theme: {}", config.theme)),
Line::from(format!("Font Size: {}", config.font_size)),
Line::from(format!("Show FPS: {}", config.show_fps)),
Line::from(format!("Show Logs: {}", config.show_logs)),
Line::from(format!("Log Level: {}", config.log_level)),
Line::from(format!("Progress Bar Style: {}", config.progress_bar_style)),
]
},
3 => { let config: MediaConfig = config_manager.get();
vec![
Line::from(format!("Auto Load Subtitles: {}", config.auto_load_subtitles)),
Line::from(format!("Subtitle Languages: {}", config.subtitle_languages.join(", "))),
Line::from(format!("Audio Languages: {}", config.audio_languages.join(", "))),
Line::from(format!("Hardware Decoding: {}", config.hardware_decoding)),
Line::from(format!("Buffer Size: {:.1}s", config.buffer_size_seconds)),
Line::from(format!("Skip Intro: {}", config.skip_intro_seconds.map_or("None".to_string(), |s| format!("{:.1}s", s)))),
Line::from(format!("Skip Outro: {}", config.skip_outro_seconds.map_or("None".to_string(), |s| format!("{:.1}s", s)))),
]
},
4 => { let config: AdvancedConfig = config_manager.get();
vec![
Line::from(format!("Debug Mode: {}", config.debug_mode)),
Line::from(format!("Memory Limit: {}", config.memory_limit_mb.map_or("None".to_string(), |m| format!("{}MB", m)))),
Line::from(format!("CPU Cores: {}", config.cpu_cores.map_or("Auto".to_string(), |c| c.to_string()))),
Line::from(format!("Experimental Features: {}", config.experimental_features)),
Line::from(format!("Custom FFmpeg Args: {}", config.custom_ffmpeg_args.join(" "))),
Line::from(format!("Log File: {}", config.log_file_path.as_ref().map_or("None".to_string(), |p| p.display().to_string()))),
Line::from(format!("Performance Monitoring: {}", config.performance_monitoring)),
]
},
5 => { let config: YoutubeConfig = config_manager.get();
vec![
Line::from(format!("Enabled: {}", config.enabled)),
Line::from(format!("Cache Directory: {}", config.cache_directory.display())),
Line::from(format!("Max Cache Size: {}MB", config.max_cache_size_mb)),
Line::from(format!("Max Cache Age: {} days", config.max_cache_age_days)),
Line::from(format!("Preferred Quality: {}", config.preferred_quality)),
Line::from(format!("Audio Only: {}", config.audio_only)),
Line::from(format!("yt-dlp Path: {}", config.ytdlp_path.as_ref().map_or("Auto".to_string(), |p| p.display().to_string()))),
Line::from(format!("Concurrent Downloads: {}", config.concurrent_downloads)),
Line::from(format!("Retry Attempts: {}", config.retry_attempts)),
]
},
_ => vec![Line::from("Invalid section")],
};
let highlighted_lines: Vec<Line> = content_lines.into_iter().enumerate().map(|(i, mut line)| {
if i == self.config_item_index {
if self.config_editing {
let field_name = line.spans[0].content.split(':').next().unwrap_or("Field");
line = Line::from(vec![
Span::styled(format!("{}: ", field_name), Style::default().fg(Color::Yellow)),
Span::styled(&self.config_edit_input, Style::default().fg(Color::White).bg(Color::Blue)),
Span::styled("_", Style::default().fg(Color::White).bg(Color::Blue)), ]);
} else {
for span in &mut line.spans {
span.style = span.style.bg(Color::DarkGray);
}
}
}
line
}).collect();
let content = Paragraph::new(highlighted_lines)
.block(content_block)
.wrap(Wrap { trim: true });
f.render_widget(content, chunks[1]);
let instructions_text = if self.config_editing {
"Enter: Save | ESC: Cancel | Type to edit value"
} else {
"↑↓: Navigate | ←→: Change section | Enter: Edit | R: Reset | S: Save | ESC: Exit"
};
let instructions = Paragraph::new(instructions_text)
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center);
let instruction_area = Rect {
x: popup_area.x + 1,
y: popup_area.y + popup_area.height - 2,
width: popup_area.width - 2,
height: 1,
};
f.render_widget(instructions, instruction_area);
}
fn get_config_item_count(&self) -> usize {
match self.config_section_index {
0 => 8, 1 => 5, 2 => 6, 3 => 7, 4 => 7, 5 => 9, _ => 1,
}
}
fn start_config_edit(&mut self) -> Result<()> {
use crate::config_manager::ConfigManager;
use crate::config::Config;
use crate::config_modules::{NetworkConfig, AppearanceConfig, MediaConfig, AdvancedConfig, YoutubeConfig};
let config_manager = ConfigManager::new()?;
let current_value = match self.config_section_index {
0 => { let config: Config = config_manager.get();
match self.config_item_index {
0 => config.default_volume.to_string(),
1 => config.default_speed.to_string(),
2 => config.fullscreen_by_default.to_string(),
3 => config.ui.update_interval_ms.to_string(),
4 => config.ui.volume_step.to_string(),
5 => config.ui.seek_step.to_string(),
6 => config.ui.speed_step.to_string(),
7 => config.ui.show_milliseconds.to_string(),
_ => "unknown".to_string(),
}
},
1 => { let config: NetworkConfig = config_manager.get();
match self.config_item_index {
0 => config.timeout_seconds.to_string(),
1 => config.retry_attempts.to_string(),
2 => config.user_agent.clone(),
3 => config.proxy_url.as_ref().map_or("None".to_string(), |p| p.clone()),
4 => config.max_redirects.to_string(),
_ => "unknown".to_string(),
}
},
2 => { let config: AppearanceConfig = config_manager.get();
match self.config_item_index {
0 => config.theme,
1 => config.font_size.to_string(),
2 => config.show_fps.to_string(),
3 => config.show_logs.to_string(),
4 => config.log_level,
5 => config.progress_bar_style,
_ => "unknown".to_string(),
}
},
3 => { let config: MediaConfig = config_manager.get();
match self.config_item_index {
0 => config.auto_load_subtitles.to_string(),
1 => config.subtitle_languages.join(","),
2 => config.audio_languages.join(","),
3 => config.hardware_decoding.to_string(),
4 => config.buffer_size_seconds.to_string(),
5 => config.skip_intro_seconds.map_or("None".to_string(), |s| s.to_string()),
6 => config.skip_outro_seconds.map_or("None".to_string(), |s| s.to_string()),
_ => "unknown".to_string(),
}
},
4 => { let config: AdvancedConfig = config_manager.get();
match self.config_item_index {
0 => config.debug_mode.to_string(),
1 => config.memory_limit_mb.map_or("None".to_string(), |m| format!("{}MB", m)),
2 => config.cpu_cores.map_or("Auto".to_string(), |c| c.to_string()),
3 => config.experimental_features.to_string(),
4 => config.custom_ffmpeg_args.join(" "),
5 => config.log_file_path.as_ref().map_or("None".to_string(), |p| p.display().to_string()),
6 => config.performance_monitoring.to_string(),
_ => "unknown".to_string(),
}
},
5 => { let config: YoutubeConfig = config_manager.get();
match self.config_item_index {
0 => config.enabled.to_string(),
1 => config.cache_directory.display().to_string(),
2 => config.max_cache_size_mb.to_string(),
3 => config.max_cache_age_days.to_string(),
4 => config.preferred_quality,
5 => config.audio_only.to_string(),
6 => config.ytdlp_path.as_ref().map_or("Auto".to_string(), |p| p.display().to_string()),
7 => config.concurrent_downloads.to_string(),
8 => config.retry_attempts.to_string(),
_ => "unknown".to_string(),
}
},
_ => "unknown".to_string(),
};
self.config_edit_input = current_value.clone();
self.config_editing = true;
self.add_log("DEBUG", &format!("Started editing config value: {} (current: {})",
self.config_edit_input, current_value));
Ok(())
}
fn save_config_edit(&mut self) -> Result<()> {
use crate::config_manager::ConfigManager;
use crate::config::Config;
use crate::config_modules::{NetworkConfig, AppearanceConfig, MediaConfig, AdvancedConfig, YoutubeConfig};
let config_manager = ConfigManager::new()?;
self.add_log("DEBUG", &format!("Saving config edit: section={}, item={}, value={}",
self.config_section_index, self.config_item_index, self.config_edit_input));
match self.config_section_index {
0 => { let mut config: Config = config_manager.get();
self.add_log("DEBUG", &format!("Before edit - volume: {}, speed: {}",
config.default_volume, config.default_speed));
match self.config_item_index {
0 => { if let Ok(value) = self.config_edit_input.parse::<f64>() {
if (0.0..=100.0).contains(&value) {
config.default_volume = value;
config_manager.set(config)?;
config_manager.sync()?;
let saved_config: Config = config_manager.get();
self.add_log("DEBUG", &format!("After save - volume: {} (expected: {})", saved_config.default_volume, value));
if (saved_config.default_volume - value).abs() < 0.001 {
self.add_log("INFO", &format!("✓ Successfully updated default volume to: {}", value));
} else {
self.add_log("ERROR", &format!("✗ Save failed! Expected: {}, Got: {}", value, saved_config.default_volume));
}
} else {
self.add_log("ERROR", "Volume must be between 0 and 100");
}
} else {
self.add_log("ERROR", "Invalid volume value");
}
},
1 => { if let Ok(value) = self.config_edit_input.parse::<f64>() {
if (0.1..=10.0).contains(&value) {
config.default_speed = value;
config_manager.set(config)?;
config_manager.sync()?; self.add_log("INFO", &format!("Updated default speed to: {}x", value));
} else {
self.add_log("ERROR", "Speed must be between 0.1 and 10.0");
}
} else {
self.add_log("ERROR", "Invalid speed value");
}
},
2 => { if let Ok(value) = self.config_edit_input.parse::<bool>() {
config.fullscreen_by_default = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated fullscreen by default to: {}", value));
} else {
self.add_log("ERROR", "Invalid boolean value (use true or false)");
}
},
3 => { if let Ok(value) = self.config_edit_input.parse::<u64>() {
config.ui.update_interval_ms = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated update interval to: {}ms", value));
} else {
self.add_log("ERROR", "Invalid interval value");
}
},
4 => { if let Ok(value) = self.config_edit_input.parse::<f64>() {
if (0.1..=50.0).contains(&value) {
config.ui.volume_step = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated volume step to: {}", value));
} else {
self.add_log("ERROR", "Volume step must be between 0.1 and 50.0");
}
} else {
self.add_log("ERROR", "Invalid volume step value");
}
},
5 => { if let Ok(value) = self.config_edit_input.parse::<f64>() {
if (0.1..=300.0).contains(&value) {
config.ui.seek_step = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated seek step to: {}s", value));
} else {
self.add_log("ERROR", "Seek step must be between 0.1 and 300.0");
}
} else {
self.add_log("ERROR", "Invalid seek step value");
}
},
6 => { if let Ok(value) = self.config_edit_input.parse::<f64>() {
if (0.01..=1.0).contains(&value) {
config.ui.speed_step = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated speed step to: {}", value));
} else {
self.add_log("ERROR", "Speed step must be between 0.01 and 1.0");
}
} else {
self.add_log("ERROR", "Invalid speed step value");
}
},
7 => { if let Ok(value) = self.config_edit_input.parse::<bool>() {
config.ui.show_milliseconds = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated show milliseconds to: {}", value));
} else {
self.add_log("ERROR", "Invalid boolean value (use true or false)");
}
},
_ => self.add_log("ERROR", "Unknown config item"),
}
},
1 => { let mut config: NetworkConfig = config_manager.get();
match self.config_item_index {
0 => { if let Ok(value) = self.config_edit_input.parse::<u64>() {
config.timeout_seconds = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated timeout to: {}s", value));
} else {
self.add_log("ERROR", "Invalid timeout value");
}
},
1 => { if let Ok(value) = self.config_edit_input.parse::<u32>() {
config.retry_attempts = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated retry attempts to: {}", value));
} else {
self.add_log("ERROR", "Invalid retry attempts value");
}
},
2 => { config.user_agent = self.config_edit_input.clone();
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated user agent to: {}", self.config_edit_input));
},
3 => { let proxy = if self.config_edit_input.trim().is_empty() || self.config_edit_input.trim().to_lowercase() == "none" {
None
} else {
Some(self.config_edit_input.clone())
};
config.proxy_url = proxy.clone();
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated proxy URL to: {:?}", proxy));
},
4 => { if let Ok(value) = self.config_edit_input.parse::<u32>() {
config.max_redirects = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated max redirects to: {}", value));
} else {
self.add_log("ERROR", "Invalid max redirects value");
}
},
_ => self.add_log("ERROR", "Unknown config item"),
}
},
2 => { let mut config: AppearanceConfig = config_manager.get();
match self.config_item_index {
0 => { config.theme = self.config_edit_input.clone();
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated theme to: {}", self.config_edit_input));
},
1 => { if let Ok(value) = self.config_edit_input.parse::<u16>() {
config.font_size = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated font size to: {}", value));
} else {
self.add_log("ERROR", "Invalid font size value");
}
},
2 => { if let Ok(value) = self.config_edit_input.parse::<bool>() {
config.show_fps = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated show FPS to: {}", value));
} else {
self.add_log("ERROR", "Invalid boolean value (use true or false)");
}
},
3 => { if let Ok(value) = self.config_edit_input.parse::<bool>() {
config.show_logs = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated show logs to: {}", value));
} else {
self.add_log("ERROR", "Invalid boolean value (use true or false)");
}
},
4 => { config.log_level = self.config_edit_input.clone();
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated log level to: {}", self.config_edit_input));
},
5 => { config.progress_bar_style = self.config_edit_input.clone();
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated progress bar style to: {}", self.config_edit_input));
},
_ => self.add_log("ERROR", "Unknown config item"),
}
},
3 => { let mut config: MediaConfig = config_manager.get();
match self.config_item_index {
0 => { if let Ok(value) = self.config_edit_input.parse::<bool>() {
config.auto_load_subtitles = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated auto load subtitles to: {}", value));
} else {
self.add_log("ERROR", "Invalid boolean value (use true or false)");
}
},
1 => { let languages: Vec<String> = self.config_edit_input
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
config.subtitle_languages = languages;
config_manager.set(config)?;
self.add_log("INFO", "Updated subtitle languages");
},
2 => { let languages: Vec<String> = self.config_edit_input
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
config.audio_languages = languages;
config_manager.set(config)?;
self.add_log("INFO", "Updated audio languages");
},
3 => { if let Ok(value) = self.config_edit_input.parse::<bool>() {
config.hardware_decoding = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated hardware decoding to: {}", value));
} else {
self.add_log("ERROR", "Invalid boolean value (use true or false)");
}
},
4 => { if let Ok(value) = self.config_edit_input.parse::<f64>() {
config.buffer_size_seconds = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated buffer size to: {}s", value));
} else {
self.add_log("ERROR", "Invalid buffer size value");
}
},
5 => { let skip_intro = if self.config_edit_input.trim().is_empty() || self.config_edit_input.trim().to_lowercase() == "none" {
None
} else if let Ok(value) = self.config_edit_input.parse::<f64>() {
Some(value)
} else {
self.add_log("ERROR", "Invalid skip intro value");
return Ok(());
};
config.skip_intro_seconds = skip_intro;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated skip intro to: {:?}s", skip_intro));
},
6 => { let skip_outro = if self.config_edit_input.trim().is_empty() || self.config_edit_input.trim().to_lowercase() == "none" {
None
} else if let Ok(value) = self.config_edit_input.parse::<f64>() {
Some(value)
} else {
self.add_log("ERROR", "Invalid skip outro value");
return Ok(());
};
config.skip_outro_seconds = skip_outro;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated skip outro to: {:?}s", skip_outro));
},
_ => self.add_log("ERROR", "Unknown config item"),
}
},
4 => { let mut config: AdvancedConfig = config_manager.get();
match self.config_item_index {
0 => { if let Ok(value) = self.config_edit_input.parse::<bool>() {
config.debug_mode = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated debug mode to: {}", value));
} else {
self.add_log("ERROR", "Invalid boolean value (use true or false)");
}
},
1 => { let memory_limit = if self.config_edit_input.trim().is_empty() || self.config_edit_input.trim().to_lowercase() == "none" {
None
} else if let Ok(value) = self.config_edit_input.parse::<u64>() {
Some(value)
} else {
self.add_log("ERROR", "Invalid memory limit value");
return Ok(());
};
config.memory_limit_mb = memory_limit;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated memory limit to: {:?}MB", memory_limit));
},
2 => { let cpu_cores = if self.config_edit_input.trim().is_empty() || self.config_edit_input.trim().to_lowercase() == "auto" {
None
} else if let Ok(value) = self.config_edit_input.parse::<u32>() {
Some(value)
} else {
self.add_log("ERROR", "Invalid CPU cores value");
return Ok(());
};
config.cpu_cores = cpu_cores;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated CPU cores to: {:?}", cpu_cores));
},
3 => { if let Ok(value) = self.config_edit_input.parse::<bool>() {
config.experimental_features = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated experimental features to: {}", value));
} else {
self.add_log("ERROR", "Invalid boolean value (use true or false)");
}
},
4 => { let args: Vec<String> = self.config_edit_input
.split_whitespace()
.map(|s| s.to_string())
.collect();
config.custom_ffmpeg_args = args;
config_manager.set(config)?;
self.add_log("INFO", "Updated custom FFmpeg args");
},
5 => { let log_path = if self.config_edit_input.trim().is_empty() || self.config_edit_input.trim().to_lowercase() == "none" {
None
} else {
Some(PathBuf::from(self.config_edit_input.clone()))
};
config.log_file_path = log_path.clone();
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated log file path to: {:?}", log_path));
},
6 => { if let Ok(value) = self.config_edit_input.parse::<bool>() {
config.performance_monitoring = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated performance monitoring to: {}", value));
} else {
self.add_log("ERROR", "Invalid boolean value (use true or false)");
}
},
_ => self.add_log("ERROR", "Unknown config item"),
}
},
5 => { let mut config: YoutubeConfig = config_manager.get();
match self.config_item_index {
0 => { if let Ok(value) = self.config_edit_input.parse::<bool>() {
config.enabled = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated YouTube enabled to: {}", value));
} else {
self.add_log("ERROR", "Invalid boolean value (use true or false)");
}
},
1 => { config.cache_directory = PathBuf::from(self.config_edit_input.clone());
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated cache directory to: {}", self.config_edit_input));
},
2 => { if let Ok(value) = self.config_edit_input.parse::<u64>() {
config.max_cache_size_mb = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated max cache size to: {}MB", value));
} else {
self.add_log("ERROR", "Invalid cache size value");
}
},
3 => { if let Ok(value) = self.config_edit_input.parse::<u64>() {
config.max_cache_age_days = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated max cache age to: {} days", value));
} else {
self.add_log("ERROR", "Invalid cache age value");
}
},
4 => { config.preferred_quality = self.config_edit_input.clone();
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated preferred quality to: {}", self.config_edit_input));
},
5 => { if let Ok(value) = self.config_edit_input.parse::<bool>() {
config.audio_only = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated audio only to: {}", value));
} else {
self.add_log("ERROR", "Invalid boolean value (use true or false)");
}
},
6 => { let ytdlp_path = if self.config_edit_input.trim().is_empty() || self.config_edit_input.trim().to_lowercase() == "auto" {
None
} else {
Some(PathBuf::from(self.config_edit_input.clone()))
};
config.ytdlp_path = ytdlp_path.clone();
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated yt-dlp path to: {:?}", ytdlp_path));
},
7 => { if let Ok(value) = self.config_edit_input.parse::<u32>() {
config.concurrent_downloads = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated concurrent downloads to: {}", value));
} else {
self.add_log("ERROR", "Invalid concurrent downloads value");
}
},
8 => { if let Ok(value) = self.config_edit_input.parse::<u32>() {
config.retry_attempts = value;
config_manager.set(config)?;
self.add_log("INFO", &format!("Updated retry attempts to: {}", value));
} else {
self.add_log("ERROR", "Invalid retry attempts value");
}
},
_ => self.add_log("ERROR", "Unknown config item"),
}
},
_ => {
self.add_log("ERROR", "Editing for this config section is not yet implemented");
}
}
Ok(())
}
fn reset_config_section(&mut self) -> Result<()> {
use crate::config_manager::{ConfigManager, ConfigModule};
use crate::config::Config;
use crate::config_modules::{NetworkConfig, AppearanceConfig, MediaConfig, AdvancedConfig, YoutubeConfig};
let config_manager = ConfigManager::new()?;
match self.config_section_index {
0 => { let default_config = <Config as ConfigModule>::default();
config_manager.set(default_config)?;
self.add_log("INFO", "Reset Player config to defaults");
},
1 => { let default_config = <NetworkConfig as ConfigModule>::default();
config_manager.set(default_config)?;
self.add_log("INFO", "Reset Network config to defaults");
},
2 => { let default_config = <AppearanceConfig as ConfigModule>::default();
config_manager.set(default_config)?;
self.add_log("INFO", "Reset Appearance config to defaults");
},
3 => { let default_config = <MediaConfig as ConfigModule>::default();
config_manager.set(default_config)?;
self.add_log("INFO", "Reset Media config to defaults");
},
4 => { let default_config = <AdvancedConfig as ConfigModule>::default();
config_manager.set(default_config)?;
self.add_log("INFO", "Reset Advanced config to defaults");
},
5 => { let default_config = <YoutubeConfig as ConfigModule>::default();
config_manager.set(default_config)?;
self.add_log("INFO", "Reset YouTube config to defaults");
},
_ => self.add_log("ERROR", "Unknown config section"),
}
Ok(())
}
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
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),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
pub struct TuiManager;
impl TuiManager {
pub fn setup() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
pub fn cleanup() -> Result<()> {
disable_raw_mode()?;
execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
Ok(())
}
}