Add default audio/subtitle language config and in-TUI settings editor
* New audio_lang / subtitle_lang fields in config (file, CLI flags, env vars). Forwarded to mpv's alang/slang as a comma-separated ISO 639 priority list, e.g. "jpn,eng". * New Settings screen (press `s` in the main view) for editing server, user, password, and language preferences without leaving the app. Credential changes trigger re-authentication against the new endpoint; on success the libraries/resume list is refreshed. Everything is persisted back to the config file the app loaded from. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
a9495389e8
commit
f0ebb4b9a4
8 files changed
+342
-13
No files matched your search
+269
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<B: Backend>(term: &mut Terminal<B>, 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<String> {
|
||||
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]);
|
||||
}
|
||||
Reference in new issue
Block a user