mod core; const PAGE_SIZE: usize = 50; use std::{ io::{self, Write, Read, BufRead, BufReader}, path::PathBuf, process::{Child, Command, Stdio}, time::{Duration, Instant}, collections::HashSet, thread, }; use std::sync::{Arc, Mutex, mpsc}; use std::os::unix::net::UnixStream; use serde_json::Value; use crate::core::downloads; use crate::core::downloads::{DownloadCommand, DownloadManager, DownloadStatus}; use crossterm::{ event::{self, Event, KeyCode, KeyEventKind}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{ layout::{Constraint, Layout, Rect, Spacing}, symbols::merge::MergeStrategy, widgets::{Block, List, ListItem, ListState, Paragraph, Wrap, Gauge}, style::{Modifier, Style}, text::Line, Frame, Terminal, }; use ratatui::backend::CrosstermBackend; #[derive(Clone)] enum View { Categories, Library { title: String, query: core::jellyfin::LibraryQuery, items: Vec, selected: usize, start_index: usize, total: usize, page_size: usize, loading: bool, }, Login, } #[derive(Clone, Debug, Default)] struct MpvState { eof: bool, idle: bool, time_pos: Option, duration: Option, percent: Option, last_err: Option, // new: media_title: Option, audio: Option, subs: Option, } #[derive(Clone, Copy)] enum LoginFocus { User, Pass, } #[derive(Clone, Copy, Debug)] enum SearchMode { All, // no filter (true "all") ShowsMovies, // Movie + Series Shows, // Series Movies, // Movie Episodes, // Episode Music, // Audio Albums, // MusicAlbum Artists, // MusicArtist } impl SearchMode { fn next(self) -> Self { use SearchMode::*; match self { All => ShowsMovies, ShowsMovies => Shows, Shows => Movies, Movies => Episodes, Episodes => Music, Music => Albums, Albums => Artists, Artists => All, } } fn prev(self) -> Self { use SearchMode::*; match self { All => Artists, ShowsMovies => All, Shows => ShowsMovies, Movies => Shows, Episodes => Movies, Music => Episodes, Albums => Music, Artists => Albums, } } fn label(self) -> &'static str { use SearchMode::*; match self { All => "All", ShowsMovies => "Shows+Movies", Shows => "Shows", Movies => "Movies", Episodes => "Episodes", Music => "Music", Albums => "Albums", Artists => "Artists", } } fn include_item_types(self) -> Option<&'static str> { use SearchMode::*; match self { All => None, // IMPORTANT: truly unfiltered ShowsMovies => Some("Movie,Series"), Shows => Some("Series"), Movies => Some("Movie"), Episodes => Some("Episode"), Music => Some("Audio"), Albums => Some("MusicAlbum"), Artists => Some("MusicArtist"), } } } #[derive(Clone)] enum SearchScope { All, Library { title: String, query: core::jellyfin::LibraryQuery, }, } struct SearchState { mode: SearchMode, scope: SearchScope, input: String, } struct App { should_exit: bool, status: String, config_status: String, view_stack: Vec, items: Vec, selected: usize, config: Option, login_user: String, login_pass: String, login_focus: LoginFocus, login_error: Option, search: Option, mpv: Option, mpv_poll_last: Instant, mpv_state: MpvState, mpv_state_rx: Option>, debug_mpv: bool, mpv_now_playing: Option, downloads: Option, downloads_focus: bool, downloads_selected: usize, downloaded_ids: HashSet, last_download_message: Option, autoplay: bool, autoplay_view_depth: usize, autoplay_next_index: Option, autoplay_eof_latched: bool, autoplay_prev_time_pos: Option, } #[derive(Debug)] struct MpvSession { socket_path: PathBuf, child: Child, } impl Default for App { fn default() -> Self { let loaded = core::config::load_config().ok(); let (view_stack, config_status) = match &loaded { Some(cfg) if cfg.access_token.is_some() => ( vec![View::Categories], format!("Connected: ({})", cfg.base_url), ), Some(cfg) => ( vec![View::Login], format!("Not Connected: ({})", cfg.base_url), ), None => ( vec![View::Login], "Missing Configuration".to_string(), ), }; Self { should_exit: false, status: String::new(), config_status, config: loaded, view_stack, items: vec![ "Shows".into(), "Movies".into(), "Music".into(), "Playlists".into(), "Collections".into(), "Libraries".into(), "Continue Watching".into(), "Recently Added".into(), "Downloads".into(), "Settings".into(), ], selected: 0, login_user: String::new(), login_pass: String::new(), login_focus: LoginFocus::User, login_error: None, search: None, mpv: None, mpv_poll_last: Instant::now(), mpv_state: MpvState::default(), mpv_state_rx: None, debug_mpv: false, mpv_now_playing: None, autoplay: false, autoplay_view_depth: 0, autoplay_next_index: None, autoplay_eof_latched: false, autoplay_prev_time_pos: None, downloads: None, downloads_focus: false, downloads_selected: 0, downloaded_ids: downloads::scan_downloaded_ids().unwrap_or_default(), last_download_message: None, } } } impl App { fn current_view(&self) -> &View { self.view_stack.last().unwrap() } } fn main() -> io::Result<()> { // Terminal setup enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; terminal.clear()?; let res = run_app(&mut terminal); // Terminal restore (always) disable_raw_mode()?; execute!(io::stdout(), LeaveAlternateScreen)?; terminal.show_cursor()?; res } fn run_app(terminal: &mut Terminal>) -> io::Result<()> { let tick_rate = Duration::from_millis(16); // ~60fps let mut last_tick = Instant::now(); let mut app = App::default(); while !app.should_exit { terminal.draw(|f| ui(f, &app))?; let timeout = tick_rate.saturating_sub(last_tick.elapsed()); if event::poll(timeout)? { if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { handle_key(&mut app, key.code); } } } if let Some(rx) = &app.mpv_state_rx { while let Ok(st) = rx.try_recv() { app.mpv_state = st; } } if app.debug_mpv { app.status = fmt_mpv_state(&app.mpv_state); } // --- mpv lifecycle (no borrow issues) --- let mpv_exited = match app.mpv.as_mut() { Some(mpv) => match mpv.child.try_wait() { Ok(Some(status)) => Some(status), _ => None, }, None => None, }; if let Some(status) = mpv_exited { // take ownership so we can clean up if let Some(mpv) = app.mpv.take() { let _ = std::fs::remove_file(&mpv.socket_path); app.status = format!("mpv exited: {status}"); } // Your requirement: window closing disables autoplay app.autoplay = false; app.autoplay_next_index = None; app.mpv_now_playing = None; app.mpv_state_rx = None; app.mpv_state = MpvState::default(); } // Refresh local-download index when downloads complete if let Some(dm) = &app.downloads { if let Ok(st) = dm.state.lock() { let msg = st.last_message.clone(); if msg != app.last_download_message { app.last_download_message = msg.clone(); if matches!(msg.as_deref(), Some("download complete")) { app.downloaded_ids = downloads::scan_downloaded_ids().unwrap_or_default(); } } } } // Option A: always tick autoplay by polling eof-reached (window stays open) autoplay_tick(&mut app); if last_tick.elapsed() >= tick_rate { // tick-based updates go here later (smooth nav, download polling, etc.) last_tick = Instant::now(); } } if let Some(dm) = &app.downloads { dm.send(downloads::DownloadCommand::Shutdown); } Ok(()) } fn handle_key(app: &mut App, code: KeyCode) { // Special input handling in Login view if matches!(app.current_view(), View::Login) { match code { KeyCode::Char('q') => { app.should_exit = true; return; } KeyCode::Esc => { app.should_exit = true; return; } KeyCode::Tab => { app.login_focus = match app.login_focus { LoginFocus::User => LoginFocus::Pass, LoginFocus::Pass => LoginFocus::User, }; return; } KeyCode::Backspace => { match app.login_focus { LoginFocus::User => { app.login_user.pop(); } LoginFocus::Pass => { app.login_pass.pop(); } } return; } KeyCode::Enter => { let cfg = match &app.config { Some(c) => c.clone(), None => { app.login_error = Some("No config loaded. Create config.toml with base_url.".to_string()); return; } }; let client = core::jellyfin::JellyfinClient::unauthenticated(&cfg.base_url); match client.login_by_name(&app.login_user, &app.login_pass) { Ok(res) => { let mut new_cfg = cfg; new_cfg.access_token = Some(res.access_token); new_cfg.user_id = res.user_id; if let Err(e) = core::config::save_config(&new_cfg) { app.login_error = Some(format!("login ok but failed to write config: {e:?}")); return; } app.config_status = format!("auth: token present ({})", new_cfg.base_url); app.config = Some(new_cfg); app.login_error = None; app.login_pass.clear(); // don't keep password around app.view_stack = vec![View::Categories]; app.status = "Logged in".to_string(); } Err(e) => { app.login_error = Some(e); } } return; } KeyCode::Char(c) => { match app.login_focus { LoginFocus::User => app.login_user.push(c), LoginFocus::Pass => app.login_pass.push(c), } return; } _ => return, } } // If search mode is active, it captures input first if let Some(search) = &mut app.search { match code { KeyCode::Esc => { app.search = None; return; } KeyCode::Backspace => { search.input.pop(); return; } KeyCode::Enter => { let term = search.input.trim().to_string(); if term.is_empty() { app.search = None; return; } let mode = search.mode; let types = mode.include_item_types().map(|s| s.to_string()); let (title, mut query) = match &search.scope { SearchScope::All => ( format!("Search: {}", term), core::jellyfin::LibraryQuery::all().with_search(term.clone()), ), SearchScope::Library { title, query } => ( format!("{}: {}", title, term), query.clone().with_search(term.clone()), ), }; // Override/force item types for the search mode query.include_item_types = types; app.search = None; // run search (same as your library fetch) let cfg = match &app.config { Some(c) => c.clone(), None => { app.status = "No config".into(); return; } }; let user_id = match cfg.user_id.as_deref() { Some(u) => u, None => { app.status = "Missing user_id".into(); return; } }; let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { Ok(c) => c, Err(e) => { app.status = e; return; } }; app.status = format!("Searching… {}", term); match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) { Ok(page) => { app.view_stack.push(View::Library { title, query, items: page.items, selected: 0, start_index: 0, total: page.total, page_size: PAGE_SIZE, loading: false, }); app.status = "Search loaded".into(); } Err(e) => app.status = format!("Search failed: {e}"), } return; } KeyCode::Char('/') => { search.mode = search.mode.next(); return; } KeyCode::Char('?') => { search.mode = search.mode.prev(); return; } KeyCode::Char(c) => { search.input.push(c); return; } _ => return, } } // Normalize arrow navigation let code = match code { KeyCode::Right => KeyCode::Enter, KeyCode::Left => KeyCode::Esc, KeyCode::Char('l') => KeyCode::Enter, KeyCode::Char('h') => KeyCode::Esc, other => other, }; // q behavior: if not home, go home; if home, quit if matches!(code, KeyCode::Char('q')) { app.autoplay = false; app.autoplay_next_index = None; if !matches!(app.current_view(), View::Categories) { app.view_stack = vec![View::Categories]; app.selected = 0; app.status = "$".into(); return; } else { app.should_exit = true; return; } } if matches!(code, KeyCode::Char('D')) { app.downloads_focus = !app.downloads_focus; app.downloads_selected = 0; app.status = if app.downloads_focus { "downloads: focus".into() } else { "downloads: unfocus".into() }; return; } // -------------------- // Downloads Focus Modal // -------------------- if app.downloads_focus { match code { KeyCode::Esc => { app.downloads_focus = false; app.status = "downloads: unfocus".into(); return; } KeyCode::Up | KeyCode::Char('k') => { if app.downloads_selected > 0 { app.downloads_selected -= 1; } return; } KeyCode::Down | KeyCode::Char('j') => { let len = app .downloads .as_ref() .and_then(|dm| dm.state.lock().ok()) .map(|st| st.queue.len()) .unwrap_or(0); if len > 0 && app.downloads_selected + 1 < len { app.downloads_selected += 1; } return; } KeyCode::Char('d') => { if let Some(dm) = &app.downloads { let idx = app.downloads_selected; if idx == 0 { dm.send(downloads::DownloadCommand::Cancel { index: 0 }); app.status = "download: cancel active".into(); } else { dm.send(downloads::DownloadCommand::RemoveQueued { index: idx }); app.status = "download: removed".into(); } // Clamp selection after removal let len = dm .state .lock() .ok() .map(|st| st.queue.len()) .unwrap_or(0); if len == 0 { app.downloads_selected = 0; } else if app.downloads_selected >= len { app.downloads_selected = len - 1; } } else { app.status = "downloads not available".into(); } return; } // Swallow everything else while focused. _ => return, } } // mpv controls (only when not typing into search/login) if app.search.is_none() && !matches!(app.current_view(), View::Login) { match code { KeyCode::Char('M') => { app.debug_mpv = !app.debug_mpv; app.status = format!("mpv debug: {}", if app.debug_mpv { "ON" } else { "OFF" }); return; } KeyCode::Char('a') => { if let Some(mpv) = &app.mpv { if let Err(e) = mpv_cycle_audio(mpv) { app.status = format!("mpv audio: {e}"); } } return; } KeyCode::Char('s') => { if let Some(mpv) = &app.mpv { if let Err(e) = mpv_cycle_subtitles(mpv) { app.status = format!("mpv subs: {e}"); } } return; } KeyCode::Char('v') => { if let Some(mpv) = &app.mpv { if let Err(e) = mpv_toggle_subs(mpv) { app.status = format!("mpv sub vis: {e}"); } } return; } KeyCode::Char('x') => { app.autoplay = false; app.autoplay_next_index = None; if let Some(mut mpv) = app.mpv.take() { mpv_stop(&mut mpv); app.status = "Stopped mpv".into(); } return; } KeyCode::Char('f') => { if let Some(mpv) = &app.mpv { if let Err(e) = mpv_toggle_fullscreen(mpv) { app.status = format!("mpv fullscreen: {e}"); } } else { app.status = "mpv not running (press 'p' to play)".into(); } return; } KeyCode::Char(' ') => { if let Some(mpv) = &app.mpv { if let Err(e) = mpv_cycle_pause(mpv) { app.status = format!("mpv pause: {e}"); } } else { app.status = "mpv not running (press 'p' to play)".into(); } return; } KeyCode::Char('p') => { app.autoplay = false; app.autoplay_next_index = None; // p = play selected item (spawn or replace) let cfg = match &app.config { Some(c) => c.clone(), None => { app.status = "No config loaded".into(); return; } }; // Determine what is “selected” depending on view let (item_id, item_name) = match app.current_view() { View::Library { items, selected, .. } if !items.is_empty() => { (items[*selected].id.clone(), items[*selected].name.clone()) } _ => { app.status = "Nothing to play here".into(); return; } }; app.mpv_now_playing = Some(item_name); // If mpv running -> replace, else spawn let url = build_jellyfin_play_url(&cfg, &item_id); if let Some(mpv) = &app.mpv { match mpv_load_url(&cfg, mpv, &url) { Ok(_) => app.status = "mpv: loaded".into(), Err(e) => app.status = format!("mpv load failed: {e}"), } } else { match spawn_mpv_url(&cfg, &url) { Ok(mpv) => { let rx = start_mpv_poller(mpv.socket_path.clone()); app.mpv_state_rx = Some(rx); app.mpv = Some(mpv); app.status = "mpv: playing".into(); } Err(e) => app.status = format!("mpv spawn failed: {e}"), } } return; } KeyCode::Char('P') => { let cfg = match &app.config { Some(c) => c.clone(), None => { app.status="No config".into(); return; } }; let (item_id, item_name, selected, len) = match app.current_view() { View::Library { items, selected, .. } if !items.is_empty() => { (items[*selected].id.clone(), items[*selected].name.clone(), *selected, items.len()) } _ => { app.status = "Nothing to play here".into(); return; } }; app.mpv_now_playing = Some(item_name); app.autoplay = true; app.status = format!("autoplay ON (next index: {:?})", app.autoplay_next_index); app.autoplay_prev_time_pos = None; app.autoplay_view_depth = app.view_stack.len(); app.autoplay_next_index = if selected + 1 < len { Some(selected + 1) } else { None }; // load/replace or spawn let url = build_jellyfin_play_url(&cfg, &item_id); if let Some(mpv) = &app.mpv { match mpv_load_url(&cfg, mpv, &url) { Ok(_) => app.status = "mpv: playing (autoplay)".into(), Err(e) => app.status = format!("mpv load failed: {e}"), } } else { match spawn_mpv_url(&cfg, &url) { Ok(mpv) => { let rx = start_mpv_poller(mpv.socket_path.clone()); app.mpv_state_rx = Some(rx); app.mpv = Some(mpv); app.status = "mpv: playing".into(); } Err(e) => app.status = format!("mpv spawn failed: {e}"), } } return; } KeyCode::Char('r') => { app.autoplay = false; app.autoplay_next_index = None; let cfg = match &app.config { Some(c) => c.clone(), None => { app.status = "No config loaded".into(); return; } }; let (item_id, item_name, start_ticks) = match app.current_view() { View::Library { items, selected, .. } if !items.is_empty() => { let it = &items[*selected]; let ty = it.item_type.as_deref().unwrap_or(""); if ty != "Episode" && ty != "Movie" { app.status = "Resume only works on Episodes/Movies (for now).".into(); return; } (it.id.clone(), it.name.clone(), playback_position_ticks(it)) } _ => { app.status = "Nothing to resume here".into(); return; } }; app.mpv_now_playing = Some(item_name); let url = build_jellyfin_play_url(&cfg, &item_id); if let Some(mpv) = &app.mpv { match mpv_cmd(&mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url)) { Ok(_) => { if let Some(t) = start_ticks { let _ = mpv_seek_absolute(&mpv.socket_path, ticks_to_seconds(t)); } app.status = "mpv: resumed".into(); } Err(e) => app.status = format!("mpv resume failed: {e}"), } } else { match spawn_mpv_url(&cfg, &url) { Ok(mpv) => { let rx = start_mpv_poller(mpv.socket_path.clone()); app.mpv_state_rx = Some(rx); let sock = mpv.socket_path.clone(); app.mpv = Some(mpv); if let Some(t) = start_ticks { let _ = mpv_seek_absolute(&sock, ticks_to_seconds(t)); } app.status = "mpv: resumed".into(); } Err(e) => app.status = format!("mpv spawn failed: {e}"), } } return; } KeyCode::Char('R') => { // resume + autoplay let cfg = match &app.config { Some(c) => c.clone(), None => { app.status="No config".into(); return; } }; let (item_id, item_name, selected, len, start_ticks) = match app.current_view() { View::Library { items, selected, .. } if !items.is_empty() => { let it = &items[*selected]; let ty = it.item_type.as_deref().unwrap_or(""); if ty != "Episode" && ty != "Movie" { app.status = "Resume only works on Episodes/Movies (for now).".into(); return; } (it.id.clone(), it.name.clone(), *selected, items.len(), playback_position_ticks(it)) } _ => { app.status = "Nothing to resume here".into(); return; } }; app.mpv_now_playing = Some(item_name); app.autoplay = true; app.autoplay_prev_time_pos = None; app.autoplay_view_depth = app.view_stack.len(); app.autoplay_next_index = if selected + 1 < len { Some(selected + 1) } else { None }; app.autoplay_eof_latched = false; let url = build_jellyfin_play_url(&cfg, &item_id); if let Some(mpv) = &app.mpv { match mpv_cmd(&mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url)) { Ok(_) => { if let Some(t) = start_ticks { let _ = mpv_seek_absolute(&mpv.socket_path, ticks_to_seconds(t)); } app.status = "mpv: resumed (autoplay)".into(); } Err(e) => app.status = format!("mpv resume failed: {e}"), } } else { match spawn_mpv_url(&cfg, &url) { Ok(mpv) => { let rx = start_mpv_poller(mpv.socket_path.clone()); app.mpv_state_rx = Some(rx); let sock = mpv.socket_path.clone(); app.mpv = Some(mpv); if let Some(t) = start_ticks { let _ = mpv_seek_absolute(&sock, ticks_to_seconds(t)); } app.status = "mpv: resumed (autoplay)".into(); } Err(e) => app.status = format!("mpv spawn failed: {e}"), } } return; } _ => {} } } // downloads focus overrides normal navigation // downloads focus overrides normal navigation let mut handled_by_downloads = false; if app.downloads_focus { if let Some(dm) = &app.downloads { let idx = app.downloads_selected; if idx == 0 { dm.send(downloads::DownloadCommand::Cancel { index: 0 }); app.status = "download: cancel active".into(); } else { dm.send(downloads::DownloadCommand::RemoveQueued { index: idx }); app.status = "download: removed".into(); } // Clamp selection immediately to avoid focus navigation glitches let len = dm .state .lock() .ok() .map(|st| st.queue.len()) .unwrap_or(0); if len == 0 { app.downloads_selected = 0; } else if app.downloads_selected >= len { app.downloads_selected = len - 1; } } else { app.status = "downloads not available".into(); } return; } // If downloads handled the key, skip normal navigation handling if handled_by_downloads { return; } match code { KeyCode::Char('d') => { let cfg = match &app.config { Some(c) => c.clone(), None => { app.status = "No config loaded".into(); return; } }; let user_id = match cfg.user_id.as_deref() { Some(u) => u, None => { app.status = "No user_id (login first)".into(); return; } }; let (it) = match app.current_view() { View::Library { items, selected, .. } if !items.is_empty() => items[*selected].clone(), _ => { app.status = "Nothing to download here".into(); return; } }; let dm = match &app.downloads { Some(dm) => dm, None => { // try start downloads now if we have a token if cfg.access_token.is_some() { app.downloads = Some(downloads::DownloadManager::start(cfg.clone())); app.downloads.as_ref().unwrap() } else { app.status = "Not connected".into(); return; } } }; let jf = match core::jellyfin::JellyfinClient::from_config(&cfg) { Ok(c) => c, Err(e) => { app.status = format!("downloads: {e}"); return; } }; match downloads::expand_for_enqueue(&jf, user_id, &it) { Ok(items) => { let n = items.len(); dm.send(downloads::DownloadCommand::Enqueue(items)); app.status = format!("queued {n}"); } Err(e) => app.status = format!("queue failed: {e}"), } return; } KeyCode::Char('q') => app.should_exit = true, //Enter code Block KeyCode::Enter => { let view = app.current_view().clone(); match view { View::Categories => { let cat = app.items[app.selected].clone(); let cfg = match &app.config { Some(c) => c.clone(), None => { app.status = "No config loaded".into(); return; } }; let user_id = match cfg.user_id.as_deref() { Some(u) => u, None => { app.status = "Missing user_id in config (login again)".into(); return; } }; let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { Ok(c) => c, Err(e) => { app.status = e; return; } }; // Special case: Libraries uses /Views, not /Items if cat == "Libraries" { app.status = "Fetching Libraries...".into(); match client.list_views(user_id) { Ok(items) => { let total = items.len(); app.view_stack.push(View::Library { title: "Libraries".into(), query: core::jellyfin::LibraryQuery::all(), // placeholder items, selected: 0, start_index: 0, total, // cheap total for UI page_size: PAGE_SIZE, loading: false, }); app.status = "Loaded".into(); } Err(e) => app.status = format!("Views failed: {e}"), } return; } // Normal categories use /Items let query = match cat.as_str() { "Movies" => core::jellyfin::LibraryQuery::movies(), "Shows" => core::jellyfin::LibraryQuery::shows(), "Music" => core::jellyfin::LibraryQuery::music(), "Playlists" => core::jellyfin::LibraryQuery::all().with_item_types("Playlist"), _ => { app.status = format!("Not wired yet: {}", cat); return; } }; app.status = format!("Fetching {}...", cat); match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) { Ok(page) => { app.view_stack.push(View::Library { title: cat, query, items: page.items, selected: 0, start_index: 0, total: page.total, page_size: PAGE_SIZE, loading: false, }); app.status = "$".into(); } Err(e) => app.status = format!("Fetch failed: {e}"), } } View::Library { title, items, selected, .. } => { if items.is_empty() { return; } let it = &items[selected]; let ty = it.item_type.as_deref().unwrap_or(""); // Libraries (/Views) usually return CollectionFolder. Entering it should list its contents. if ty == "CollectionFolder" { let q = core::jellyfin::LibraryQuery::all().with_parent(it.id.clone()); open_children(app, format!("{} > {}", title, it.name), q); return; } // Shows -> Seasons -> Episodes if ty == "Series" { let q = core::jellyfin::LibraryQuery::all() .with_parent(it.id.clone()) .with_item_types("Season"); open_children(app, format!("{} > {}", title, it.name), q); return; } if ty == "Season" { let q = core::jellyfin::LibraryQuery::all() .with_parent(it.id.clone()) .with_item_types("Episode"); open_children(app, format!("{} > {}", title, it.name), q); return; } // MusicArtist -> MusicAlbum -> Audio if ty == "MusicArtist" { let q = core::jellyfin::LibraryQuery::all() .with_parent(it.id.clone()) .with_item_types("MusicAlbum"); open_children(app, format!("{} > {}", title, it.name), q); return; } if ty == "MusicAlbum" { let q = core::jellyfin::LibraryQuery::all() .with_parent(it.id.clone()) .with_item_types("Audio"); open_children(app, format!("{} > {}", title, it.name), q); return; } if ty == "BoxSet" { let q = core::jellyfin::LibraryQuery::all().with_parent(it.id.clone()); open_children(app, format!("{} > {}", title, it.name), q); return; } if ty == "UserView" { // Most UserViews are libraries like Shows/Movies/Music/Playlists. // For Playlists view, drill into playlists. let q = core::jellyfin::LibraryQuery::all() .with_parent(it.id.clone()) .with_item_types("Playlist"); open_children(app, format!("{} > {}", title, it.name), q); return; } if ty == "Playlist" { let cfg = match &app.config { Some(c) => c.clone(), None => { app.status="No config".into(); return; } }; let user_id = match cfg.user_id.as_deref() { Some(u) => u, None => { app.status="Missing user_id".into(); return; } }; let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { Ok(c)=>c, Err(e)=>{ app.status=e; return; } }; match client.playlist_items(user_id, &it.id, 0, PAGE_SIZE) { Ok(page) => { app.view_stack.push(View::Library { title: format!("{} > {}", title, it.name), // store a query placeholder; playlist paging could be done later if you want query: core::jellyfin::LibraryQuery::all(), items: page.items, selected: 0, start_index: 0, total: page.total, page_size: PAGE_SIZE, loading: false, }); app.status = "$".into(); } Err(e) => app.status = format!("Playlist load failed: {e}"), } return; } // Playables -> mpv // Playables: do nothing on Enter (use 'p' to play) if ty == "Episode" || ty == "Audio" || ty == "Movie" { app.status = "Press 'p' to play, or 'P' for autoplay.".into(); return; } app.status = format!("No drilldown for type: {}", ty); } _ => {} } } KeyCode::Esc | KeyCode::Backspace => { if app.view_stack.len() > 1 { app.view_stack.pop(); app.status = "$".to_string(); app.selected = 0; } } KeyCode::Up | KeyCode::Char('k') => { match app.view_stack.last_mut().unwrap() { View::Categories => { if app.items.is_empty() { return; } if app.selected == 0 { app.selected = app.items.len() - 1; } else { app.selected -= 1; } //app.status = format!("Selected: {}", app.items[app.selected]); app.status = "$".into(); } View::Library { items, selected, total, .. } => { if items.is_empty() { return; } if *selected == 0 { // wrap-up wants the real end if items.len() < *total { ensure_loaded_to_end(app); } if let Some(View::Library { items, selected, .. }) = app.view_stack.last_mut() { if !items.is_empty() { *selected = items.len() - 1; } } } else { *selected -= 1; } maybe_prefetch(app); } _ => {} } } KeyCode::Down | KeyCode::Char('j') => { match app.view_stack.last_mut().unwrap() { View::Categories => { if app.items.is_empty() { return; } app.selected = (app.selected + 1) % app.items.len(); //app.status = format!("Selected: {}", app.items[app.selected]); app.status = "$".into(); } View::Library { items, selected, total, .. } => { if items.is_empty() { return; } // If we're at the end of what we have loaded… if *selected + 1 >= items.len() { // …and there’s more available on the server, fetch next page. if items.len() < *total { load_next_library_page(app); // After loading more, move down if possible. if let Some(View::Library { items, selected, .. }) = app.view_stack.last_mut() { if !items.is_empty() && *selected + 1 < items.len() { *selected += 1; } } } else { *selected = 0; } } else { *selected += 1; } maybe_prefetch(app); } _ => {} } } KeyCode::Char('/') => { let scope = match app.current_view() { View::Categories => SearchScope::All, View::Library { title, query, .. } => SearchScope::Library { title: title.clone(), query: query.clone(), }, _ => SearchScope::All, }; app.search = Some(SearchState { scope, mode: SearchMode::All, input: String::new(), }); } _ => {} } } fn load_next_library_page(app: &mut App) { // pull info + mark loading let (query, next_start, total, page_size) = match app.view_stack.last_mut() { Some(View::Library { query, items, total, start_index, loading, page_size, .. }) => { if *loading { return; } *loading = true; let loaded = items.len(); let next_start = *start_index + loaded; (query.clone(), next_start, *total, *page_size) } _ => return, }; let cfg = match &app.config { Some(c) => c.clone(), None => { app.status = "No config loaded".to_string(); finish_loading(app); return; } }; let user_id = match cfg.user_id.as_deref() { Some(u) => u, None => { app.status = "Missing user_id in config (login again)".to_string(); finish_loading(app); return; } }; let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { Ok(c) => c, Err(e) => { app.status = e; finish_loading(app); return; } }; let page = match client.list_items(user_id, query.clone(), next_start, page_size) { Ok(p) => p, Err(e) => { app.status = format!("Fetch failed: {e}"); finish_loading(app); return; } }; if let Some(View::Library { items, total, .. }) = app.view_stack.last_mut() { items.extend(page.items); *total = page.total; } finish_loading(app); } fn finish_loading(app: &mut App) { if let Some(View::Library { loading, .. }) = app.view_stack.last_mut() { *loading = false; } } fn maybe_prefetch(app: &mut App) { const PREFETCH_ROWS: usize = 0; let should = match app.view_stack.last() { Some(View::Library { items, selected, total, loading, .. }) => { if *loading { false } else if items.len() >= *total { false } else { let remaining_loaded = items.len().saturating_sub(*selected); remaining_loaded <= PREFETCH_ROWS } } _ => false, }; if should { load_next_library_page(app); } } fn ensure_loaded_to_end(app: &mut App) { loop { let done = match app.view_stack.last() { Some(View::Library { items, total, loading, .. }) => { !*loading && items.len() >= *total } _ => true, }; if done { break; } load_next_library_page(app); } } fn ticks_to_runtime_str(ticks: Option) -> Option { // Jellyfin ticks are 10,000,000 per second let t = ticks?; if t <= 0 { return None; } let total_seconds = (t as u64 / 10_000_000) as u64; let minutes = total_seconds / 60; let seconds = total_seconds % 60; if minutes >= 60 { let h = minutes / 60; let m = minutes % 60; Some(format!("{h}h {m}m")) } else { Some(format!("{minutes}m {seconds:02}s")) } } fn join_artists(artists: &[String]) -> String { let names: Vec = artists .iter() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); if names.is_empty() { "-".into() } else { names.join(", ") } } fn first_nonempty(opt: &Option) -> Option { opt.as_deref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|s| s.to_string()) } fn join_genres(genres: &[String]) -> String { if genres.is_empty() { "-".into() } else { genres.join(", ") } } fn join_studios(studios: &[core::jellyfin::JfStudio]) -> String { let names: Vec = studios .iter() .map(|s| s.name.clone()) .filter(|s| !s.trim().is_empty()) .collect(); if names.is_empty() { "-".into() } else { names.join(", ") } } fn year_str(y: Option) -> String { y.map(|v| v.to_string()).unwrap_or_else(|| "-".into()) } fn overview_str(o: &Option) -> String { o.as_deref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) .unwrap_or("-") .to_string() } fn format_details(it: &core::jellyfin::JfItem) -> String { let ty = it.item_type.as_deref().unwrap_or(""); match ty { "Series" => format!( "Name: {}\nYear: {}\nGenres: {}\nStudio: {}\n\nOverview:\n{}", it.name, year_str(it.production_year), join_genres(&it.genres), join_studios(&it.studios), overview_str(&it.overview), ), "Movie" => { let mut header = format!( "Name: {}\nYear: {}\nRuntime: {}\nGenres: {}\nStudio: {}", it.name, year_str(it.production_year), ticks_to_runtime_str(it.run_time_ticks).unwrap_or_else(|| "-".into()), join_genres(&it.genres), join_studios(&it.studios), ); if is_in_progress_fast(it) { let pos = playback_position_ticks(it).unwrap_or(0); let pos_secs = (pos / 10_000_000) as f64; header.push_str(&format!( "\nIn progress ({}): press r/R to resume.", fmt_hms(pos_secs) )); } format!( "{}\n\nOverview:\n{}", header, overview_str(&it.overview), ) } "Episode" => { let series = it.series_name.clone().unwrap_or_else(|| "-".into()); let se = match (it.parent_index_number, it.index_number) { (Some(s), Some(e)) => format!("{:02}x{:02}", s, e), _ => "-".into(), }; let mut header = format!( "Series: {}\nEpisode: {} {}\nYear: {}", series, se, it.name, year_str(it.production_year), ); if is_in_progress_fast(it) { let pos = playback_position_ticks(it).unwrap_or(0); let pos_secs = (pos / 10_000_000) as f64; header.push_str(&format!( "\nIn progress ({}): press r/R to resume.", fmt_hms(pos_secs) )); } format!( "{}\n\nOverview:\n{}", header, overview_str(&it.overview), ) } // --- Songs --- "Audio" => { let artist = first_nonempty(&it.album_artist) .or_else(|| it.artists.get(0).cloned()) .unwrap_or_else(|| "-".into()); let album = first_nonempty(&it.album).unwrap_or_else(|| "-".into()); format!( "Name: {}\nRuntime: {}\nArtist: {}\nAlbum: {}\nYear: {}", it.name, ticks_to_runtime_str(it.run_time_ticks).unwrap_or_else(|| "-".into()), artist, album, year_str(it.production_year), ) } // --- Albums --- "MusicAlbum" => { let artist = first_nonempty(&it.album_artist) .or_else(|| it.artists.get(0).cloned()) .unwrap_or_else(|| "-".into()); format!( "Name: {}\nRuntime: {}\nArtist: {}\nYear: {}", it.name, ticks_to_runtime_str(it.run_time_ticks).unwrap_or_else(|| "-".into()), artist, year_str(it.production_year), ) } // --- Artists --- "MusicArtist" => format!("Name: {}", it.name), _ => format!( "Name: {}\nId: {}\nType: {:?}\nYear: {:?}", it.name, it.id, it.item_type, it.production_year ), } } fn ui(frame: &mut Frame, app: &App) { let area = frame.area(); let panes = layout(area); // Top //frame.render_widget( // Paragraph::new("cj-jftui").block(Block::bordered()), // panes.top, //); let top_block = Block::bordered(); frame.render_widget(top_block.clone(), panes.top); let top_inner = top_block.inner(panes.top); let [top_l, top_r] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(40)]) .areas(top_inner); frame.render_widget(Paragraph::new("cj-jftui"), top_l); let server = app .config .as_ref() .map(|c| c.base_url.clone()) .unwrap_or_else(|| "-".into()); frame.render_widget( Paragraph::new(server) .alignment(ratatui::layout::Alignment::Right), top_r, ); // Main match app.current_view() { View::Categories => { render_categories(frame, app, panes.left_main, panes.right_main); } View::Library { title, query, items, selected, start_index, total, .. } => { let is_audio = query .include_item_types .as_deref() .map(|t| t.split(',').any(|x| x.trim() == "Audio")) .unwrap_or(false); let show_audio_track_numbers = is_audio && query.parent_id.is_some(); render_library( frame, app, title, *start_index, *total, items, *selected, panes.left_main, panes.right_main, show_audio_track_numbers, ); } View::Login => { render_login(frame, app, panes.left_main, panes.right_main); } } // Bottom Left // ---- Bottom Left: prompt / mpv ---- // Bottom Left: Search (temporary) > MPV (persistent) > "$" if let Some(s) = &app.search { let scope = match &s.scope { SearchScope::All => "All".to_string(), SearchScope::Library { title, .. } => title.clone(), }; let mode = s.mode.label(); let line = if s.input.is_empty() { format!("/ ({scope}) [{mode}]") } else { format!("/ ({scope}) [{mode}] {}", s.input) }; frame.render_widget( Paragraph::new(line).block(Block::bordered().merge_borders(MergeStrategy::Exact)), panes.left_output, ); } else if app.downloads_focus || app.downloads.as_ref().map(|d| d.state.lock().map(|s| !s.queue.is_empty()).unwrap_or(false)).unwrap_or(false) { render_downloads_panel(frame, app, panes.left_output); } else if app.mpv.is_some() { // mpv panel takes over the whole bottom-left area render_mpv_panel(frame, app, panes.left_output); } else { frame.render_widget( Paragraph::new("$").block(Block::bordered().merge_borders(MergeStrategy::Exact)), panes.left_output, ); } // ---- Bottom Right: always-on status/help ---- let connected = if app.config.as_ref().and_then(|c| c.access_token.as_ref()).is_some() { "✅ Connected" } else { "❌ Disconnected" }; let server = app .config .as_ref() .map(|c| c.base_url.as_str()) .unwrap_or("-"); let right_text = format!( "{connected}\n\np/P: play/autoplay r/R: resume/autoplay d: download / cancel q: quit /help: more help" ); frame.render_widget( Paragraph::new(right_text) .wrap(Wrap { trim: false }) .block(Block::bordered().merge_borders(MergeStrategy::Exact)), panes.right_output, ); } fn compute_window(len: usize, selected: usize, viewport: usize) -> (usize, usize) { if len == 0 || viewport == 0 { return (0, 0); } let viewport = viewport.min(len); let half = viewport / 2; let mut start = selected.saturating_sub(half); if start + viewport > len { start = len - viewport; } let end = start + viewport; (start, end) } fn render_categories(frame: &mut Frame, app: &App, left: Rect, right: Rect) { // how many rows can we show inside the bordered block? let viewport = left.height.saturating_sub(2) as usize; let len = app.items.len(); let selected = app.selected.min(len.saturating_sub(1)); let (start, end) = compute_window(len, selected, viewport); let items: Vec = app.items[start..end] .iter() .map(|s| ListItem::new(s.as_str())) .collect(); let list = List::new(items) .block(Block::bordered().title("Categories").merge_borders(MergeStrategy::Exact)) .highlight_symbol("> ") .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); let mut state = ListState::default(); state.select(Some(selected - start)); // selection relative to slice frame.render_stateful_widget(list, left, &mut state); // Right pane details (use the real selected item) frame.render_widget( Paragraph::new("") .block(Block::bordered().merge_borders(MergeStrategy::Exact)), right, ); } fn render_placeholder(frame: &mut Frame, name: &str, left: Rect, right: Rect) { frame.render_widget( Paragraph::new(format!("Inside {}\n\nPress Esc to go back", name)) .block(Block::bordered().title("Library").merge_borders(MergeStrategy::Exact)), left, ); frame.render_widget( Paragraph::new("Right pane reserved for item details / thumbnails") .block(Block::bordered().title("Details").merge_borders(MergeStrategy::Exact)), right, ); } fn render_library( frame: &mut Frame, app: &App, title: &str, start_index: usize, total: usize, items: &[core::jellyfin::JfItem], selected: usize, left: Rect, right: Rect, show_audio_track_numbers: bool, ) { let viewport = left.height.saturating_sub(2) as usize; let len = items.len(); let selected = selected.min(len.saturating_sub(1)); let (start, end) = compute_window(len, selected, viewport); fn show_year_for(item_type: Option<&str>) -> bool { matches!(item_type, Some("Movie" | "Series" | "MusicAlbum")) } fn left_number_prefix(it: &core::jellyfin::JfItem, show_audio_track_numbers: bool) -> String { match it.item_type.as_deref() { Some("Episode") => { match (it.parent_index_number, it.index_number) { (Some(season), Some(ep)) => format!("{:02}x{:02} ", season, ep), (_, Some(ep)) => format!("{:02} ", ep), _ => String::new(), } } Some("Audio") => { if !show_audio_track_numbers { return String::new(); } match it.index_number { Some(n) => format!("{:02}. ", n), None => String::new(), } } _ => String::new(), } } let list_items: Vec = items[start..end] .iter() .filter(|it| { // Hide the redundant "collections" folder inside the Collections library if title.ends_with("Collections") { if it.item_type.as_deref() == Some("Folder") && it.name.eq_ignore_ascii_case("collections") { return false; } } true }) .map(|it| { let emoji = emoji_prefix_for(app, it); let prefix = left_number_prefix(it, show_audio_track_numbers); let year = if show_year_for(it.item_type.as_deref()) { it.production_year.map(|y| format!(" ({y})")).unwrap_or_default() } else { String::new() }; ListItem::new(format!("{}{}{}{}", emoji, prefix, it.name, year)) }) .collect(); let list = List::new(list_items) .block( Block::bordered() .title(format!("{title} [{}/{}]", items.len(), total)) .merge_borders(MergeStrategy::Exact), ) .highlight_symbol("> ") .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); let mut state = ListState::default(); state.select(if len > 0 { Some(selected - start) } else { None }); frame.render_stateful_widget(list, left, &mut state); let detail = if len > 0 { let it = &items[selected]; format_details(it) } else { "No items returned".to_string() }; frame.render_widget( Paragraph::new(detail) .wrap(Wrap { trim: false }) .block(Block::bordered().title("Details").merge_borders(MergeStrategy::Exact)), right, ); } fn open_children(app: &mut App, title: String, query: core::jellyfin::LibraryQuery) { let cfg = match &app.config { Some(c) => c.clone(), None => { app.status = "No config loaded".into(); return; } }; let user_id = match cfg.user_id.as_deref() { Some(u) => u, None => { app.status = "Missing user_id in config (login again)".into(); return; } }; let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { Ok(c) => c, Err(e) => { app.status = e; return; } }; app.status = format!("Fetching {}...", title); match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) { Ok(page) => { app.view_stack.push(View::Library { title, query, items: page.items, selected: 0, start_index: 0, total: page.total, page_size: PAGE_SIZE, loading: false, }); app.status = "$".into(); } Err(e) => app.status = format!("Fetch failed: {e}"), } } fn mpv_socket_path() -> PathBuf { let mut p = std::env::temp_dir(); let pid = std::process::id(); p.push(format!("cj-jftui-mpv-{}.sock", pid)); p } fn mpv_request(socket: &PathBuf, json_line: &str) -> Result { let mut stream = UnixStream::connect(socket) .map_err(|e| format!("mpv ipc connect failed: {e}"))?; // Prevent UI hangs if mpv doesn't respond let _ = stream.set_write_timeout(Some(Duration::from_millis(40))); let _ = stream.set_read_timeout(Some(Duration::from_millis(40))); stream .write_all(json_line.as_bytes()) .map_err(|e| format!("mpv ipc write failed: {e}"))?; let mut reader = BufReader::new(stream); let mut line = String::new(); match reader.read_line(&mut line) { Ok(0) => Err("mpv ipc: EOF".to_string()), Ok(_) => Ok(line), Err(e) => { // timeout is normal; just treat it as "no data yet" if e.kind() == io::ErrorKind::WouldBlock || e.kind() == io::ErrorKind::TimedOut { Err("mpv ipc timeout".to_string()) } else { Err(format!("mpv ipc read failed: {e}")) } } } } fn mpv_get_bool(mpv: &MpvSession, prop: &str) -> Result { let resp = mpv_request( &mpv.socket_path, &format!(r#"{{"command":["get_property","{}"]}}\n"#, prop), )?; let v: Value = serde_json::from_str(resp.trim()) .map_err(|e| format!("mpv json parse failed: {e} ({})", resp.trim()))?; if v.get("error").and_then(|e| e.as_str()) != Some("success") { return Err(format!("mpv get_property failed: {}", resp.trim())); } Ok(v.get("data").and_then(|d| d.as_bool()).unwrap_or(false)) } fn mpv_get_f64(mpv: &MpvSession, prop: &str) -> Result, String> { let resp = mpv_request( &mpv.socket_path, &format!(r#"{{"command":["get_property","{}"]}}\n"#, prop), )?; let v: Value = serde_json::from_str(resp.trim()) .map_err(|e| format!("mpv json parse failed: {e} ({})", resp.trim()))?; if v.get("error").and_then(|e| e.as_str()) != Some("success") { return Err(format!("mpv get_property failed: {}", resp.trim())); } Ok(v.get("data").and_then(|d| d.as_f64())) } fn mpv_send(socket: &PathBuf, json_line: &str) -> Result<(), String> { let mut stream = UnixStream::connect(socket) .map_err(|e| format!("mpv ipc connect failed: {e}"))?; stream .write_all(json_line.as_bytes()) .map_err(|e| format!("mpv ipc write failed: {e}"))?; Ok(()) } fn mpv_cmd(socket: &PathBuf, cmd: &str) -> Result<(), String> { // cmd should already be valid JSON without trailing newline mpv_send(socket, &format!("{cmd}\n")) } fn ticks_to_seconds(ticks: u64) -> f64 { // Jellyfin ticks are 10,000,000 per second (ticks as f64) / 10_000_000.0 } fn mpv_seek_absolute(socket: &PathBuf, seconds: f64) -> Result<(), String> { // Wait until mpv has loaded something real (duration becomes available), // then issue a seek and REQUIRE a success reply. let seek_cmd = format!(r#"{{"command":["seek",{}, "absolute"]}}"#, seconds); let start = Instant::now(); while start.elapsed() < Duration::from_millis(2500) { // duration being Some is a good signal that the file is actually ready let dur = mpv_get_f64_jtui(socket, "duration", 1).ok().flatten(); if dur.is_some() { // seek, but only accept success if mpv_cmd_reply(socket, &seek_cmd).is_ok() { return Ok(()); } } thread::sleep(Duration::from_millis(50)); } Err("mpv seek failed (mpv never became ready or rejected seek)".into()) } fn spawn_mpv(cfg: &core::config::Config, first_item_id: &str) -> Result { let token = cfg .access_token .as_deref() .ok_or_else(|| "Missing access_token (login again)".to_string())?; let base = cfg.base_url.trim_end_matches('/'); let url = format!("{}/Items/{}/Download", base, first_item_id); let header = format!("X-Emby-Token: {}", token); let sock = mpv_socket_path(); // If a stale socket exists, remove it. let _ = std::fs::remove_file(&sock); let mut child = Command::new("mpv") .arg(url) .arg("--force-window=yes") .arg("--keep-open=yes") .arg("--idle=yes") .arg(format!("--http-header-fields={header}")) .arg(format!("--input-ipc-server={}", sock.display())) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .map_err(|e| format!("Failed to spawn mpv: {e}"))?; // Wait briefly for IPC socket to appear let start = Instant::now(); while start.elapsed() < Duration::from_millis(800) { if sock.exists() { return Ok(MpvSession { socket_path: sock, child, }); } thread::sleep(Duration::from_millis(10)); } // If socket never appeared, mpv probably died let _ = child.kill(); Err("mpv IPC socket did not appear".to_string()) } fn mpv_load_item(cfg: &core::config::Config, mpv: &MpvSession, item_id: &str) -> Result<(), String> { let base = cfg.base_url.trim_end_matches('/'); let url = format!("{}/Items/{}/Download", base, item_id); // loadfile replace mpv_cmd( &mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url), ) } fn mpv_cycle_audio(mpv: &MpvSession) -> Result<(), String> { mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","audio"]}"#) } fn mpv_cycle_subtitles(mpv: &MpvSession) -> Result<(), String> { mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","sub"]}"#) } fn mpv_toggle_subs(mpv: &MpvSession) -> Result<(), String> { mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","sub-visibility"]}"#) } fn mpv_stop(mpv: &mut MpvSession) { let _ = mpv_cmd(&mpv.socket_path, r#"{"command":["quit"]}"#); let _ = mpv.child.kill(); let _ = std::fs::remove_file(&mpv.socket_path); } fn mpv_get_prop_jtui(socket: &PathBuf, prop: &str, request_id: u32) -> Result { let mut stream = UnixStream::connect(socket) .map_err(|e| format!("mpv ipc connect failed: {e}"))?; let _ = stream.set_write_timeout(Some(Duration::from_millis(250))); let _ = stream.set_read_timeout(Some(Duration::from_millis(250))); let cmd = format!( r#"{{"command":["get_property","{}"],"request_id":{}}}"#, prop, request_id ); stream .write_all(format!("{cmd}\n").as_bytes()) .map_err(|e| format!("mpv ipc write failed: {e}"))?; // IMPORTANT: read a whole JSON line (track-list can be >1024 bytes) let mut reader = BufReader::new(stream); let mut line = String::new(); reader .read_line(&mut line) .map_err(|e| format!("mpv ipc read failed: {e}"))?; if line.trim().is_empty() { return Err("mpv ipc: empty response".into()); } let v: Value = serde_json::from_str(line.trim()) .map_err(|e| format!("mpv json parse failed: {e} ({})", line.trim()))?; if v.get("error").and_then(|e| e.as_str()) != Some("success") { return Err(format!("mpv get_property({prop}) failed: {v}")); } Ok(v.get("data").cloned().unwrap_or(Value::Null)) } fn selected_tracks_from_track_list(track_list: &Value) -> (Option, Option) { let arr = match track_list.as_array() { Some(a) => a, None => return (None, None), }; let mut audio: Option = None; let mut subs: Option = None; for t in arr { let ty = t.get("type").and_then(|x| x.as_str()).unwrap_or(""); let selected = t.get("selected").and_then(|x| x.as_bool()).unwrap_or(false); if !selected { continue; } match ty { "audio" => audio = Some(track_label_from_entry(t, "Audio")), "sub" => subs = Some(track_label_from_entry(t, "Sub")), _ => {} } } (audio, subs) } fn mpv_get_f64_jtui(socket: &PathBuf, prop: &str, request_id: u32) -> Result, String> { Ok(mpv_get_prop_jtui(socket, prop, request_id)?.as_f64()) } fn mpv_get_bool_jtui(socket: &PathBuf, prop: &str, request_id: u32) -> Result { Ok(mpv_get_prop_jtui(socket, prop, request_id)?.as_bool().unwrap_or(false)) } fn mpv_toggle_fullscreen(mpv: &MpvSession) -> Result<(), String> { // cycles fullscreen on/off mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","fullscreen"]}"#) // alternatively: // mpv_cmd(&mpv.socket_path, r#"{"command":["set_property","fullscreen","yes"]}"#) } fn mpv_cycle_pause(mpv: &MpvSession) -> Result<(), String> { mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","pause"]}"#) } fn autoplay_tick(app: &mut App) { if !app.autoplay { return; } // If user navigated away from the view where autoplay started, cancel it. if app.view_stack.len() != app.autoplay_view_depth { app.autoplay = false; app.autoplay_next_index = None; app.autoplay_eof_latched = false; app.autoplay_prev_time_pos = None; return; } // Snapshot latest mpv state from poller thread let st = app.mpv_state.clone(); // Optional: live debug in status (toggle with 'D') if app.debug_mpv { app.status = fmt_mpv_state(&st); } // Track previous time to detect "snapped back to 0" if let Some(tp) = st.time_pos { if tp > 0.25 { app.autoplay_prev_time_pos = Some(tp); } } let near_end = match (st.time_pos, st.duration) { (Some(tp), Some(d)) if d.is_finite() && d > 1.0 => (d - tp) <= 0.50, _ => false, }; let near_100 = match st.percent { Some(p) if p.is_finite() => p >= 99.8, _ => false, }; let snapped_to_start_idle = match (st.idle, st.time_pos, app.autoplay_prev_time_pos) { (true, Some(tp), Some(prev)) if prev > 1.0 && tp < 0.10 => true, _ => false, }; let finished = st.eof || near_end || near_100 || snapped_to_start_idle; // If not finished, clear latch so we can trigger when it *does* finish later. if !finished { app.autoplay_eof_latched = false; return; } // Only trigger once per finish event if app.autoplay_eof_latched { return; } app.autoplay_eof_latched = true; // Need mpv running to advance let mpv = match &app.mpv { Some(m) => m, None => return, }; let next_i = match app.autoplay_next_index { Some(i) => i, None => { app.autoplay = false; app.status = "$".into(); return; } }; let (next_id, next_name, len) = match app.current_view() { View::Library { items, .. } if next_i < items.len() => ( items[next_i].id.clone(), items[next_i].name.clone(), items.len(), ), _ => { app.autoplay = false; app.autoplay_next_index = None; app.status = "$".into(); return; } }; app.mpv_now_playing = Some(next_name); // Move selection highlight in UI if let Some(View::Library { selected, .. }) = app.view_stack.last_mut() { *selected = next_i; } app.autoplay_next_index = if next_i + 1 < len { Some(next_i + 1) } else { None }; let cfg = match app.config.clone() { Some(c) => c, None => { app.autoplay = false; app.autoplay_next_index = None; app.status = "No config".into(); return; } }; // Load next item let url = build_jellyfin_play_url(&cfg, &next_id); if let Err(e) = mpv_load_url(&cfg, mpv, &url) { app.autoplay = false; app.autoplay_next_index = None; app.status = format!("autoplay load failed: {e}"); return; } // Ensure playback resumes let _ = mpv_set_pause(mpv, false); // Reset tracking for new file app.autoplay_prev_time_pos = None; if !app.debug_mpv { app.status = "$".into(); } } fn mpv_set_pause(mpv: &MpvSession, paused: bool) -> Result<(), String> { // pause is a boolean property in mpv JSON IPC let v = if paused { "true" } else { "false" }; mpv_cmd( &mpv.socket_path, &format!(r#"{{"command":["set_property","pause",{}]}}"#, v), ) } fn fmt_mpv_state(st: &MpvState) -> String { let pct = match st.percent { Some(p) if p.is_finite() => p, _ => match (st.time_pos, st.duration) { (Some(t), Some(d)) if t.is_finite() && d.is_finite() && d > 0.0 => (t / d) * 100.0, _ => 0.0, }, }; format!("{pct:5.1}%") } fn start_mpv_poller(socket_path: PathBuf) -> mpsc::Receiver { let (tx, rx) = mpsc::channel::(); std::thread::spawn(move || { let mut request_id: u32 = 1; loop { let mut st = MpvState::default(); // jtui pattern: connect->write->read(1024)->parse->close (per property call) let tp = mpv_get_f64_jtui(&socket_path, "time-pos", request_id).ok().flatten(); request_id = request_id.wrapping_add(1); let dur = mpv_get_f64_jtui(&socket_path, "duration", request_id).ok().flatten(); request_id = request_id.wrapping_add(1); let percent_pos = mpv_get_f64_jtui(&socket_path, "percent-pos", request_id).ok().flatten(); request_id = request_id.wrapping_add(1); let eof = mpv_get_bool_jtui(&socket_path, "eof-reached", request_id).unwrap_or(false); request_id = request_id.wrapping_add(1); let idle = mpv_get_bool_jtui(&socket_path, "idle-active", request_id).unwrap_or(false); request_id = request_id.wrapping_add(1); let aid = mpv_get_prop_jtui(&socket_path, "aid", request_id).ok().and_then(|v| v.as_i64()); request_id = request_id.wrapping_add(1); let sid = mpv_get_prop_jtui(&socket_path, "sid", request_id).ok().and_then(|v| v.as_i64()); request_id = request_id.wrapping_add(1); let track_list = mpv_get_prop_jtui(&socket_path, "track-list", request_id).ok(); request_id = request_id.wrapping_add(1); let (audio, subs) = match track_list.as_ref() { Some(v) => selected_tracks_from_track_list(v), None => (None, None), }; let media_title = mpv_get_prop_jtui(&socket_path, "media-title", request_id) .ok() .and_then(|v| v.as_str().map(|s| s.to_string())); request_id = request_id.wrapping_add(1); st.time_pos = tp; st.duration = dur; st.eof = eof; st.idle = idle; st.media_title = media_title; st.audio = audio; st.subs = subs; // compute percent yourself (jtui style) st.percent = percent_pos .filter(|p| p.is_finite()) .or_else(|| match (tp, dur) { (Some(t), Some(d)) if t.is_finite() && d.is_finite() && d > 0.0 => Some((t / d) * 100.0), _ => None, }); // Optional: populate last_err if we got nothing useful if st.time_pos.is_none() && st.duration.is_none() { st.last_err = Some("mpv poll: no time-pos/duration (check socket / mpv running)".into()); } if tx.send(st).is_err() { break; } std::thread::sleep(Duration::from_millis(200)); } }); rx } fn spawn_mpv_url(cfg: &core::config::Config, url: &str) -> Result { let token = cfg .access_token .as_deref() .ok_or_else(|| "Missing access_token (login again)".to_string())?; let header = format!("X-Emby-Token: {}", token); let sock = mpv_socket_path(); let _ = std::fs::remove_file(&sock); let mut child = Command::new("mpv") .arg(url) .arg("--force-window=yes") .arg("--keep-open=yes") .arg("--idle=yes") .arg(format!("--http-header-fields={header}")) .arg(format!("--input-ipc-server={}", sock.display())) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .map_err(|e| format!("Failed to spawn mpv: {e}"))?; let start = Instant::now(); while start.elapsed() < Duration::from_millis(800) { if sock.exists() { return Ok(MpvSession { socket_path: sock, child }); } thread::sleep(Duration::from_millis(10)); } let _ = child.kill(); Err("mpv IPC socket did not appear".to_string()) } fn mpv_load_url(cfg: &core::config::Config, mpv: &MpvSession, url: &str) -> Result<(), String> { // mpv already has the http header from spawn flags let _ = cfg; // keep signature symmetric; might be useful later mpv_cmd( &mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url), ) } fn track_label(title: Option<&str>, lang: Option<&str>, id: Option, kind: &str) -> String { let mut parts: Vec = Vec::new(); if let Some(t) = title.map(|s| s.trim()).filter(|s| !s.is_empty()) { parts.push(t.to_string()); } if let Some(l) = lang.map(|s| s.trim()).filter(|s| !s.is_empty()) { parts.push(l.to_string()); } if parts.is_empty() { if let Some(i) = id { return format!("{kind} {i}"); } return "-".into(); } parts.join(" • ") } fn track_label_from_entry(t: &serde_json::Value, kind: &str) -> String { let title = t.get("title").and_then(|x| x.as_str()).map(str::trim).filter(|s| !s.is_empty()); let lang = t.get("lang").and_then(|x| x.as_str()).map(str::trim).filter(|s| !s.is_empty()); // audio extras are often useful (codec/channels) let codec = t.get("codec").and_then(|x| x.as_str()).map(str::trim).filter(|s| !s.is_empty()); let ch = t.get("channels").and_then(|x| x.as_str()).map(str::trim).filter(|s| !s.is_empty()); let mut parts: Vec = Vec::new(); if let Some(v) = title { parts.push(v.to_string()); } if let Some(v) = lang { parts.push(v.to_string()); } if kind == "Audio" { if let Some(v) = codec { parts.push(v.to_string()); } if let Some(v) = ch { parts.push(v.to_string()); } } if parts.is_empty() { "-".into() } else { parts.join(" • ") } } fn tracks_from_ids(track_list: &serde_json::Value, aid: Option, sid: Option) -> (Option, Option) { let arr = match track_list.as_array() { Some(a) => a, None => return (None, None), }; let mut audio: Option = None; let mut subs: Option = None; for t in arr { let ty = t.get("type").and_then(|x| x.as_str()).unwrap_or(""); let id = t.get("id").and_then(|x| x.as_i64()); match (ty, id) { ("audio", Some(id)) if Some(id) == aid => { audio = Some(track_label_from_entry(t, "Audio")); } ("sub", Some(id)) if Some(id) == sid => { subs = Some(track_label_from_entry(t, "Sub")); } _ => {} } } (audio, subs) } fn fmt_hms(t: f64) -> String { if !t.is_finite() || t < 0.0 { return "--:--".into(); } let total = t as u64; let h = total / 3600; let m = (total % 3600) / 60; let s = total % 60; if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") } } fn mpv_progress(st: &MpvState) -> (u16, String) { let pct = st.percent.unwrap_or(0.0).clamp(0.0, 100.0); let p_u16 = pct.round() as u16; let cur = st.time_pos.map(fmt_hms).unwrap_or("--:--".into()); let dur = st.duration.map(fmt_hms).unwrap_or("--:--".into()); (p_u16, format!("{cur} / {dur} ({pct:0.1}%)")) } fn render_downloads_panel(frame: &mut Frame, app: &App, area: Rect) { let title = if app.downloads_focus { "Downloads (focus)" } else { "Downloads" }; // rows available inside the border let viewport = area.height.saturating_sub(2) as usize; let mut rows: Vec = Vec::new(); let mut state = ListState::default(); // Helper: "basic" scrolling window (no centering) // Ensures selected is always visible within [start, start+viewport) fn basic_window(len: usize, selected: usize, viewport: usize) -> (usize, usize) { if len == 0 || viewport == 0 { return (0, 0); } let viewport = viewport.min(len); let mut start = 0usize; // If selected is below the visible window, scroll down just enough if selected >= start + viewport { start = selected + 1 - viewport; } // If selected is above the visible window, scroll up just enough if selected < start { start = selected; } // Clamp if start + viewport > len { start = len - viewport; } (start, start + viewport) } // Empty states if app.downloads.is_none() { rows.push(ListItem::new("(downloads disabled)")); state.select(None); let list = List::new(rows) .block(Block::bordered().title(title).merge_borders(MergeStrategy::Exact)) .highlight_symbol(">> ") .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); frame.render_stateful_widget(list, area, &mut state); return; } let dm = app.downloads.as_ref().unwrap(); match dm.state.lock() { Err(_) => { rows.push(ListItem::new("(downloads lock poisoned)")); state.select(None); } Ok(st) => { let len = st.queue.len(); if len == 0 { rows.push(ListItem::new("(empty)")); state.select(None); } else { // Clamp selection let selected = app.downloads_selected.min(len.saturating_sub(1)); // Basic scrolling window let (start, end) = basic_window(len, selected, viewport); for i in start..end { let it = match st.queue.get(i) { Some(v) => v, None => continue, }; let status = match &it.status { DownloadStatus::Queued => "queued", DownloadStatus::Downloading => "downloading", DownloadStatus::Finalizing => "finalizing", DownloadStatus::Completed => "done", DownloadStatus::Cancelled => "cancelled", DownloadStatus::Failed(_) => "failed", }; let active_prefix = if i == 0 { "▶ " } else { " " }; // Prefer aria2-derived info if you have it; otherwise it will show --% (--:--) let prog_prefix = if matches!(it.status, DownloadStatus::Downloading) { // uses the fields you now populate from aria2 RPC // (bytes_downloaded, bytes_total, eta_secs) let pct_str = match it.bytes_total { Some(t) if t > 0 => { let pct = (it.bytes_downloaded as f64 / t as f64) * 100.0; format!("{:0.0}%", pct.clamp(0.0, 100.0)) } _ => "--%".to_string(), }; let eta_str = match it.eta_secs { Some(s) => fmt_hms(s as f64), None => "--:--".to_string(), }; format!("{pct_str} ({eta_str}) ") } else { String::new() }; rows.push(ListItem::new(format!( "{active_prefix}{status}: {prog_prefix}{}", it.title ))); } // selection must be relative to the window slice state.select(Some(selected - start)); } } } let list = List::new(rows) .block(Block::bordered().title(title).merge_borders(MergeStrategy::Exact)) .highlight_symbol(">> ") .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); frame.render_stateful_widget(list, area, &mut state); } // Returns e.g. "56% (4:32) " // If total is unknown, returns "--% (--:--) " fn format_download_pct_eta(downloaded: u64, total: Option, added_at: Instant) -> String { // Percent let pct_str = match total { Some(t) if t > 0 => { let pct = (downloaded as f64 / t as f64) * 100.0; format!("{:0.0}%", pct.clamp(0.0, 100.0)) } _ => "--%".to_string(), }; // ETA from average speed since added_at (good enough for UI) let elapsed = added_at.elapsed().as_secs_f64(); let speed = if elapsed > 0.0 { downloaded as f64 / elapsed } else { 0.0 }; let eta_str = match (total, speed) { (Some(t), s) if t > downloaded && s > 1.0 => { let remaining = (t - downloaded) as f64; let eta_secs = remaining / s; fmt_hms(eta_secs) } _ => "--:--".to_string(), }; format!("{pct_str} ({eta_str}) ") } fn render_mpv_panel(frame: &mut Frame, app: &App, area: Rect) { // Make the bar exactly 1 row and don't depend on bordered blocks. let [c0, c1, c2, c3, c4, c5] = Layout::vertical([ Constraint::Length(1), // title (no border) Constraint::Length(1), // audio Constraint::Length(1), // subs Constraint::Length(1), // BAR (raw) Constraint::Length(1), // time Constraint::Min(1), // controls ]) .areas(area); let st = &app.mpv_state; let title = app .mpv_now_playing .clone() .or_else(|| st.media_title.clone()) .unwrap_or_else(|| "-".into()); let audio = st.audio.clone().unwrap_or_else(|| "-".into()); let subs = st.subs.clone().unwrap_or_else(|| "None".into()); frame.render_widget(Paragraph::new(format!("▶ {title}")), c0); frame.render_widget(Paragraph::new(format!("Audio: {audio}")), c1); frame.render_widget(Paragraph::new(format!("Subs: {subs}")), c2); let ratio = (st.percent.unwrap_or(0.0) / 100.0).clamp(0.0, 1.0); let pct = st.percent.unwrap_or(0.0); // reserve 1 col so we don't draw into the overlap w/ right pane border let safe_w = c3.width.saturating_sub(1); let bar_line = mpv_bar_line(safe_w, ratio, pct); frame.render_widget(Paragraph::new(bar_line), c3); let cur_secs = st.time_pos.unwrap_or(0.0); let dur_secs = st.duration.unwrap_or(0.0); let cur = st.time_pos.map(fmt_hms).unwrap_or("--:--".into()); let dur = st.duration.map(fmt_hms).unwrap_or("--:--".into()); let remaining = if dur_secs > 0.0 && cur_secs <= dur_secs { fmt_hms(dur_secs - cur_secs) } else { "--:--".into() }; frame.render_widget( Paragraph::new(format!("{cur} / {dur} ({remaining} remaining)")), c4, ); let controls = "Space: pause x: stop f: fullscreen\na: audio s: subs v: sub vis"; frame.render_widget(Paragraph::new(controls).wrap(Wrap { trim: true }), c5); } fn mpv_bar_line(width: u16, ratio: f64, pct: f64) -> String { let w = width as usize; if w == 0 { return String::new(); } let prefix = "mpv "; let pct_text = format!("{:>5.1}%", pct.clamp(0.0, 100.0)); // " 43.3%" let pct_area_min = pct_text.len() + 2; // breathing room around percent // If too narrow, just show prefix+percent if w <= prefix.len() + pct_text.len() { return format!("{prefix}{pct_text}"); } let avail = w - prefix.len(); // Give pct a fixed-ish area if we can, but keep at least 2 cols for the bar: "[]" let pct_area = pct_area_min.min(avail.saturating_sub(2)); let bar_w = (avail - pct_area).max(2); let bar = unicode_progress_bar(bar_w, ratio); // IMPORTANT: compute remaining space using bar_w (columns), not bar.len() (bytes) let pct_area_actual = avail.saturating_sub(bar_w); let mut pct_buf = vec![' '; pct_area_actual]; if pct_area_actual >= pct_text.len() { let start = (pct_area_actual - pct_text.len()) / 2; for (i, ch) in pct_text.chars().enumerate() { pct_buf[start + i] = ch; } } let pct_field: String = pct_buf.into_iter().collect(); format!("{prefix}{bar}{pct_field}") } fn unicode_progress_bar(width: usize, ratio: f64) -> String { // width includes the brackets: [ .... ] if width < 2 { return String::new(); } let inner = width - 2; let ratio = ratio.clamp(0.0, 1.0); // 8-level partial blocks // 0..=7 maps to: (none), ▏▎▍▌▋▊▉ const PARTS: [char; 8] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉']; let total_units = inner * 8; let filled_units = (ratio * total_units as f64).round() as usize; let full = filled_units / 8; let rem = filled_units % 8; let mut s = String::with_capacity(width); s.push('['); // full blocks let full = full.min(inner); s.push_str(&"█".repeat(full)); // partial block (if any room left) if full < inner { if rem > 0 { s.push(PARTS[rem]); } else { s.push(' '); } // rest empty let used = full + 1; if used < inner { s.push_str(&" ".repeat(inner - used)); } } s.push(']'); s } fn is_music_item(it: &core::jellyfin::JfItem) -> bool { matches!(it.item_type.as_deref(), Some("Audio" | "MusicAlbum" | "MusicArtist")) } fn is_downloaded(app: &App, it: &core::jellyfin::JfItem) -> bool { // Basic: only exact item-id matches. // (No container inference for Series/Season/Album right now.) app.downloaded_ids.contains(&it.id) } fn is_played(it: &core::jellyfin::JfItem) -> bool { if it.user_data.as_ref().and_then(|u| u.played) == Some(true) { return true; } // Jellyfin often provides this for Season/Series if matches!(it.item_type.as_deref(), Some("Season" | "Series")) { if it.user_data.as_ref().and_then(|u| u.unplayed_item_count) == Some(0) { return true; } } if let Some(p) = it.user_data.as_ref().and_then(|u| u.played_percentage) { return p >= 99.9; } false } fn playback_position_ticks(it: &core::jellyfin::JfItem) -> Option { it.user_data .as_ref() .and_then(|u| u.playback_position_ticks) .and_then(|v| if v > 0 { Some(v as u64) } else { None }) } fn is_in_progress_fast(it: &core::jellyfin::JfItem) -> bool { // Already fully played? Never in-progress. if is_played(it) { return false; } match it.item_type.as_deref() { // Movies / Episodes → resume ticks mean in-progress Some("Movie" | "Episode") => { playback_position_ticks(it).is_some() } // Seasons → jtui-style fast logic Some("Season") => { // 1) Prefer server percentage if present if let Some(p) = it.user_data .as_ref() .and_then(|u| u.played_percentage) { if p > 0.1 && p < 99.9 { return true; } } // 2) Fallback to count-based detection: // 0 < UnplayedItemCount < ChildCount let total = it.child_count .and_then(|v| if v > 0 { Some(v as u64) } else { None }); let unplayed = it.user_data .as_ref() .and_then(|u| u.unplayed_item_count) .and_then(|v| if v >= 0 { Some(v as u64) } else { None }); match (total, unplayed) { (Some(t), Some(u)) if t > 0 && u > 0 && u < t => true, _ => false, } } // Series → complete or nothing (as you requested) Some("Series") => false, _ => false, } } fn emoji_prefix_for(app: &App, it: &core::jellyfin::JfItem) -> String { // Keep your existing behavior: no emojis for music items (optional). // If you DO want 💾 for music too, remove this early return. if is_music_item(it) { return String::new(); } let downloaded = is_downloaded(app, it); let played = is_played(it); let inprog = is_in_progress_fast(it); let mut s = String::new(); if downloaded { s.push_str("💾 "); } if played { s.push_str("✅ "); } else if inprog { s.push_str("🟠 "); } s } /* fn is_in_progress(it: &core::jellyfin::JfItem) -> bool { if is_played(it) { return false; } match it.item_type.as_deref() { // Movies/Episodes: only if resume ticks exist Some("Movie" | "Episode") => playback_position_ticks(it).is_some(), // Seasons: percent-based Some("Season") => it.user_data .as_ref() .and_then(|u| u.played_percentage) .is_some_and(|p| p > 0.1 && p < 99.9), // Series: no in-progress marker (your requirement) Some("Series") => false, _ => false, } } */ // --- transcoding + resume URL builder (Approach B) --- fn clamp_mbps_to_bps(mbps: u32) -> u32 { mbps.saturating_mul(1_000_000) // Mb/s -> bits/s } fn build_jellyfin_play_url(cfg: &core::config::Config, item_id: &str) -> String { let base = cfg.base_url.trim_end_matches('/'); format!("{}/Items/{}/Download", base, item_id) } fn mpv_cmd_reply(socket: &PathBuf, cmd: &str) -> Result { let mut stream = UnixStream::connect(socket) .map_err(|e| format!("mpv ipc connect failed: {e}"))?; let _ = stream.set_write_timeout(Some(Duration::from_millis(500))); let _ = stream.set_read_timeout(Some(Duration::from_millis(500))); stream .write_all(format!("{cmd}\n").as_bytes()) .map_err(|e| format!("mpv ipc write failed: {e}"))?; let mut reader = BufReader::new(stream); let mut line = String::new(); reader .read_line(&mut line) .map_err(|e| format!("mpv ipc read failed: {e}"))?; if line.trim().is_empty() { return Err("mpv ipc: empty response".into()); } let v: Value = serde_json::from_str(line.trim()) .map_err(|e| format!("mpv json parse failed: {e} ({})", line.trim()))?; if v.get("error").and_then(|e| e.as_str()) != Some("success") { return Err(format!("mpv command failed: {}", line.trim())); } Ok(v) } struct Panes { top: Rect, left_main: Rect, right_main: Rect, left_output: Rect, right_output: Rect, } fn layout(area: Rect) -> Panes { use Constraint::Fill; let [top, bottom] = Layout::vertical([Fill(1), Fill(12)]).areas(area); let [main_area, output_area] = Layout::vertical([Fill(9), Fill(3)]) .spacing(Spacing::Overlap(1)) .areas(bottom); let [left_main, right_main] = Layout::horizontal([Fill(1), Fill(1)]) .spacing(Spacing::Overlap(1)) .areas(main_area); let [left_output, right_output] = Layout::horizontal([Fill(7), Fill(2)]) .spacing(Spacing::Overlap(1)) .areas(output_area); Panes { top, left_main, right_main, left_output, right_output, } } fn render_login(frame: &mut Frame, app: &App, left: Rect, right: Rect) { let focus = match app.login_focus { LoginFocus::User => "username", LoginFocus::Pass => "password", }; let masked = "*".repeat(app.login_pass.len()); let left_text = format!( "Login (Tab switch, Enter submit)\n\nusername: {}\npassword: {}\n\nfocus: {}\n", app.login_user, masked, focus ); frame.render_widget( Paragraph::new(left_text) .block(Block::bordered().title("Login").merge_borders(MergeStrategy::Exact)), left, ); let right_text = match &app.login_error { Some(e) => format!("Error:\n{e}"), None => "Enter Jellyfin credentials.\nToken will be stored in config.toml.".to_string(), }; frame.render_widget( Paragraph::new(right_text) .block(Block::bordered().title("Info").merge_borders(MergeStrategy::Exact)), right, ); }