From eace0446f69ab30be1d553100de5d38e0d007aca Mon Sep 17 00:00:00 2001 From: cwj3rcb Date: Mon, 16 Feb 2026 19:08:03 -0600 Subject: [PATCH] Offline Mode Feature Complete --- src/main.rs | 189 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 168 insertions(+), 21 deletions(-) diff --git a/src/main.rs b/src/main.rs index f907177..8ef8ca4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,7 +50,8 @@ enum View { }, OfflineLibrary { title: String, - items: Vec, + dir: std::path::PathBuf, + entries: Vec, selected: usize, }, Login, @@ -733,25 +734,44 @@ fn handle_key(app: &mut App, code: KeyCode) { }; // Determine what is “selected” depending on view - let (item_id, item_name) = match app.current_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() => { - (items[*selected].id.clone(), items[*selected].name.clone()) + (Some(items[*selected].id.clone()), items[*selected].name.clone(), None) } - View::OfflineLibrary { items, selected, .. } if !items.is_empty() => { - (items[*selected].item_id.clone(), items[*selected].title.clone()) + 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); - // If mpv running -> replace, else spawn - let url = if let Some(p) = crate::core::downloads::local_media_path_for_item_id(&item_id) { + // 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 { - build_jellyfin_play_url(&cfg, &item_id) + build_jellyfin_play_url(&cfg, maybe_item_id.as_deref().unwrap()) }; + // If mpv running -> replace, else spawn if let Some(mpv) = &app.mpv { match mpv_load_url(&cfg, mpv, &url) { Ok(_) => app.status = "mpv: loaded".into(), @@ -770,6 +790,7 @@ fn handle_key(app: &mut App, code: KeyCode) { } return; } + KeyCode::Char('P') => { if matches!(app.current_view(), View::OfflineLibrary { .. }) { app.status = "Offline: autoplay disabled (use 'p')".into(); @@ -1027,18 +1048,25 @@ fn handle_key(app: &mut App, code: KeyCode) { // OFFLINE: local-only library if app.offline { if cat == "Downloaded" { - let items = crate::core::downloads::scan_local_entries(); + 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(), - items, + dir: root, + entries, selected: 0, }); app.status = "$".into(); return; } - - // Everything else offline can remain as-is (Downloads/Settings etc) } let cfg = match &app.config { @@ -1214,6 +1242,27 @@ fn handle_key(app: &mut App, code: KeyCode) { 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; + } + _ => {} } } @@ -1255,9 +1304,9 @@ fn handle_key(app: &mut App, code: KeyCode) { maybe_prefetch(app); } - View::OfflineLibrary { items, selected, .. } => { - if items.is_empty() { return; } - if *selected == 0 { *selected = items.len() - 1; } + View::OfflineLibrary { entries, selected, .. } => { + if entries.is_empty() { return; } + if *selected == 0 { *selected = entries.len() - 1; } else { *selected -= 1; } } _ => {} @@ -1295,11 +1344,10 @@ fn handle_key(app: &mut App, code: KeyCode) { maybe_prefetch(app); } - View::OfflineLibrary { items, selected, .. } => { - if items.is_empty() { return; } - *selected = (*selected + 1) % items.len(); + View::OfflineLibrary { entries, selected, .. } => { + if entries.is_empty() { return; } + *selected = (*selected + 1) % entries.len(); } - _ => {} } } @@ -1611,8 +1659,8 @@ fn ui(frame: &mut Frame, app: &App) { // Main match app.current_view() { - View::OfflineLibrary { title, items, selected } => { - render_offline_library(frame, title, items, *selected, panes.left_main, panes.right_main); + 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); @@ -3143,3 +3191,102 @@ fn categories_for_mode(offline: bool) -> Vec { ] } } + +#[derive(Clone, Debug)] +struct OfflineEntry { + name: String, + path: std::path::PathBuf, + is_dir: bool, +} + +fn offline_list_dir(dir: &std::path::Path) -> Vec { + let mut out: Vec = 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 = 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, + ); +}