Files
CjTui/src/main.rs

877 lines
27 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,
time::{Duration, Instant},
};
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},
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, 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 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>,
}
impl Default for App {
fn default() -> Self {
let loaded = core::config::load_config().ok();
let (view_stack, config_status) = match &loaded {
Some(cfg) if cfg.access_token.is_some() => (
vec![View::Categories],
format!("auth: token present ({})", cfg.base_url),
),
Some(cfg) => (
vec![View::Login],
format!("auth: needs login ({})", cfg.base_url),
),
None => (
vec![View::Login],
"config: missing/invalid (need base_url)".to_string(),
),
};
Self {
should_exit: false,
status: "Press q to quit".to_string(),
config_status,
config: loaded,
view_stack,
items: vec![
"Shows".into(),
"Movies".into(),
"Music".into(),
"Playlists".into(),
"Collections".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,
}
}
}
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 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) => {
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(c) => { search.input.push(c); return; }
_ => return,
}
}
match code {
KeyCode::Char('q') => app.should_exit = true,
KeyCode::Enter => {
match app.current_view() {
View::Categories => {
let cat = app.items[app.selected].clone();
// Map your categories to Jellyfin queries (expand later)
let query = match cat.as_str() {
"Movies" => core::jellyfin::LibraryQuery::movies(),
"Shows" => core::jellyfin::LibraryQuery::shows(),
"Music" => core::jellyfin::LibraryQuery::music(),
_ => {
app.status = format!("Not wired yet: {}", cat);
return;
}
};
let cfg = match &app.config {
Some(c) => c.clone(),
None => {
app.status = "No config loaded".to_string();
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();
return;
}
};
let client = match core::jellyfin::JellyfinClient::from_config(&cfg) {
Ok(c) => c,
Err(e) => {
app.status = e;
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 = "Loaded".to_string();
}
Err(e) => {
app.status = format!("Fetch failed: {e}");
}
}
}
_ => {}
}
}
KeyCode::Esc | KeyCode::Backspace => {
if app.view_stack.len() > 1 {
app.view_stack.pop();
app.status = "Went back".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]);
}
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]);
}
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 ui(frame: &mut Frame, app: &App) {
let area = frame.area();
let panes = layout(area);
// Top
frame.render_widget(Block::bordered().title("cj-jftui"), panes.top);
// Main
match app.current_view() {
View::Categories => {
render_categories(frame, app, panes.left_main, panes.right_main);
}
View::Library { title, items, selected, start_index, total, .. } => {
render_library(frame, title, *start_index, *total, items, *selected, panes.left_main, panes.right_main);
}
View::Login => {
render_login(frame, app, panes.left_main, panes.right_main);
}
}
// Bottom Left
let status_text = 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();
if s.input.is_empty() {
format!("/ ({scope}) [{mode}]")
} else {
format!("/ ({scope}) [{mode}] {}", s.input)
}
} else {
app.status.clone()
};
frame.render_widget(
Paragraph::new(status_text)
.block(Block::bordered().title("Status").merge_borders(MergeStrategy::Exact)),
panes.left_output,
);
// Bottom Right
let info = match app.current_view() {
View::Library { items, total, loading, .. } => {
if *loading {
format!("loading… {}/{}", items.len(), total)
} else {
format!("{}/{}", items.len(), total)
}
}
_ => app.config_status.clone(),
};
frame.render_widget(
Paragraph::new(info)
.block(Block::bordered().title("Info").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)
let detail = if len > 0 {
format!("Details for:\n\n{}", app.items[selected])
} else {
"No items".to_string()
};
frame.render_widget(
Paragraph::new(detail)
.block(Block::bordered().title("Details").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,
) {
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| {
let year = it.production_year.map(|y| format!(" ({y})")).unwrap_or_default();
ListItem::new(format!("{}{}", 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!(
"Name: {}\nId: {}\nType: {:?}\nYear: {:?}",
it.name, it.id, it.item_type, it.production_year
)
} else {
"No items returned".to_string()
};
frame.render_widget(
Paragraph::new(detail)
.block(Block::bordered().title("Details").merge_borders(MergeStrategy::Exact)),
right,
);
}
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(3), Fill(5)])
.spacing(Spacing::Overlap(1))
.areas(main_area);
let [left_output, right_output] =
Layout::horizontal([Fill(5), Fill(1)])
.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,
);
}