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
@@ -1,3 +1,8 @@
|
|||||||
server = "http://emby.home.local"
|
server = "http://emby.home.local"
|
||||||
user = "admin"
|
user = "admin"
|
||||||
password = "your-password-here"
|
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"
|
||||||
+20
-3
@@ -1,6 +1,8 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use ratatui::widgets::ListState;
|
use ratatui::widgets::ListState;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
use crate::emby::{Client, Item};
|
use crate::emby::{Client, Item};
|
||||||
use crate::player::Player;
|
use crate::player::Player;
|
||||||
|
|
||||||
@@ -40,10 +42,20 @@ pub struct App {
|
|||||||
/// brings you to the same season/series row, not row 0).
|
/// brings you to the same season/series row, not row 0).
|
||||||
pub selection_stack: Vec<usize>,
|
pub selection_stack: Vec<usize>,
|
||||||
pub status: String,
|
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<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub async fn new(client: Client) -> Result<Self> {
|
pub async fn new(
|
||||||
|
client: Client,
|
||||||
|
config: Config,
|
||||||
|
config_path: Option<PathBuf>,
|
||||||
|
) -> Result<Self> {
|
||||||
let libraries = client.views().await?;
|
let libraries = client.views().await?;
|
||||||
// Best-effort: don't fail startup if the resume endpoint hiccups.
|
// Best-effort: don't fail startup if the resume endpoint hiccups.
|
||||||
let resume = client.resume_items(20).await.unwrap_or_default();
|
let resume = client.resume_items(20).await.unwrap_or_default();
|
||||||
@@ -72,6 +84,8 @@ impl App {
|
|||||||
breadcrumbs: vec![],
|
breadcrumbs: vec![],
|
||||||
selection_stack: vec![],
|
selection_stack: vec![],
|
||||||
status,
|
status,
|
||||||
|
config,
|
||||||
|
config_path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +242,10 @@ impl App {
|
|||||||
self.status = format!("Playing {title}...");
|
self.status = format!("Playing {title}...");
|
||||||
let start_seconds = (*start_ticks as f64) / 10_000_000.0;
|
let start_seconds = (*start_ticks as f64) / 10_000_000.0;
|
||||||
let reporter = self.client.reporter(id.clone());
|
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));
|
let r = player.play_blocking(&url, title, start_seconds, Some(&reporter));
|
||||||
self.status = match &r {
|
self.status = match &r {
|
||||||
Ok(_) => format!("Finished: {title}"),
|
Ok(_) => format!("Finished: {title}"),
|
||||||
@@ -261,7 +278,7 @@ fn restore_selection(saved: Option<usize>, len: usize) -> Option<usize> {
|
|||||||
Some(saved.unwrap_or(0).min(len - 1))
|
Some(saved.unwrap_or(0).min(len - 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_home_entries(libraries: &[Item], resume: &[Item]) -> Vec<Item> {
|
pub(crate) fn build_home_entries(libraries: &[Item], resume: &[Item]) -> Vec<Item> {
|
||||||
let mut entries: Vec<Item> = Vec::with_capacity(libraries.len() + 1);
|
let mut entries: Vec<Item> = Vec::with_capacity(libraries.len() + 1);
|
||||||
if !resume.is_empty() {
|
if !resume.is_empty() {
|
||||||
entries.push(Item {
|
entries.push(Item {
|
||||||
|
|||||||
+20
-4
@@ -18,6 +18,16 @@ pub struct CliArgs {
|
|||||||
#[arg(short, long, env = "EMBY_PASSWORD")]
|
#[arg(short, long, env = "EMBY_PASSWORD")]
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
|
||||||
/// Path to config file. Defaults: ./config.toml then $XDG_CONFIG_HOME/emby-client/config.toml
|
/// Path to config file. Defaults: ./config.toml then $XDG_CONFIG_HOME/emby-client/config.toml
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
pub config: Option<PathBuf>,
|
pub config: Option<PathBuf>,
|
||||||
@@ -32,6 +42,8 @@ pub struct FileConfig {
|
|||||||
pub server: Option<String>,
|
pub server: Option<String>,
|
||||||
pub user: Option<String>,
|
pub user: Option<String>,
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
|
pub audio_lang: Option<String>,
|
||||||
|
pub subtitle_lang: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -39,24 +51,26 @@ pub struct Config {
|
|||||||
pub server: String,
|
pub server: String,
|
||||||
pub user: String,
|
pub user: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
|
pub audio_lang: Option<String>,
|
||||||
|
pub subtitle_lang: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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<Config>, Option<PathBuf>)> {
|
pub fn load(args: &CliArgs) -> Result<(Option<Config>, Option<PathBuf>)> {
|
||||||
let (file_cfg, save_target) = read_file_config(args.config.as_deref())?;
|
let (file_cfg, save_target) = read_file_config(args.config.as_deref())?;
|
||||||
|
|
||||||
let server = args.server.clone().or(file_cfg.server);
|
let server = args.server.clone().or(file_cfg.server);
|
||||||
let user = args.user.clone().or(file_cfg.user);
|
let user = args.user.clone().or(file_cfg.user);
|
||||||
let password = args.password.clone().or(file_cfg.password);
|
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) {
|
let cfg = match (server, user, password) {
|
||||||
(Some(server), Some(user), Some(password)) => Some(Config {
|
(Some(server), Some(user), Some(password)) => Some(Config {
|
||||||
server: server.trim_end_matches('/').to_string(),
|
server: server.trim_end_matches('/').to_string(),
|
||||||
user,
|
user,
|
||||||
password,
|
password,
|
||||||
|
audio_lang,
|
||||||
|
subtitle_lang,
|
||||||
}),
|
}),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
@@ -108,6 +122,8 @@ pub fn save(path: &Path, cfg: &Config) -> Result<()> {
|
|||||||
server: Some(cfg.server.clone()),
|
server: Some(cfg.server.clone()),
|
||||||
user: Some(cfg.user.clone()),
|
user: Some(cfg.user.clone()),
|
||||||
password: Some(cfg.password.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")?;
|
let text = toml::to_string_pretty(&file_cfg).context("serializing config")?;
|
||||||
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
|
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
|
||||||
|
|||||||
+4
-2
@@ -137,6 +137,8 @@ fn run<B: ratatui::backend::Backend>(
|
|||||||
server,
|
server,
|
||||||
user,
|
user,
|
||||||
password: form.password.clone(),
|
password: form.password.clone(),
|
||||||
|
audio_lang: None,
|
||||||
|
subtitle_lang: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
KeyCode::Char(' ') if form.focus == Field::SaveToggle => {
|
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]);
|
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 {
|
let display: String = if mask {
|
||||||
"*".repeat(value.chars().count())
|
"*".repeat(value.chars().count())
|
||||||
} else {
|
} 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()
|
let popup_layout = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
|
|||||||
+12
-2
@@ -4,6 +4,7 @@ mod emby;
|
|||||||
mod install;
|
mod install;
|
||||||
mod login;
|
mod login;
|
||||||
mod player;
|
mod player;
|
||||||
|
mod settings;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -21,7 +22,7 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
let (cfg, save_target) = config::load(&args)?;
|
let (cfg, save_target) = config::load(&args)?;
|
||||||
|
|
||||||
let cfg = match cfg {
|
let mut cfg = match cfg {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => {
|
None => {
|
||||||
// Interactive credential prompt in the TUI
|
// 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 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?;
|
ui::run(&mut app).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
+9
-1
@@ -9,7 +9,7 @@ pub struct Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Player {
|
impl Player {
|
||||||
pub fn new() -> Result<Self> {
|
pub fn new(audio_lang: Option<&str>, subtitle_lang: Option<&str>) -> Result<Self> {
|
||||||
let mpv = Mpv::new().map_err(|e| anyhow!("mpv init failed: {e}"))?;
|
let mpv = Mpv::new().map_err(|e| anyhow!("mpv init failed: {e}"))?;
|
||||||
for (k, v) in [
|
for (k, v) in [
|
||||||
("force-window", "yes"),
|
("force-window", "yes"),
|
||||||
@@ -21,6 +21,14 @@ impl Player {
|
|||||||
] {
|
] {
|
||||||
let _ = mpv.set_property(k, v);
|
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 })
|
Ok(Self { mpv })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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]);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use crate::app::{App, RESUME_VIRTUAL_ID, View};
|
use crate::app::{App, RESUME_VIRTUAL_ID, View};
|
||||||
use crate::emby::Item;
|
use crate::emby::Item;
|
||||||
|
use crate::settings;
|
||||||
|
|
||||||
type Term = Terminal<CrosstermBackend<Stdout>>;
|
type Term = Terminal<CrosstermBackend<Stdout>>;
|
||||||
|
|
||||||
@@ -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::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);
|
f.render_stateful_widget(list, chunks[1], &mut app.list_state);
|
||||||
|
|
||||||
// Status bar
|
// 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![
|
let status = Paragraph::new(vec![
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
app.status.clone(),
|
app.status.clone(),
|
||||||
|
|||||||
Reference in new issue
Block a user