Offline Mode Feature Complete
This commit is contained in:
189
src/main.rs
189
src/main.rs
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user