3146 lines
103 KiB
Rust
3146 lines
103 KiB
Rust
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<core::jellyfin::JfItem>,
|
||
selected: usize,
|
||
start_index: usize,
|
||
total: usize,
|
||
page_size: usize,
|
||
loading: bool,
|
||
},
|
||
OfflineLibrary {
|
||
title: String,
|
||
items: Vec<core::downloads::LocalEntry>,
|
||
selected: usize,
|
||
},
|
||
Login,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default)]
|
||
struct MpvState {
|
||
eof: bool,
|
||
idle: bool,
|
||
time_pos: Option<f64>,
|
||
duration: Option<f64>,
|
||
percent: Option<f64>,
|
||
last_err: Option<String>,
|
||
|
||
// new:
|
||
media_title: Option<String>,
|
||
audio: Option<String>,
|
||
subs: Option<String>,
|
||
}
|
||
|
||
#[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<View>,
|
||
|
||
items: Vec<String>,
|
||
selected: usize,
|
||
|
||
config: Option<core::config::Config>,
|
||
login_user: String,
|
||
login_pass: String,
|
||
login_focus: LoginFocus,
|
||
login_error: Option<String>,
|
||
offline: bool,
|
||
|
||
search: Option<SearchState>,
|
||
mpv: Option<MpvSession>,
|
||
mpv_poll_last: Instant,
|
||
mpv_state: MpvState,
|
||
mpv_state_rx: Option<mpsc::Receiver<MpvState>>,
|
||
debug_mpv: bool,
|
||
mpv_now_playing: Option<String>,
|
||
downloads: Option<downloads::DownloadManager>,
|
||
downloads_focus: bool,
|
||
downloads_selected: usize,
|
||
downloaded_ids: HashSet<String>,
|
||
last_download_message: Option<String>,
|
||
|
||
autoplay: bool,
|
||
autoplay_view_depth: usize,
|
||
autoplay_next_index: Option<usize>,
|
||
autoplay_eof_latched: bool,
|
||
autoplay_prev_time_pos: Option<f64>,
|
||
}
|
||
|
||
#[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, offline) = match &loaded {
|
||
Some(cfg) if cfg.access_token.is_some() => {
|
||
let online = core::jellyfin::JellyfinClient::ping_base_url(&cfg.base_url);
|
||
if online {
|
||
(vec![View::Categories], format!("✅ Online: ({})", cfg.base_url), false)
|
||
} else {
|
||
(vec![View::Categories], format!("📴 Offline: ({})", cfg.base_url), true)
|
||
}
|
||
}
|
||
Some(cfg) => {
|
||
// no token: treat as "not connected", but still allow offline browsing
|
||
let online = core::jellyfin::JellyfinClient::ping_base_url(&cfg.base_url);
|
||
if online {
|
||
(vec![View::Login], format!("Not Connected: ({})", cfg.base_url), false)
|
||
} else {
|
||
(vec![View::Categories], format!("📴 Offline (no token): ({})", cfg.base_url), true)
|
||
}
|
||
}
|
||
None => (vec![View::Login], "Missing Configuration".to_string(), false),
|
||
};
|
||
|
||
Self {
|
||
should_exit: false,
|
||
status: String::new(),
|
||
config_status,
|
||
config: loaded,
|
||
|
||
view_stack,
|
||
offline,
|
||
|
||
items: categories_for_mode(offline),
|
||
|
||
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<CrosstermBackend<io::Stdout>>) -> 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;
|
||
}
|
||
// Toggle offline/online mode.
|
||
// - If currently online -> go offline immediately (latched until toggled back).
|
||
// - If currently offline -> ping server; only switch to online if ping succeeds.
|
||
if matches!(code, KeyCode::Char('o')) {
|
||
let base_url = app
|
||
.config
|
||
.as_ref()
|
||
.map(|c| c.base_url.clone())
|
||
.unwrap_or_default();
|
||
|
||
if !app.offline {
|
||
// Online -> Offline (no ping needed)
|
||
app.offline = true;
|
||
app.items = categories_for_mode(true);
|
||
app.selected = 0;
|
||
app.view_stack = vec![View::Categories];
|
||
|
||
// stop autoplay if it was running (offline shouldn't try to advance via network)
|
||
app.autoplay = false;
|
||
app.autoplay_next_index = None;
|
||
app.autoplay_eof_latched = false;
|
||
|
||
app.status = "📴 Offline mode enabled".into();
|
||
return;
|
||
}
|
||
|
||
// Offline -> Online (must confirm server reachable)
|
||
if base_url.is_empty() {
|
||
app.status = "Cannot go online: missing base_url in config".into();
|
||
return;
|
||
}
|
||
|
||
let ok = core::jellyfin::JellyfinClient::ping_base_url(&base_url);
|
||
if ok {
|
||
app.offline = false;
|
||
app.items = categories_for_mode(false);
|
||
app.selected = 0;
|
||
app.view_stack = vec![View::Categories];
|
||
app.status = "✅ Online mode enabled".into();
|
||
} else {
|
||
app.status = "Still offline: server not reachable".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())
|
||
}
|
||
View::OfflineLibrary { items, selected, .. } if !items.is_empty() => {
|
||
(items[*selected].item_id.clone(), items[*selected].title.clone())
|
||
}
|
||
_ => { app.status = "Nothing to play here".into(); return; }
|
||
};
|
||
|
||
app.mpv_now_playing = Some(item_name);
|
||
|
||
// If mpv running -> replace, else spawn
|
||
let url = if let Some(p) = crate::core::downloads::local_media_path_for_item_id(&item_id) {
|
||
p.to_string_lossy().to_string()
|
||
} else {
|
||
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') => {
|
||
if matches!(app.current_view(), View::OfflineLibrary { .. }) {
|
||
app.status = "Offline: autoplay disabled (use 'p')".into();
|
||
return;
|
||
}
|
||
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 = if let Some(p) = crate::core::downloads::local_media_path_for_item_id(&item_id) {
|
||
p.to_string_lossy().to_string()
|
||
} else {
|
||
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();
|
||
|
||
// OFFLINE: local-only library
|
||
if app.offline {
|
||
if cat == "Downloaded" {
|
||
let items = crate::core::downloads::scan_local_entries();
|
||
|
||
app.view_stack.push(View::OfflineLibrary {
|
||
title: "Downloaded".into(),
|
||
items,
|
||
selected: 0,
|
||
});
|
||
app.status = "$".into();
|
||
return;
|
||
}
|
||
|
||
// Everything else offline can remain as-is (Downloads/Settings etc)
|
||
}
|
||
|
||
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"),
|
||
|
||
// NEW
|
||
"Collections" => core::jellyfin::LibraryQuery::all()
|
||
.with_item_types("BoxSet"),
|
||
|
||
"Continue Watching" => core::jellyfin::LibraryQuery::all()
|
||
.with_item_types("Episode,Movie")
|
||
.with_filters("IsResumable")
|
||
.with_sort_by("DatePlayed")
|
||
.with_sort_order("Descending"),
|
||
|
||
"Recently Added" => core::jellyfin::LibraryQuery::all()
|
||
.with_item_types("Movie,Series")
|
||
.with_sort_by("DateCreated,SortName")
|
||
.with_sort_order("Descending"),
|
||
|
||
_ => { 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);
|
||
}
|
||
View::OfflineLibrary { items, selected, .. } => {
|
||
if items.is_empty() { return; }
|
||
if *selected == 0 { *selected = items.len() - 1; }
|
||
else { *selected -= 1; }
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
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);
|
||
}
|
||
View::OfflineLibrary { items, selected, .. } => {
|
||
if items.is_empty() { return; }
|
||
*selected = (*selected + 1) % items.len();
|
||
}
|
||
|
||
_ => {}
|
||
}
|
||
}
|
||
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<i64>) -> Option<String> {
|
||
// 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<String> = 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<String>) -> Option<String> {
|
||
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<String> = 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<i32>) -> String {
|
||
y.map(|v| v.to_string()).unwrap_or_else(|| "-".into())
|
||
}
|
||
|
||
fn overview_str(o: &Option<String>) -> 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::OfflineLibrary { title, items, selected } => {
|
||
render_offline_library(frame, title, items, *selected, panes.left_main, panes.right_main);
|
||
}
|
||
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.offline {
|
||
"📴 Offline"
|
||
} else 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<ListItem> = 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<ListItem> = 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<String, String> {
|
||
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<bool, 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_bool()).unwrap_or(false))
|
||
}
|
||
|
||
fn mpv_get_f64(mpv: &MpvSession, prop: &str) -> Result<Option<f64>, 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<MpvSession, String> {
|
||
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<Value, String> {
|
||
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<String>, Option<String>) {
|
||
let arr = match track_list.as_array() {
|
||
Some(a) => a,
|
||
None => return (None, None),
|
||
};
|
||
|
||
let mut audio: Option<String> = None;
|
||
let mut subs: Option<String> = 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<Option<f64>, 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<bool, String> {
|
||
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 (prefer local if available)
|
||
let url = if let Some(p) = crate::core::downloads::local_media_path_for_item_id(&next_id) {
|
||
p.to_string_lossy().to_string()
|
||
} else {
|
||
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<MpvState> {
|
||
let (tx, rx) = mpsc::channel::<MpvState>();
|
||
|
||
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<MpvSession, String> {
|
||
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<i64>, kind: &str) -> String {
|
||
let mut parts: Vec<String> = 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<String> = 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<i64>, sid: Option<i64>) -> (Option<String>, Option<String>) {
|
||
let arr = match track_list.as_array() {
|
||
Some(a) => a,
|
||
None => return (None, None),
|
||
};
|
||
let mut audio: Option<String> = None;
|
||
let mut subs: Option<String> = 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<ListItem> = 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<u64>, 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<u64> {
|
||
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 {
|
||
// Music: show only the local-download indicator (💾) and never show played/in-progress.
|
||
// Everything else keeps the existing behavior (💾 + ✅/🟠).
|
||
let downloaded = is_downloaded(app, it);
|
||
|
||
if is_music_item(it) {
|
||
return if downloaded { "💾 ".to_string() } else { String::new() };
|
||
}
|
||
|
||
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<Value, String> {
|
||
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,
|
||
);
|
||
}
|
||
|
||
fn render_offline_library(
|
||
frame: &mut Frame,
|
||
title: &str,
|
||
items: &[core::downloads::LocalEntry],
|
||
selected: usize,
|
||
left: Rect,
|
||
right: Rect,
|
||
) {
|
||
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);
|
||
|
||
let list_items: Vec<ListItem> = items[start..end]
|
||
.iter()
|
||
.map(|it| ListItem::new(format!("💾 {}", it.title)))
|
||
.collect();
|
||
|
||
let list = List::new(list_items)
|
||
.block(
|
||
Block::bordered()
|
||
.title(format!("{title} [{}/{}]", items.len(), items.len()))
|
||
.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!("Title: {}\nItemId: {}\nPath:\n{}", it.title, it.item_id, it.path.display())
|
||
} else {
|
||
"No downloaded items found".to_string()
|
||
};
|
||
|
||
frame.render_widget(
|
||
Paragraph::new(detail)
|
||
.wrap(Wrap { trim: false })
|
||
.block(Block::bordered().title("Details").merge_borders(MergeStrategy::Exact)),
|
||
right,
|
||
);
|
||
}
|
||
|
||
fn categories_for_mode(offline: bool) -> Vec<String> {
|
||
if offline {
|
||
vec![
|
||
"Downloaded".into(),
|
||
"Settings".into(),
|
||
]
|
||
} else {
|
||
vec![
|
||
"Shows".into(),
|
||
"Movies".into(),
|
||
"Music".into(),
|
||
"Playlists".into(),
|
||
"Collections".into(),
|
||
"Libraries".into(),
|
||
"Continue Watching".into(),
|
||
"Recently Added".into(),
|
||
"Settings".into(),
|
||
]
|
||
}
|
||
}
|