Files
CjTui/src/main.rs
Chris Johnson 2f0493faa9 revert to Pre-Transcoding
revert Playlist downloading and offline hardlinks
2026-02-17 05:32:55 +00:00

3616 lines
120 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},
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,
playlist_ctx: Option<PlaylistCtx>,
},
OfflineLibrary {
title: String,
dir: std::path::PathBuf,
entries: Vec<OfflineEntry>,
selected: usize,
},
Login,
}
#[derive(Clone)]
struct PlaylistCtx {
name: String,
}
#[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,
transcoding: 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,
transcoding: false,
}
}
}
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,
playlist_ctx: None,
});
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;
}
// Toggle transcoding (affects remote playback URL selection only).
if matches!(code, KeyCode::Char('t')) {
app.transcoding = !app.transcoding;
let mbps = app
.config
.as_ref()
.map(|c| c.transcode_mbps)
.unwrap_or(5.0);
app.status = if app.transcoding {
format!("transcoding: ON ({} Mbps)", mbps)
} else {
"transcoding: OFF".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
// - Online library: we have an item_id (and can prefer local by id)
// - Offline directory browser: we have a direct local file path
let (maybe_item_id, item_name, maybe_path) = match app.current_view() {
View::Library { items, selected, .. } if !items.is_empty() => {
(Some(items[*selected].id.clone()), items[*selected].name.clone(), None)
}
View::OfflineLibrary { entries, selected, .. } if !entries.is_empty() => {
let e = &entries[*selected];
if e.is_dir {
app.status = "Select a file (or Enter to open folder)".into();
return;
}
(None, e.name.clone(), Some(e.path.clone()))
}
_ => { app.status = "Nothing to play here".into(); return; }
};
app.mpv_now_playing = Some(item_name);
// Decide URL/path:
// 1) If OfflineLibrary gave us a file path -> play it directly.
// 2) Else (online library): prefer local download by item_id, else stream.
let local = maybe_item_id
.as_deref()
.and_then(|id| crate::core::downloads::local_media_path_for_item_id(id));
if app.offline && maybe_path.is_none() && local.is_none() {
app.status = "Offline: item not downloaded".into();
return;
}
let url = if let Some(p) = maybe_path.or(local) {
p.to_string_lossy().to_string()
} else {
let id = maybe_item_id.as_deref().unwrap();
if app.transcoding {
build_jellyfin_play_url_transcode_forced(&cfg, id)
.unwrap_or_else(|| build_jellyfin_play_url(&cfg, id, true))
} else {
build_jellyfin_play_url(&cfg, id, false)
}
};
// If mpv running -> replace, else spawn
if let Some(mpv) = &app.mpv {
// For local files, use mpv_load_local; otherwise mpv_load_url
let r = if url.starts_with('/') { mpv_load_local(mpv, &url) } else { mpv_load_url(&cfg, mpv, &url) };
match r {
Ok(_) => app.status = "mpv: loaded".into(),
Err(e) => app.status = format!("mpv load failed: {e}"),
}
} else {
let r = if url.starts_with('/') { spawn_mpv_local(&url) } else { spawn_mpv_url(&cfg, &url) };
match r {
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 view = app.current_view().clone();
match view {
View::Library { items, selected, .. } if !items.is_empty() => {
let item_id = items[selected].id.clone();
let item_name = items[selected].name.clone();
let len = items.len();
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_eof_latched = false;
app.autoplay_next_index = if selected + 1 < len { Some(selected + 1) } else { None };
let local = crate::core::downloads::local_media_path_for_item_id(&item_id);
if app.offline && local.is_none() {
app.autoplay = false;
app.autoplay_next_index = None;
app.status = "Offline: item not downloaded".into();
return;
}
let url = if let Some(p) = local {
p.to_string_lossy().to_string()
} else if app.transcoding {
build_jellyfin_play_url_transcode_forced(&cfg, &item_id)
.unwrap_or_else(|| build_jellyfin_play_url(&cfg, &item_id, true))
} else {
build_jellyfin_play_url(&cfg, &item_id, false)
};
if let Some(mpv) = &app.mpv {
let r = if url.starts_with('/') { mpv_load_local(mpv, &url) } else { mpv_load_url(&cfg, mpv, &url) };
if let Err(e) = r {
app.autoplay = false;
app.autoplay_next_index = None;
app.status = format!("mpv load failed: {e}");
} else {
app.status = "mpv: playing (autoplay)".into();
}
} else {
let r = if url.starts_with('/') { spawn_mpv_local(&url) } else { spawn_mpv_url(&cfg, &url) };
match r {
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.autoplay = false;
app.autoplay_next_index = None;
app.status = format!("mpv spawn failed: {e}");
}
}
}
return;
}
View::OfflineLibrary { entries, selected, .. } if !entries.is_empty() => {
let e = &entries[selected];
if e.is_dir {
app.status = "Select a file to autoplay".into();
return;
}
app.autoplay = true;
app.autoplay_prev_time_pos = None;
app.autoplay_view_depth = app.view_stack.len();
app.autoplay_eof_latched = false;
app.autoplay_next_index = next_offline_playable(&entries, selected);
app.mpv_now_playing = Some(e.name.clone());
let url = e.path.to_string_lossy().to_string();
if let Some(mpv) = &app.mpv {
if let Err(e) = mpv_load_local(mpv, &url) {
app.autoplay = false;
app.autoplay_next_index = None;
app.status = format!("mpv load failed: {e}");
} else {
app.status = "mpv: playing (offline autoplay)".into();
}
} else {
match spawn_mpv_local(&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 (offline autoplay)".into();
}
Err(e) => {
app.autoplay = false;
app.autoplay_next_index = None;
app.status = format!("mpv spawn failed: {e}");
}
}
}
return;
}
_ => {
app.status = "Nothing to autoplay here".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 = if app.transcoding {
build_jellyfin_play_url_transcode_forced(&cfg, &item_id)
.unwrap_or_else(|| build_jellyfin_play_url(&cfg, &item_id, true))
} else {
build_jellyfin_play_url(&cfg, &item_id, false)
};
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 = if app.transcoding {
build_jellyfin_play_url_transcode_forced(&cfg, &item_id)
.unwrap_or_else(|| build_jellyfin_play_url(&cfg, &item_id, true))
} else {
build_jellyfin_play_url(&cfg, &item_id, false)
};
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
/* //come back
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, playlist_name, playlist_index) = match app.current_view() {
View::Library { items, selected, playlist_ctx, .. } if !items.is_empty() => {
let it = items[*selected].clone();
let pname = playlist_ctx.as_ref().map(|p| p.name.clone());
(it, pname, *selected)
}
_ => { 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; }
};
if let Some(pname) = playlist_name {
// only allow direct playables from playlists
let ty = it.item_type.as_deref().unwrap_or("");
let playable = matches!(ty, "Movie" | "Episode" | "Audio");
if !playable {
app.status = "Playlist downloads only support Movie/Episode/Audio".into();
return;
}
dm.send(downloads::DownloadCommand::EnqueuePlaylistItem {
item: it,
playlist_name: pname,
playlist_index,
});
app.status = "queued 1 (playlist)".into();
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 root = match crate::core::paths::app_config_dir() {
Some(p) => p.join("downloads"),
None => {
app.status = "offline: cannot resolve config dir".into();
return;
}
};
let entries = offline_list_dir(&root);
app.view_stack.push(View::OfflineLibrary {
title: "Downloaded".into(),
dir: root,
entries,
selected: 0,
});
app.status = "$".into();
return;
}
}
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,
playlist_ctx: None,
});
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,
playlist_ctx: None,
});
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,
playlist_ctx: Some(PlaylistCtx { name: it.name.clone() }),
});
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);
}
View::OfflineLibrary { title, dir: _, entries, selected } => {
if entries.is_empty() { return; }
let e = &entries[selected];
if e.is_dir {
let new_dir = e.path.clone();
let new_entries = offline_list_dir(&new_dir);
app.view_stack.push(View::OfflineLibrary {
title: format!("{title} > {}", e.name),
dir: new_dir,
entries: new_entries,
selected: 0,
});
app.status = "$".into();
} else {
app.status = "Press 'p' to play local file".into();
}
return;
}
_ => {}
}
}
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 { entries, selected, .. } => {
if entries.is_empty() { return; }
if *selected == 0 { *selected = entries.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 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);
}
View::OfflineLibrary { entries, selected, .. } => {
if entries.is_empty() { return; }
*selected = (*selected + 1) % entries.len();
}
_ => {}
}
}
KeyCode::Char('/') => {
// Always behave like home-page search (global), regardless of current view.
app.search = Some(SearchState {
scope: SearchScope::All,
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 {
"Playlist" | "UserView" | "CollectionFolder" | "BoxSet" => {
format!("Name: {}", it.name)
}
"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, dir: _, entries, selected } => {
render_offline_library_dir(frame, title, entries, *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 mbps = app
.config
.as_ref()
.map(|c| c.transcode_mbps)
.unwrap_or(5.0);
let transcode_line = if app.transcoding {
format!("Transcoding On ({}Mbps)", mbps)
} else {
"Transcoding Off".to_string()
};
let right_text = format!(
"{connected}\n{transcode_line}\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,
playlist_ctx: None,
});
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 'M' in your keybinds)
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;
}
};
// Choose next target based on current view
enum NextTarget {
Online { id: String, name: String, len: usize },
Offline { path: PathBuf, name: String, len: usize },
}
let next = match app.current_view() {
View::Library { items, .. } if next_i < items.len() => NextTarget::Online {
id: items[next_i].id.clone(),
name: items[next_i].name.clone(),
len: items.len(),
},
View::OfflineLibrary { entries, .. } if next_i < entries.len() => {
// skip dirs: if next_i lands on a dir, find the next file
let mut j = next_i;
while j < entries.len() && entries[j].is_dir {
j += 1;
}
if j >= entries.len() {
// no more playable files
app.autoplay = false;
app.autoplay_next_index = None;
if !app.debug_mpv {
app.status = "$".into();
}
return;
}
NextTarget::Offline {
path: entries[j].path.clone(),
name: entries[j].name.clone(),
len: entries.len(),
}
}
_ => {
app.autoplay = false;
app.autoplay_next_index = None;
if !app.debug_mpv {
app.status = "$".into();
}
return;
}
};
// Update "now playing"
match &next {
NextTarget::Online { name, .. } => app.mpv_now_playing = Some(name.clone()),
NextTarget::Offline { name, .. } => app.mpv_now_playing = Some(name.clone()),
}
// Move selection highlight in UI
match app.view_stack.last_mut() {
Some(View::Library { selected, .. }) => {
*selected = next_i;
}
Some(View::OfflineLibrary {
selected,
entries,
..
}) => {
// if we skipped dirs, move selection to actual file index
let mut j = next_i;
while j < entries.len() && entries[j].is_dir {
j += 1;
}
*selected = j;
}
_ => {}
}
// compute next index (offline skip-dirs happens on next tick too)
let len = match &next {
NextTarget::Online { len, .. } => *len,
NextTarget::Offline { len, .. } => *len,
};
app.autoplay_next_index = if next_i + 1 < len {
Some(next_i + 1)
} else {
None
};
// Load next
match next {
NextTarget::Online { id: next_id, .. } => {
let cfg = match app.config.clone() {
Some(c) => c,
None => {
app.autoplay = false;
app.autoplay_next_index = None;
app.status = "No config".into();
return;
}
};
// If offline, MUST be local
let local = crate::core::downloads::local_media_path_for_item_id(&next_id);
if app.offline && local.is_none() {
app.autoplay = false;
app.autoplay_next_index = None;
app.status = "Offline: next item not downloaded".into();
return;
}
let url = if let Some(p) = local {
p.to_string_lossy().to_string()
} else if app.transcoding {
build_jellyfin_play_url_transcode_forced(&cfg, &next_id)
.unwrap_or_else(|| build_jellyfin_play_url(&cfg, &next_id, true))
} else {
build_jellyfin_play_url(&cfg, &next_id, false)
};
// Local vs remote loader
let r = if url.starts_with('/') {
mpv_load_local(mpv, &url)
} else {
mpv_load_url(&cfg, mpv, &url)
};
if let Err(e) = r {
app.autoplay = false;
app.autoplay_next_index = None;
app.status = format!("autoplay load failed: {e}");
return;
}
}
NextTarget::Offline { path, .. } => {
let url = path.to_string_lossy().to_string();
if let Err(e) = mpv_load_local(mpv, &url) {
app.autoplay = false;
app.autoplay_next_index = None;
app.status = format!("offline autoplay load failed: {e}");
return;
}
}
}
let _ = mpv_set_pause(mpv, false);
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);
}
fn next_offline_playable(entries: &[OfflineEntry], from_index: usize) -> Option<usize> {
if entries.is_empty() {
return None;
}
let mut i = from_index + 1;
while i < entries.len() {
if !entries[i].is_dir {
return Some(i);
}
i += 1;
}
None
}
fn spawn_mpv_local(url: &str) -> Result<MpvSession, String> {
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!("--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_local(mpv: &MpvSession, url: &str) -> Result<(), String> {
mpv_cmd(
&mpv.socket_path,
&format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url),
)
}
// 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, transcoding_on: bool) -> String {
let base = cfg.base_url.trim_end_matches('/');
// Transcoding OFF => keep existing behavior exactly.
if !transcoding_on {
return format!("{}/Items/{}/Download", base, item_id);
}
// Transcoding ON => use HLS "main" (does NOT require mediaSourceId)
// videoBitRate expects bits/sec, e.g. 5_000_000
let max_bps: u64 = (cfg.transcode_mbps.max(0.01) * 1_000_000.0) as u64;
format!(
"{}/Videos/{}/main.m3u8?videoBitRate={}&static=false",
base, item_id, max_bps
)
}
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(),
]
}
}
#[derive(Clone, Debug)]
struct OfflineEntry {
name: String,
path: std::path::PathBuf,
is_dir: bool,
}
fn offline_list_dir(dir: &std::path::Path) -> Vec<OfflineEntry> {
let mut out: Vec<OfflineEntry> = Vec::new();
let rd = match std::fs::read_dir(dir) {
Ok(r) => r,
Err(_) => return out,
};
for ent in rd.flatten() {
let path = ent.path();
let name = match path.file_name().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => continue,
};
// Hide download marker files + aria2 artifacts
if name.ends_with(".cj-jftui.json") || name.ends_with(".aria2") || name.ends_with(".part") {
continue;
}
let is_dir = path.is_dir();
out.push(OfflineEntry { name, path, is_dir });
}
// dirs first, then files; alpha within groups
out.sort_by(|a, b| {
match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
}
});
out
}
fn render_offline_library_dir(
frame: &mut Frame,
title: &str,
entries: &[OfflineEntry],
selected: usize,
left: Rect,
right: Rect,
) {
let viewport = left.height.saturating_sub(2) as usize;
let len = entries.len();
let selected = selected.min(len.saturating_sub(1));
let (start, end) = compute_window(len, selected, viewport);
let list_items: Vec<ListItem> = entries[start..end]
.iter()
.map(|e| {
if e.is_dir {
ListItem::new(format!("📁 {}", e.name))
} else {
ListItem::new(format!("💾 {}", e.name))
}
})
.collect();
let list = List::new(list_items)
.block(Block::bordered().title(title).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 e = &entries[selected];
format!(
"Name: {}\nType: {}\nPath:\n{}",
e.name,
if e.is_dir { "dir" } else { "file" },
e.path.display()
)
} else {
"Empty directory".to_string()
};
frame.render_widget(
Paragraph::new(detail)
.wrap(Wrap { trim: false })
.block(Block::bordered().title("Details").merge_borders(MergeStrategy::Exact)),
right,
);
}
fn build_jellyfin_play_url_transcode_forced(
cfg: &core::config::Config,
item_id: &str,
) -> Option<String> {
let user_id = cfg.user_id.as_deref()?;
let client = core::jellyfin::JellyfinClient::from_config(cfg).ok()?;
let max_bps: i32 = (cfg.transcode_mbps.max(0.01) * 1_000_000.0) as i32;
let turl = client
.playback_transcoding_url(item_id, Some(user_id), max_bps, true)
.ok()??;
if turl.starts_with("http://") || turl.starts_with("https://") {
Some(turl)
} else {
Some(format!("{}{}", cfg.base_url.trim_end_matches('/'), turl))
}
}