Offline Mode Feature Complete

This commit is contained in:
2026-02-16 19:08:03 -06:00
parent 1adfd8fd03
commit eace0446f6

View File

@@ -50,7 +50,8 @@ enum View {
}, },
OfflineLibrary { OfflineLibrary {
title: String, title: String,
items: Vec<core::downloads::LocalEntry>, dir: std::path::PathBuf,
entries: Vec<OfflineEntry>,
selected: usize, selected: usize,
}, },
Login, Login,
@@ -733,25 +734,44 @@ fn handle_key(app: &mut App, code: KeyCode) {
}; };
// Determine what is “selected” depending on view // 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() => { 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() => { View::OfflineLibrary { entries, selected, .. } if !entries.is_empty() => {
(items[*selected].item_id.clone(), items[*selected].title.clone()) 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.status = "Nothing to play here".into(); return; }
}; };
app.mpv_now_playing = Some(item_name); app.mpv_now_playing = Some(item_name);
// If mpv running -> replace, else spawn // Decide URL/path:
let url = if let Some(p) = crate::core::downloads::local_media_path_for_item_id(&item_id) { // 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() p.to_string_lossy().to_string()
} else { } 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 { if let Some(mpv) = &app.mpv {
match mpv_load_url(&cfg, mpv, &url) { match mpv_load_url(&cfg, mpv, &url) {
Ok(_) => app.status = "mpv: loaded".into(), Ok(_) => app.status = "mpv: loaded".into(),
@@ -770,6 +790,7 @@ fn handle_key(app: &mut App, code: KeyCode) {
} }
return; return;
} }
KeyCode::Char('P') => { KeyCode::Char('P') => {
if matches!(app.current_view(), View::OfflineLibrary { .. }) { if matches!(app.current_view(), View::OfflineLibrary { .. }) {
app.status = "Offline: autoplay disabled (use 'p')".into(); app.status = "Offline: autoplay disabled (use 'p')".into();
@@ -1027,18 +1048,25 @@ fn handle_key(app: &mut App, code: KeyCode) {
// OFFLINE: local-only library // OFFLINE: local-only library
if app.offline { if app.offline {
if cat == "Downloaded" { 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 { app.view_stack.push(View::OfflineLibrary {
title: "Downloaded".into(), title: "Downloaded".into(),
items, dir: root,
entries,
selected: 0, selected: 0,
}); });
app.status = "$".into(); app.status = "$".into();
return; return;
} }
// Everything else offline can remain as-is (Downloads/Settings etc)
} }
let cfg = match &app.config { 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); 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); maybe_prefetch(app);
} }
View::OfflineLibrary { items, selected, .. } => { View::OfflineLibrary { entries, selected, .. } => {
if items.is_empty() { return; } if entries.is_empty() { return; }
if *selected == 0 { *selected = items.len() - 1; } if *selected == 0 { *selected = entries.len() - 1; }
else { *selected -= 1; } else { *selected -= 1; }
} }
_ => {} _ => {}
@@ -1295,11 +1344,10 @@ fn handle_key(app: &mut App, code: KeyCode) {
maybe_prefetch(app); maybe_prefetch(app);
} }
View::OfflineLibrary { items, selected, .. } => { View::OfflineLibrary { entries, selected, .. } => {
if items.is_empty() { return; } if entries.is_empty() { return; }
*selected = (*selected + 1) % items.len(); *selected = (*selected + 1) % entries.len();
} }
_ => {} _ => {}
} }
} }
@@ -1611,8 +1659,8 @@ fn ui(frame: &mut Frame, app: &App) {
// Main // Main
match app.current_view() { match app.current_view() {
View::OfflineLibrary { title, items, selected } => { View::OfflineLibrary { title, dir: _, entries, selected } => {
render_offline_library(frame, title, items, *selected, panes.left_main, panes.right_main); render_offline_library_dir(frame, title, entries, *selected, panes.left_main, panes.right_main);
} }
View::Categories => { View::Categories => {
render_categories(frame, app, panes.left_main, panes.right_main); render_categories(frame, app, panes.left_main, panes.right_main);
@@ -3143,3 +3191,102 @@ fn categories_for_mode(offline: bool) -> Vec<String> {
] ]
} }
} }
#[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,
);
}