use anyhow::{Result, anyhow}; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use ratatui::Terminal; use ratatui::backend::Backend; use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; use std::time::Duration; use crate::app::{self, App, View}; use crate::config::{self, Config}; use crate::emby::Client; use crate::login::{centered_rect, field_widget}; #[derive(Clone, Copy, PartialEq, Eq)] enum Field { Server, User, Password, AudioLang, SubtitleLang, VideoOutput, } const FIELDS: [Field; 6] = [ Field::Server, Field::User, Field::Password, Field::AudioLang, Field::SubtitleLang, Field::VideoOutput, ]; impl Field { fn next(self) -> Self { let i = FIELDS.iter().position(|f| *f == self).unwrap(); FIELDS[(i + 1) % FIELDS.len()] } fn prev(self) -> Self { let i = FIELDS.iter().position(|f| *f == self).unwrap(); FIELDS[(i + FIELDS.len() - 1) % FIELDS.len()] } } struct Form { server: String, user: String, password: String, audio_lang: String, subtitle_lang: String, video_output: String, focus: Field, error: Option, } impl Form { fn from_config(cfg: &Config) -> Self { Self { server: cfg.server.clone(), user: cfg.user.clone(), password: cfg.password.clone(), audio_lang: cfg.audio_lang.clone().unwrap_or_default(), subtitle_lang: cfg.subtitle_lang.clone().unwrap_or_default(), video_output: cfg.video_output.clone().unwrap_or_default(), focus: Field::Server, error: None, } } fn value_mut(&mut self, f: Field) -> &mut String { match f { Field::Server => &mut self.server, Field::User => &mut self.user, Field::Password => &mut self.password, Field::AudioLang => &mut self.audio_lang, Field::SubtitleLang => &mut self.subtitle_lang, Field::VideoOutput => &mut self.video_output, } } } /// Open the settings editor. Returns when the user saves or cancels. /// Mutates `app` in place: applies the new config, re-authenticates if /// credentials changed, and persists to disk. pub async fn run(term: &mut Terminal, app: &mut App) -> Result<()> { let mut form = Form::from_config(&app.config); loop { term.draw(|f| draw(f, &form))?; if !event::poll(Duration::from_millis(200))? { continue; } let Event::Key(key) = event::read()? else { continue; }; if key.kind != KeyEventKind::Press { continue; } if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C')) { return Ok(()); } match key.code { KeyCode::Esc => return Ok(()), KeyCode::Tab | KeyCode::Down => form.focus = form.focus.next(), KeyCode::BackTab | KeyCode::Up => form.focus = form.focus.prev(), KeyCode::Enter => match apply(&mut form, app).await { Ok(()) => return Ok(()), Err(e) => form.error = Some(format!("{e}")), }, KeyCode::Char(c) => form.value_mut(form.focus).push(c), KeyCode::Backspace => { form.value_mut(form.focus).pop(); } _ => {} } } } async fn apply(form: &mut Form, app: &mut App) -> Result<()> { let server = form.server.trim().trim_end_matches('/').to_string(); let user = form.user.trim().to_string(); if server.is_empty() { form.focus = Field::Server; return Err(anyhow!("Server URL required")); } if !(server.starts_with("http://") || server.starts_with("https://")) { form.focus = Field::Server; return Err(anyhow!("Server URL must start with http:// or https://")); } if user.is_empty() { form.focus = Field::User; return Err(anyhow!("Username required")); } // Reflect the normalized values back so a successful save shows them on // disk and on screen identically. form.server = server.clone(); form.user = user.clone(); let new_cfg = Config { server: server.clone(), user: user.clone(), password: form.password.clone(), audio_lang: trim_to_opt(&form.audio_lang), subtitle_lang: trim_to_opt(&form.subtitle_lang), video_output: trim_to_opt(&form.video_output), }; let creds_changed = new_cfg.server != app.config.server || new_cfg.user != app.config.user || new_cfg.password != app.config.password; if creds_changed { // Re-authenticate against the new endpoint/credentials. On failure // we propagate the error so the form stays open and the user can fix // the input. The old `Client` stays in place, so the session keeps // working if they bail with Esc afterwards. let new_client = Client::new(&new_cfg) .await .map_err(|e| anyhow!("Auth failed: {e}"))?; let libraries = new_client.views().await.unwrap_or_default(); let resume = new_client.resume_items(20).await.unwrap_or_default(); app.libraries = libraries; app.resume = resume; app.home_entries = app::build_home_entries(&app.libraries, &app.resume); app.view = View::Libraries; app.list_state.select(if app.home_entries.is_empty() { None } else { Some(0) }); app.breadcrumbs.clear(); app.selection_stack.clear(); app.current_title = None; app.client = new_client; } if let Some(path) = &app.config_path { config::save(path, &new_cfg)?; app.status = format!("Settings saved to {}", path.display()); } else { app.status = "Settings applied (no writable config path)".into(); } app.config = new_cfg; Ok(()) } fn trim_to_opt(s: &str) -> Option { let t = s.trim(); if t.is_empty() { None } else { Some(t.to_string()) } } fn draw(f: &mut ratatui::Frame, form: &Form) { let area = centered_rect(70, 80, f.area()); let block = Block::default() .borders(Borders::ALL) .title(" Settings ") .border_style(Style::default().fg(Color::Cyan)); f.render_widget(block.clone(), area); let inner = block.inner(area); let rows = Layout::default() .direction(Direction::Vertical) .margin(1) .constraints([ Constraint::Length(1), // hint Constraint::Length(3), // server Constraint::Length(3), // user Constraint::Length(3), // password Constraint::Length(3), // audio lang Constraint::Length(3), // subtitle lang Constraint::Length(3), // video output Constraint::Min(1), // error / help ]) .split(inner); let hint = Paragraph::new(Span::styled( "Edit configuration. Changing server/user/password reconnects to Emby.", Style::default().fg(Color::Gray), )); f.render_widget(hint, rows[0]); f.render_widget( field_widget("Server", &form.server, form.focus == Field::Server, false), rows[1], ); f.render_widget( field_widget("User", &form.user, form.focus == Field::User, false), rows[2], ); f.render_widget( field_widget( "Password", &form.password, form.focus == Field::Password, true, ), rows[3], ); f.render_widget( field_widget( "Audio lang (e.g. jpn,eng)", &form.audio_lang, form.focus == Field::AudioLang, false, ), rows[4], ); f.render_widget( field_widget( "Subtitle lang (e.g. eng)", &form.subtitle_lang, form.focus == Field::SubtitleLang, false, ), rows[5], ); f.render_widget( field_widget( "Video output (gpu/sixel/kitty/drm/tct, blank=mpv default)", &form.video_output, form.focus == Field::VideoOutput, false, ), rows[6], ); let footer = if let Some(err) = &form.error { Line::from(Span::styled(err.clone(), Style::default().fg(Color::Red))) } else { Line::from(Span::styled( "Tab/↓ next · Shift+Tab/↑ prev · Enter save · Esc cancel", Style::default().fg(Color::DarkGray), )) }; f.render_widget(Paragraph::new(footer), rows[7]); }