Files
CjTui/old_src/main.rs

2613 lines
83 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

mod core;
const PAGE_SIZE: usize = 50;
use std::{
io::{self, Write, Read, BufRead, BufReader},
path::PathBuf,
process::{Child, Command, Stdio},
time::{Duration, Instant},
thread,
};
use std::sync::{Arc, Mutex, mpsc};
use std::os::unix::net::UnixStream;
use serde_json::Value;
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},
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,
},
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>,
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>,
autoplay: bool,
autoplay_view_depth: usize,
autoplay_next_index: Option<usize>,
autoplay_eof_latched: bool,
autoplay_prev_time_pos: Option<f64>,
transcode_on: bool,
transcode_mbps: u32,
}
#[derive(Debug)]
struct MpvSession {
socket_path: PathBuf,
child: Child,
}
impl Default for App {
fn default() -> Self {
let loaded = core::config::load_config().ok();
let default_mbps = loaded
.as_ref()
.and_then(|c| c.transcode_mbps)
.unwrap_or(5);
let default_on = loaded
.as_ref()
.and_then(|c| c.transcode_default_on)
.unwrap_or(false);
let (view_stack, config_status) = match &loaded {
Some(cfg) if cfg.access_token.is_some() => (
vec![View::Categories],
format!("Connected: ({})", cfg.base_url),
),
Some(cfg) => (
vec![View::Login],
format!("Not Connected: ({})", cfg.base_url),
),
None => (
vec![View::Login],
"Missing Configuration".to_string(),
),
};
Self {
should_exit: false,
status: String::new(),
config_status,
config: loaded,
view_stack,
items: vec![
"Shows".into(),
"Movies".into(),
"Music".into(),
"Playlists".into(),
"Collections".into(),
"Libraries".into(),
"Continue Watching".into(),
"Recently Added".into(),
"Downloads".into(),
"Settings".into(),
],
selected: 0,
login_user: String::new(),
login_pass: String::new(),
login_focus: LoginFocus::User,
login_error: None,
search: None,
mpv: None,
mpv_poll_last: Instant::now(),
mpv_state: MpvState::default(),
mpv_state_rx: None,
debug_mpv: false,
mpv_now_playing: None,
autoplay: false,
autoplay_view_depth: 0,
autoplay_next_index: None,
autoplay_eof_latched: false,
autoplay_prev_time_pos: None,
transcode_on: default_on,
transcode_mbps: default_mbps,
}
}
}
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();
}
// 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();
}
}
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') => { reload_config(app); 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.debug_mpv = !app.debug_mpv;
app.status = format!("mpv debug: {}", if app.debug_mpv { "ON" } else { "OFF" });
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('a') => {
if let Some(mpv) = &app.mpv {
if let Err(e) = mpv_cycle_audio(mpv) {
app.status = format!("mpv audio: {e}");
}
}
return;
}
KeyCode::Char('s') => {
if let Some(mpv) = &app.mpv {
if let Err(e) = mpv_cycle_subtitles(mpv) {
app.status = format!("mpv subs: {e}");
}
}
return;
}
KeyCode::Char('v') => {
if let Some(mpv) = &app.mpv {
if let Err(e) = mpv_toggle_subs(mpv) {
app.status = format!("mpv sub vis: {e}");
}
}
return;
}
KeyCode::Char('x') => {
app.autoplay = false;
app.autoplay_next_index = None;
if let Some(mut mpv) = app.mpv.take() {
mpv_stop(&mut mpv);
app.status = "Stopped mpv".into();
}
return;
}
KeyCode::Char('f') => {
if let Some(mpv) = &app.mpv {
if let Err(e) = mpv_toggle_fullscreen(mpv) {
app.status = format!("mpv fullscreen: {e}");
}
} else {
app.status = "mpv not running (press 'p' to play)".into();
}
return;
}
KeyCode::Char(' ') => {
if let Some(mpv) = &app.mpv {
if let Err(e) = mpv_cycle_pause(mpv) {
app.status = format!("mpv pause: {e}");
}
} else {
app.status = "mpv not running (press 'p' to play)".into();
}
return;
}
KeyCode::Char('p') => {
app.autoplay = false;
app.autoplay_next_index = None;
// p = play selected item (spawn or replace)
let cfg = match &app.config {
Some(c) => c.clone(),
None => { app.status = "No config loaded".into(); return; }
};
// Determine what is “selected” depending on view
let (item_id, item_name) = match app.current_view() {
View::Library { items, selected, .. } if !items.is_empty() => {
(items[*selected].id.clone(), items[*selected].name.clone())
}
_ => { app.status = "Nothing to play here".into(); return; }
};
app.mpv_now_playing = Some(item_name);
// If mpv running -> replace, else spawn
let url = build_jellyfin_play_url(
&cfg,
&item_id,
None,
app.transcode_on,
app.transcode_mbps,
);
if let Some(mpv) = &app.mpv {
match mpv_load_url(&cfg, mpv, &url) {
Ok(_) => app.status = "mpv: loaded".into(),
Err(e) => app.status = format!("mpv load failed: {e}"),
}
} else {
match spawn_mpv_url(&cfg, &url) {
Ok(mpv) => {
let rx = start_mpv_poller(mpv.socket_path.clone());
app.mpv_state_rx = Some(rx);
app.mpv = Some(mpv);
app.status = "mpv: playing".into();
}
Err(e) => app.status = format!("mpv spawn failed: {e}"),
}
}
return;
}
KeyCode::Char('P') => {
let cfg = match &app.config { Some(c) => c.clone(), None => { app.status="No config".into(); return; } };
let (item_id, item_name, selected, len) = match app.current_view() {
View::Library { items, selected, .. } if !items.is_empty() => {
(items[*selected].id.clone(), items[*selected].name.clone(), *selected, items.len())
}
_ => { app.status = "Nothing to play here".into(); return; }
};
app.mpv_now_playing = Some(item_name);
app.autoplay = true;
app.status = format!("autoplay ON (next index: {:?})", app.autoplay_next_index);
app.autoplay_prev_time_pos = None;
app.autoplay_view_depth = app.view_stack.len();
app.autoplay_next_index = if selected + 1 < len { Some(selected + 1) } else { None };
// load/replace or spawn
let url = build_jellyfin_play_url(
&cfg,
&item_id,
None,
app.transcode_on,
app.transcode_mbps,
);
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('t') => {
app.transcode_on = !app.transcode_on;
app.status = if app.transcode_on {
format!("Transcoding ON ({}Mbps)", app.transcode_mbps)
} else {
"Transcoding OFF".into()
};
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,
start_ticks,
app.transcode_on,
app.transcode_mbps,
);
if let Some(mpv) = &app.mpv {
match mpv_cmd(&mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url)) {
Ok(_) => 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);
app.mpv = Some(mpv);
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,
start_ticks,
app.transcode_on,
app.transcode_mbps,
);
if let Some(mpv) = &app.mpv {
match mpv_cmd(&mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url)) {
Ok(_) => 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);
app.mpv = Some(mpv);
app.status = "mpv: resumed (autoplay)".into();
}
Err(e) => app.status = format!("mpv spawn failed: {e}"),
}
}
return;
}
_ => {}
}
}
match code {
KeyCode::Char('q') => app.should_exit = true,
//Enter code Block
KeyCode::Enter => {
let view = app.current_view().clone();
match view {
View::Categories => {
let cat = app.items[app.selected].clone();
let cfg = match &app.config {
Some(c) => c.clone(),
None => { app.status = "No config loaded".into(); return; }
};
let user_id = match cfg.user_id.as_deref() {
Some(u) => u,
None => { app.status = "Missing user_id in config (login again)".into(); return; }
};
let client = match core::jellyfin::JellyfinClient::from_config(&cfg) {
Ok(c) => c,
Err(e) => { app.status = e; return; }
};
// Special case: Libraries uses /Views, not /Items
if cat == "Libraries" {
app.status = "Fetching Libraries...".into();
match client.list_views(user_id) {
Ok(items) => {
let total = items.len();
app.view_stack.push(View::Library {
title: "Libraries".into(),
query: core::jellyfin::LibraryQuery::all(), // placeholder
items,
selected: 0,
start_index: 0,
total, // cheap total for UI
page_size: PAGE_SIZE,
loading: false,
});
app.status = "Loaded".into();
}
Err(e) => app.status = format!("Views failed: {e}"),
}
return;
}
// Normal categories use /Items
let query = match cat.as_str() {
"Movies" => core::jellyfin::LibraryQuery::movies(),
"Shows" => core::jellyfin::LibraryQuery::shows(),
"Music" => core::jellyfin::LibraryQuery::music(),
"Playlists" => core::jellyfin::LibraryQuery::all().with_item_types("Playlist"),
_ => { app.status = format!("Not wired yet: {}", cat); return; }
};
app.status = format!("Fetching {}...", cat);
match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) {
Ok(page) => {
app.view_stack.push(View::Library {
title: cat,
query,
items: page.items,
selected: 0,
start_index: 0,
total: page.total,
page_size: PAGE_SIZE,
loading: false,
});
app.status = "$".into();
}
Err(e) => app.status = format!("Fetch failed: {e}"),
}
}
View::Library { title, items, selected, .. } => {
if items.is_empty() { return; }
let it = &items[selected];
let ty = it.item_type.as_deref().unwrap_or("");
// Libraries (/Views) usually return CollectionFolder. Entering it should list its contents.
if ty == "CollectionFolder" {
let q = core::jellyfin::LibraryQuery::all().with_parent(it.id.clone());
open_children(app, format!("{} > {}", title, it.name), q);
return;
}
// Shows -> Seasons -> Episodes
if ty == "Series" {
let q = core::jellyfin::LibraryQuery::all()
.with_parent(it.id.clone())
.with_item_types("Season");
open_children(app, format!("{} > {}", title, it.name), q);
return;
}
if ty == "Season" {
let q = core::jellyfin::LibraryQuery::all()
.with_parent(it.id.clone())
.with_item_types("Episode");
open_children(app, format!("{} > {}", title, it.name), q);
return;
}
// MusicArtist -> MusicAlbum -> Audio
if ty == "MusicArtist" {
let q = core::jellyfin::LibraryQuery::all()
.with_parent(it.id.clone())
.with_item_types("MusicAlbum");
open_children(app, format!("{} > {}", title, it.name), q);
return;
}
if ty == "MusicAlbum" {
let q = core::jellyfin::LibraryQuery::all()
.with_parent(it.id.clone())
.with_item_types("Audio");
open_children(app, format!("{} > {}", title, it.name), q);
return;
}
if ty == "BoxSet" {
let q = core::jellyfin::LibraryQuery::all().with_parent(it.id.clone());
open_children(app, format!("{} > {}", title, it.name), q);
return;
}
if ty == "UserView" {
// Most UserViews are libraries like Shows/Movies/Music/Playlists.
// For Playlists view, drill into playlists.
let q = core::jellyfin::LibraryQuery::all()
.with_parent(it.id.clone())
.with_item_types("Playlist");
open_children(app, format!("{} > {}", title, it.name), q);
return;
}
if ty == "Playlist" {
let cfg = match &app.config { Some(c) => c.clone(), None => { app.status="No config".into(); return; } };
let user_id = match cfg.user_id.as_deref() { Some(u) => u, None => { app.status="Missing user_id".into(); return; } };
let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { Ok(c)=>c, Err(e)=>{ app.status=e; return; } };
match client.playlist_items(user_id, &it.id, 0, PAGE_SIZE) {
Ok(page) => {
app.view_stack.push(View::Library {
title: format!("{} > {}", title, it.name),
// store a query placeholder; playlist paging could be done later if you want
query: core::jellyfin::LibraryQuery::all(),
items: page.items,
selected: 0,
start_index: 0,
total: page.total,
page_size: PAGE_SIZE,
loading: false,
});
app.status = "$".into();
}
Err(e) => app.status = format!("Playlist load failed: {e}"),
}
return;
}
// Playables -> mpv
// Playables: do nothing on Enter (use 'p' to play)
if ty == "Episode" || ty == "Audio" || ty == "Movie" {
app.status = "Press 'p' to play, or 'P' for autoplay.".into();
return;
}
app.status = format!("No drilldown for type: {}", ty);
}
_ => {}
}
}
KeyCode::Esc | KeyCode::Backspace => {
if app.view_stack.len() > 1 {
app.view_stack.pop();
app.status = "$".to_string();
app.selected = 0;
}
}
KeyCode::Up | KeyCode::Char('k') => {
match app.view_stack.last_mut().unwrap() {
View::Categories => {
if app.items.is_empty() { return; }
if app.selected == 0 { app.selected = app.items.len() - 1; }
else { app.selected -= 1; }
//app.status = format!("Selected: {}", app.items[app.selected]);
app.status = "$".into();
}
View::Library { items, selected, total, .. } => {
if items.is_empty() { return; }
if *selected == 0 {
// wrap-up wants the real end
if items.len() < *total {
ensure_loaded_to_end(app);
}
if let Some(View::Library { items, selected, .. }) = app.view_stack.last_mut() {
if !items.is_empty() {
*selected = items.len() - 1;
}
}
} else {
*selected -= 1;
}
maybe_prefetch(app);
}
_ => {}
}
}
KeyCode::Down | KeyCode::Char('j') => {
match app.view_stack.last_mut().unwrap() {
View::Categories => {
if app.items.is_empty() { return; }
app.selected = (app.selected + 1) % app.items.len();
//app.status = format!("Selected: {}", app.items[app.selected]);
app.status = "$".into();
}
View::Library { items, selected, total, .. } => {
if items.is_empty() { return; }
// If we're at the end of what we have loaded…
if *selected + 1 >= items.len() {
// …and theres more available on the server, fetch next page.
if items.len() < *total {
load_next_library_page(app);
// After loading more, move down if possible.
if let Some(View::Library { items, selected, .. }) = app.view_stack.last_mut() {
if !items.is_empty() && *selected + 1 < items.len() {
*selected += 1;
}
}
} else {
*selected = 0;
}
} else {
*selected += 1;
}
maybe_prefetch(app);
}
_ => {}
}
}
KeyCode::Char('/') => {
let scope = match app.current_view() {
View::Categories => SearchScope::All,
View::Library { title, query, .. } => SearchScope::Library {
title: title.clone(),
query: query.clone(),
},
_ => SearchScope::All,
};
app.search = Some(SearchState {
scope,
mode: SearchMode::All,
input: String::new(),
});
}
_ => {}
}
}
fn load_next_library_page(app: &mut App) {
// pull info + mark loading
let (query, next_start, total, page_size) = match app.view_stack.last_mut() {
Some(View::Library { query, items, total, start_index, loading, page_size, .. }) => {
if *loading { return; }
*loading = true;
let loaded = items.len();
let next_start = *start_index + loaded;
(query.clone(), next_start, *total, *page_size)
}
_ => return,
};
let cfg = match &app.config {
Some(c) => c.clone(),
None => { app.status = "No config loaded".to_string(); finish_loading(app); return; }
};
let user_id = match cfg.user_id.as_deref() {
Some(u) => u,
None => { app.status = "Missing user_id in config (login again)".to_string(); finish_loading(app); return; }
};
let client = match core::jellyfin::JellyfinClient::from_config(&cfg) {
Ok(c) => c,
Err(e) => { app.status = e; finish_loading(app); return; }
};
let page = match client.list_items(user_id, query.clone(), next_start, page_size) {
Ok(p) => p,
Err(e) => { app.status = format!("Fetch failed: {e}"); finish_loading(app); return; }
};
if let Some(View::Library { items, total, .. }) = app.view_stack.last_mut() {
items.extend(page.items);
*total = page.total;
}
finish_loading(app);
}
fn finish_loading(app: &mut App) {
if let Some(View::Library { loading, .. }) = app.view_stack.last_mut() {
*loading = false;
}
}
fn maybe_prefetch(app: &mut App) {
const PREFETCH_ROWS: usize = 0;
let should = match app.view_stack.last() {
Some(View::Library { items, selected, total, loading, .. }) => {
if *loading { false }
else if items.len() >= *total { false }
else {
let remaining_loaded = items.len().saturating_sub(*selected);
remaining_loaded <= PREFETCH_ROWS
}
}
_ => false,
};
if should {
load_next_library_page(app);
}
}
fn ensure_loaded_to_end(app: &mut App) {
loop {
let done = match app.view_stack.last() {
Some(View::Library { items, total, loading, .. }) => {
!*loading && items.len() >= *total
}
_ => true,
};
if done { break; }
load_next_library_page(app);
}
}
fn ticks_to_runtime_str(ticks: Option<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(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(it) {
let pos = playback_position_ticks(it).unwrap_or(0);
let pos_secs = (pos / 10_000_000) as f64;
header.push_str(&format!(
"\nIn progress ({}): press r/R to resume.",
fmt_hms(pos_secs)
));
}
format!(
"{}\n\nOverview:\n{}",
header,
overview_str(&it.overview),
)
}
// --- Songs ---
"Audio" => {
let artist = first_nonempty(&it.album_artist)
.or_else(|| it.artists.get(0).cloned())
.unwrap_or_else(|| "-".into());
let album = first_nonempty(&it.album).unwrap_or_else(|| "-".into());
format!(
"Name: {}\nRuntime: {}\nArtist: {}\nAlbum: {}\nYear: {}",
it.name,
ticks_to_runtime_str(it.run_time_ticks).unwrap_or_else(|| "-".into()),
artist,
album,
year_str(it.production_year),
)
}
// --- Albums ---
"MusicAlbum" => {
let artist = first_nonempty(&it.album_artist)
.or_else(|| it.artists.get(0).cloned())
.unwrap_or_else(|| "-".into());
format!(
"Name: {}\nRuntime: {}\nArtist: {}\nYear: {}",
it.name,
ticks_to_runtime_str(it.run_time_ticks).unwrap_or_else(|| "-".into()),
artist,
year_str(it.production_year),
)
}
// --- Artists ---
"MusicArtist" => format!("Name: {}", it.name),
_ => format!(
"Name: {}\nId: {}\nType: {:?}\nYear: {:?}",
it.name, it.id, it.item_type, it.production_year
),
}
}
fn ui(frame: &mut Frame, app: &App) {
let area = frame.area();
let panes = layout(area);
// Top
//frame.render_widget(
// Paragraph::new("cj-jftui").block(Block::bordered()),
// panes.top,
//);
let top_block = Block::bordered();
frame.render_widget(top_block.clone(), panes.top);
let top_inner = top_block.inner(panes.top);
let [top_l, top_r] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(40)])
.areas(top_inner);
frame.render_widget(Paragraph::new("cj-jftui"), top_l);
let server = app
.config
.as_ref()
.map(|c| c.base_url.clone())
.unwrap_or_else(|| "-".into());
frame.render_widget(
Paragraph::new(server)
.alignment(ratatui::layout::Alignment::Right),
top_r,
);
// Main
match app.current_view() {
View::Categories => {
render_categories(frame, app, panes.left_main, panes.right_main);
}
View::Library { title, query, items, selected, start_index, total, .. } => {
let is_audio = query
.include_item_types
.as_deref()
.map(|t| t.split(',').any(|x| x.trim() == "Audio"))
.unwrap_or(false);
let show_audio_track_numbers = is_audio && query.parent_id.is_some();
render_library(
frame,
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.mpv.is_some() {
// mpv panel takes over the whole bottom-left area
render_mpv_panel(frame, app, panes.left_output);
} else {
frame.render_widget(
Paragraph::new("$").block(Block::bordered().merge_borders(MergeStrategy::Exact)),
panes.left_output,
);
}
// ---- Bottom Right: always-on status/help ----
let connected = if app.config.as_ref().and_then(|c| c.access_token.as_ref()).is_some() {
"✅ Connected"
} else {
"❌ Disconnected"
};
let server = app
.config
.as_ref()
.map(|c| c.base_url.as_str())
.unwrap_or("-");
let tx_line = if app.transcode_on {
format!("Transcoding On ({}Mbps)", app.transcode_mbps)
} else {
"Transcoding Off".to_string()
};
let right_text = format!(
"{connected}\n{tx_line}\n\np/P: play/autoplay\nq: quit\n/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,
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(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 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
let url = build_jellyfin_play_url(
&cfg,
&next_id,
None,
app.transcode_on,
app.transcode_mbps,
);
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_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
}
// --- watched / in-progress helpers ---
fn is_music_item(it: &core::jellyfin::JfItem) -> bool {
matches!(it.item_type.as_deref(), Some("Audio" | "MusicAlbum" | "MusicArtist"))
}
// Placeholder for later
fn is_downloaded(_it: &core::jellyfin::JfItem) -> bool {
false
}
fn is_played(it: &core::jellyfin::JfItem) -> bool {
if it.user_data.as_ref().and_then(|u| u.played) == Some(true) {
return true;
}
// If Jellyfin provides played percentage
if let Some(p) = it.user_data.as_ref().and_then(|u| u.played_percentage) {
if p >= 99.9 {
return true;
}
}
// Series/Season often only has UnplayedItemCount
if matches!(it.item_type.as_deref(), Some("Series" | "Season")) {
if it.user_data.as_ref().and_then(|u| u.unplayed_item_count) == Some(0) {
return true;
}
}
false
}
fn is_in_progress(it: &core::jellyfin::JfItem) -> bool {
if is_played(it) {
return false;
}
// movies/episodes
if playback_position_ticks(it).is_some() {
return true;
}
// series/seasons (percentage-based)
match it.user_data.as_ref().and_then(|u| u.played_percentage) {
Some(p) if p > 0.1 && p < 99.9 => true,
_ => 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 emoji_prefix_for(it: &core::jellyfin::JfItem) -> &'static str {
// no emoji for music
if is_music_item(it) {
return "";
}
let played = is_played(it);
let prog = is_in_progress(it);
match (played, prog) {
(true, _) => "",
(false, true) => "🟠 ", // orange in-progress
(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,
start_ticks: Option<u64>,
transcode_on: bool,
transcode_mbps: u32,
) -> String {
let base = cfg.base_url.trim_end_matches('/');
let token = cfg
.access_token
.as_deref()
.unwrap_or(""); // if empty, server likely rejects anyway
// Common helper
let bps = clamp_mbps_to_bps(transcode_mbps);
if !transcode_on {
// Direct stream.
// IMPORTANT: static=true can cause Jellyfin to ignore startTimeTicks on some servers.
// So we keep static=false here to preserve resume behavior.
let mut url = format!(
"{}/Videos/{}/stream?static=false&api_key={}",
base, item_id, token
);
if let Some(t) = start_ticks {
url.push_str(&format!("&startTimeTicks={}", t));
}
return url;
}
// Transcoded stream via HLS (fixes "duration == buffered" + enables seeking)
let mut url = format!(
"{}/Videos/{}/master.m3u8?api_key={}&static=false",
base, item_id, token
);
// Correct Jellyfin param names for this endpoint:
// - videoBitRate (capital R) is the documented query param name.
// - segmentContainer is the documented query param name.
url.push_str(&format!("&videoBitRate={}", bps));
url.push_str(&format!("&audioBitRate={}", bps)); // optional but helps keep variants sane
url.push_str("&segmentContainer=ts");
// Force transcode for testing/consistency
url.push_str("&enableAutoStreamCopy=false");
// deterministic codecs
url.push_str("&videoCodec=h264");
url.push_str("&audioCodec=aac");
url.push_str("&transcodingContainer=ts");
if let Some(t) = start_ticks {
url.push_str(&format!("&startTimeTicks={}", t));
}
url
}
fn reload_config(app: &mut App) {
if let Ok(cfg) = core::config::load_config() {
app.transcode_mbps = cfg.transcode_mbps.unwrap_or(5);
app.transcode_on = cfg.transcode_default_on.unwrap_or(false);
app.config = Some(cfg);
app.status = "Reloaded config".into();
} else {
app.status = "Failed to reload config".into();
}
}
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,
);
}