diff --git a/config.toml.example b/config.toml.example index 8d4d6e1..207d79a 100644 --- a/config.toml.example +++ b/config.toml.example @@ -1,3 +1,8 @@ server = "http://emby.home.local" user = "admin" password = "your-password-here" + +# Optional default audio/subtitle language preference. Comma-separated ISO 639 +# codes in priority order; forwarded to mpv's `alang` / `slang`. +# audio_lang = "jpn,eng" +# subtitle_lang = "eng" diff --git a/src/app.rs b/src/app.rs index 727cbab..33ccce0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,8 @@ use anyhow::Result; use ratatui::widgets::ListState; +use std::path::PathBuf; +use crate::config::Config; use crate::emby::{Client, Item}; use crate::player::Player; @@ -40,10 +42,20 @@ pub struct App { /// brings you to the same season/series row, not row 0). pub selection_stack: Vec, pub status: String, + /// Live, editable configuration. Mirrors what's on disk; the settings + /// screen mutates this and persists it through `config_path`. + pub config: Config, + /// Where to persist updated settings. `None` when no writable location was + /// determined (rare; the settings screen will then refuse to save). + pub config_path: Option, } impl App { - pub async fn new(client: Client) -> Result { + pub async fn new( + client: Client, + config: Config, + config_path: Option, + ) -> Result { let libraries = client.views().await?; // Best-effort: don't fail startup if the resume endpoint hiccups. let resume = client.resume_items(20).await.unwrap_or_default(); @@ -72,6 +84,8 @@ impl App { breadcrumbs: vec![], selection_stack: vec![], status, + config, + config_path, }) } @@ -228,7 +242,10 @@ impl App { self.status = format!("Playing {title}..."); let start_seconds = (*start_ticks as f64) / 10_000_000.0; let reporter = self.client.reporter(id.clone()); - let mut player = Player::new()?; + let mut player = Player::new( + self.config.audio_lang.as_deref(), + self.config.subtitle_lang.as_deref(), + )?; let r = player.play_blocking(&url, title, start_seconds, Some(&reporter)); self.status = match &r { Ok(_) => format!("Finished: {title}"), @@ -261,7 +278,7 @@ fn restore_selection(saved: Option, len: usize) -> Option { Some(saved.unwrap_or(0).min(len - 1)) } -fn build_home_entries(libraries: &[Item], resume: &[Item]) -> Vec { +pub(crate) fn build_home_entries(libraries: &[Item], resume: &[Item]) -> Vec { let mut entries: Vec = Vec::with_capacity(libraries.len() + 1); if !resume.is_empty() { entries.push(Item { diff --git a/src/config.rs b/src/config.rs index 34863ba..79dd121 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,6 +18,16 @@ pub struct CliArgs { #[arg(short, long, env = "EMBY_PASSWORD")] pub password: Option, + /// Preferred audio language(s), comma-separated ISO 639 codes (e.g. "jpn,eng"). + /// Forwarded to mpv's `alang`. + #[arg(long, env = "EMBY_AUDIO_LANG")] + pub audio_lang: Option, + + /// Preferred subtitle language(s), comma-separated ISO 639 codes (e.g. "eng,jpn"). + /// Forwarded to mpv's `slang`. + #[arg(long, env = "EMBY_SUBTITLE_LANG")] + pub subtitle_lang: Option, + /// Path to config file. Defaults: ./config.toml then $XDG_CONFIG_HOME/emby-client/config.toml #[arg(short, long)] pub config: Option, @@ -32,6 +42,8 @@ pub struct FileConfig { pub server: Option, pub user: Option, pub password: Option, + pub audio_lang: Option, + pub subtitle_lang: Option, } #[derive(Debug, Clone)] @@ -39,24 +51,26 @@ pub struct Config { pub server: String, pub user: String, pub password: String, + pub audio_lang: Option, + pub subtitle_lang: Option, } -/// Returns `(Some(config), save_target)` when all credentials are available, -/// or `(None, save_target)` when at least one is missing — the caller can then -/// prompt the user. `save_target` is the path where new credentials should -/// be persisted if the user chooses to save them. pub fn load(args: &CliArgs) -> Result<(Option, Option)> { let (file_cfg, save_target) = read_file_config(args.config.as_deref())?; let server = args.server.clone().or(file_cfg.server); let user = args.user.clone().or(file_cfg.user); let password = args.password.clone().or(file_cfg.password); + let audio_lang = args.audio_lang.clone().or(file_cfg.audio_lang); + let subtitle_lang = args.subtitle_lang.clone().or(file_cfg.subtitle_lang); let cfg = match (server, user, password) { (Some(server), Some(user), Some(password)) => Some(Config { server: server.trim_end_matches('/').to_string(), user, password, + audio_lang, + subtitle_lang, }), _ => None, }; @@ -108,6 +122,8 @@ pub fn save(path: &Path, cfg: &Config) -> Result<()> { server: Some(cfg.server.clone()), user: Some(cfg.user.clone()), password: Some(cfg.password.clone()), + audio_lang: cfg.audio_lang.clone(), + subtitle_lang: cfg.subtitle_lang.clone(), }; let text = toml::to_string_pretty(&file_cfg).context("serializing config")?; std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?; diff --git a/src/login.rs b/src/login.rs index ab708e6..1b7c81d 100644 --- a/src/login.rs +++ b/src/login.rs @@ -137,6 +137,8 @@ fn run( server, user, password: form.password.clone(), + audio_lang: None, + subtitle_lang: None, }); } KeyCode::Char(' ') if form.focus == Field::SaveToggle => { @@ -222,7 +224,7 @@ fn draw(f: &mut ratatui::Frame, form: &Form) { f.render_widget(Paragraph::new(footer), rows[5]); } -fn field_widget<'a>(label: &'a str, value: &'a str, focused: bool, mask: bool) -> Paragraph<'a> { +pub(crate) fn field_widget<'a>(label: &'a str, value: &'a str, focused: bool, mask: bool) -> Paragraph<'a> { let display: String = if mask { "*".repeat(value.chars().count()) } else { @@ -243,7 +245,7 @@ fn field_widget<'a>(label: &'a str, value: &'a str, focused: bool, mask: bool) - ) } -fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { +pub(crate) fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { let popup_layout = Layout::default() .direction(Direction::Vertical) .constraints([ diff --git a/src/main.rs b/src/main.rs index 16385a4..fe9bb9b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod emby; mod install; mod login; mod player; +mod settings; mod ui; use anyhow::Result; @@ -21,7 +22,7 @@ async fn main() -> Result<()> { let (cfg, save_target) = config::load(&args)?; - let cfg = match cfg { + let mut cfg = match cfg { Some(c) => c, None => { // Interactive credential prompt in the TUI @@ -29,8 +30,17 @@ async fn main() -> Result<()> { } }; + // CLI/env language flags still apply even when credentials came from the + // interactive prompt (which doesn't ask for languages). + if cfg.audio_lang.is_none() { + cfg.audio_lang = args.audio_lang.clone(); + } + if cfg.subtitle_lang.is_none() { + cfg.subtitle_lang = args.subtitle_lang.clone(); + } + let client = emby::Client::new(&cfg).await?; - let mut app = app::App::new(client).await?; + let mut app = app::App::new(client, cfg, save_target).await?; ui::run(&mut app).await?; Ok(()) } diff --git a/src/player.rs b/src/player.rs index 8d3b03c..1b280ce 100644 --- a/src/player.rs +++ b/src/player.rs @@ -9,7 +9,7 @@ pub struct Player { } impl Player { - pub fn new() -> Result { + pub fn new(audio_lang: Option<&str>, subtitle_lang: Option<&str>) -> Result { let mpv = Mpv::new().map_err(|e| anyhow!("mpv init failed: {e}"))?; for (k, v) in [ ("force-window", "yes"), @@ -21,6 +21,14 @@ impl Player { ] { let _ = mpv.set_property(k, v); } + // Default audio/subtitle track preference. mpv parses these as a + // comma-separated priority list of ISO 639 language codes. + if let Some(alang) = audio_lang { + let _ = mpv.set_property("alang", alang); + } + if let Some(slang) = subtitle_lang { + let _ = mpv.set_property("slang", slang); + } Ok(Self { mpv }) } diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..eb7225e --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,269 @@ +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, +} + +const FIELDS: [Field; 5] = [ + Field::Server, + Field::User, + Field::Password, + Field::AudioLang, + Field::SubtitleLang, +]; + +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, + 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(), + 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, + } + } +} + +/// 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), + }; + + 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::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], + ); + + 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[6]); +} diff --git a/src/ui.rs b/src/ui.rs index 0e7a050..da7fe03 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -15,6 +15,7 @@ use std::time::Duration; use crate::app::{App, RESUME_VIRTUAL_ID, View}; use crate::emby::Item; +use crate::settings; type Term = Terminal>; @@ -89,6 +90,7 @@ async fn run_loop(term: &mut Term, app: &mut App) -> Result<()> { } } KeyCode::Backspace | KeyCode::Char('h') | KeyCode::Left => app.back().await?, + KeyCode::Char('s') => settings::run(term, app).await?, _ => {} } } @@ -147,7 +149,7 @@ fn draw(f: &mut ratatui::Frame, app: &mut App) { f.render_stateful_widget(list, chunks[1], &mut app.list_state); // Status bar - let help = "↑/↓ move · Enter open/play · Backspace back · q quit (Q force)"; + let help = "↑/↓ move · Enter open/play · Backspace back · s settings · q quit (Q force)"; let status = Paragraph::new(vec![ Line::from(Span::styled( app.status.clone(),