Offline Mode Feature Complete
This commit is contained in:
189
src/main.rs
189
src/main.rs
@@ -50,7 +50,8 @@ enum View {
|
||||
},
|
||||
OfflineLibrary {
|
||||
title: String,
|
||||
items: Vec<core::downloads::LocalEntry>,
|
||||
dir: std::path::PathBuf,
|
||||
entries: Vec<OfflineEntry>,
|
||||
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<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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user