From 1d2a7d5268631ae86a9985bfb1166a12afbcf514 Mon Sep 17 00:00:00 2001 From: cwj3rcb Date: Mon, 16 Feb 2026 19:39:07 -0600 Subject: [PATCH] added offline autoplay --- src/main.rs | 363 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 271 insertions(+), 92 deletions(-) diff --git a/src/main.rs b/src/main.rs index 94cda20..e900398 100644 --- a/src/main.rs +++ b/src/main.rs @@ -792,52 +792,119 @@ fn handle_key(app: &mut App, code: KeyCode) { } KeyCode::Char('P') => { - if matches!(app.current_view(), View::OfflineLibrary { .. }) { - app.status = "Offline: autoplay disabled (use 'p')".into(); - return; - } - let cfg = match &app.config { Some(c) => c.clone(), None => { app.status="No config".into(); return; } }; + let cfg = match &app.config { + Some(c) => c.clone(), + None => { app.status = "No config".into(); return; } + }; - let (item_id, item_name, selected, len) = match app.current_view() { + let view = app.current_view().clone(); + + match view { View::Library { items, selected, .. } if !items.is_empty() => { - (items[*selected].id.clone(), items[*selected].name.clone(), *selected, items.len()) - } - _ => { app.status = "Nothing to play here".into(); return; } - }; + let item_id = items[selected].id.clone(); + let item_name = items[selected].name.clone(); + let len = items.len(); - app.mpv_now_playing = Some(item_name); + app.mpv_now_playing = Some(item_name); - app.autoplay = true; - app.status = format!("autoplay ON (next index: {:?})", app.autoplay_next_index); - app.autoplay_prev_time_pos = None; - app.autoplay_view_depth = app.view_stack.len(); - app.autoplay_next_index = if selected + 1 < len { Some(selected + 1) } else { None }; + app.autoplay = true; + app.autoplay_prev_time_pos = None; + app.autoplay_view_depth = app.view_stack.len(); + app.autoplay_eof_latched = false; + app.autoplay_next_index = if selected + 1 < len { Some(selected + 1) } else { None }; - // load/replace or spawn - let url = if let Some(p) = crate::core::downloads::local_media_path_for_item_id(&item_id) { - p.to_string_lossy().to_string() - } else { - build_jellyfin_play_url(&cfg, &item_id) - }; - - if let Some(mpv) = &app.mpv { - match mpv_load_url(&cfg, mpv, &url) { - Ok(_) => app.status = "mpv: playing (autoplay)".into(), - Err(e) => app.status = format!("mpv load failed: {e}"), - } - } else { - match spawn_mpv_url(&cfg, &url) { - Ok(mpv) => { - let rx = start_mpv_poller(mpv.socket_path.clone()); - app.mpv_state_rx = Some(rx); - app.mpv = Some(mpv); - app.status = "mpv: playing".into(); + let local = crate::core::downloads::local_media_path_for_item_id(&item_id); + if app.offline && local.is_none() { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = "Offline: item not downloaded".into(); + return; } - Err(e) => app.status = format!("mpv spawn failed: {e}"), + + let url = if let Some(p) = local { + p.to_string_lossy().to_string() + } else { + build_jellyfin_play_url(&cfg, &item_id) + }; + + if let Some(mpv) = &app.mpv { + let r = if url.starts_with('/') { mpv_load_local(mpv, &url) } else { mpv_load_url(&cfg, mpv, &url) }; + if let Err(e) = r { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = format!("mpv load failed: {e}"); + } else { + app.status = "mpv: playing (autoplay)".into(); + } + } else { + let r = if url.starts_with('/') { spawn_mpv_local(&url) } else { spawn_mpv_url(&cfg, &url) }; + match r { + Ok(mpv) => { + let rx = start_mpv_poller(mpv.socket_path.clone()); + app.mpv_state_rx = Some(rx); + app.mpv = Some(mpv); + app.status = "mpv: playing".into(); + } + Err(e) => { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = format!("mpv spawn failed: {e}"); + } + } + } + return; + } + + View::OfflineLibrary { entries, selected, .. } if !entries.is_empty() => { + let e = &entries[selected]; + if e.is_dir { + app.status = "Select a file to autoplay".into(); + return; + } + + app.autoplay = true; + app.autoplay_prev_time_pos = None; + app.autoplay_view_depth = app.view_stack.len(); + app.autoplay_eof_latched = false; + app.autoplay_next_index = next_offline_playable(&entries, selected); + + app.mpv_now_playing = Some(e.name.clone()); + let url = e.path.to_string_lossy().to_string(); + + if let Some(mpv) = &app.mpv { + if let Err(e) = mpv_load_local(mpv, &url) { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = format!("mpv load failed: {e}"); + } else { + app.status = "mpv: playing (offline autoplay)".into(); + } + } else { + match spawn_mpv_local(&url) { + Ok(mpv) => { + let rx = start_mpv_poller(mpv.socket_path.clone()); + app.mpv_state_rx = Some(rx); + app.mpv = Some(mpv); + app.status = "mpv: playing (offline autoplay)".into(); + } + Err(e) => { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = format!("mpv spawn failed: {e}"); + } + } + } + return; + } + + _ => { + app.status = "Nothing to autoplay here".into(); + return; } } - return; } + + KeyCode::Char('r') => { app.autoplay = false; app.autoplay_next_index = None; @@ -2284,63 +2351,126 @@ fn autoplay_tick(app: &mut App) { } }; - let (next_id, next_name, len) = match app.current_view() { - View::Library { items, .. } if next_i < items.len() => ( - items[next_i].id.clone(), - items[next_i].name.clone(), - items.len(), - ), - _ => { - app.autoplay = false; - app.autoplay_next_index = None; + // Choose next target based on current view + enum NextTarget { + Online { id: String, name: String, len: usize }, + Offline { path: PathBuf, name: String, len: usize }, + } + + let next = match app.current_view() { + View::Library { items, .. } if next_i < items.len() => { + NextTarget::Online { + id: items[next_i].id.clone(), + name: items[next_i].name.clone(), + len: items.len(), + } + } + View::OfflineLibrary { entries, .. } if next_i < entries.len() => { + // skip dirs: if next_i lands on a dir, find the next file + let mut j = next_i; + while j < entries.len() && entries[j].is_dir { + j += 1; + } + if j >= entries.len() { + // no more playable files + app.autoplay = false; + app.autoplay_next_index = None; + if !app.debug_mpv { app.status = "$".into(); } + return; + } + + NextTarget::Offline { + path: entries[j].path.clone(), + name: entries[j].name.clone(), + len: entries.len(), + } + } + _ => { + app.autoplay = false; + app.autoplay_next_index = None; + if !app.debug_mpv { app.status = "$".into(); } + return; + } + }; + + match &next { + NextTarget::Online { name, .. } => app.mpv_now_playing = Some(name.clone()), + NextTarget::Offline { name, .. } => app.mpv_now_playing = Some(name.clone()), + } + + // Move selection highlight in UI + compute following index + match app.view_stack.last_mut() { + Some(View::Library { selected, .. }) => { + *selected = next_i; + } + Some(View::OfflineLibrary { selected, entries, .. }) => { + // if we skipped dirs, move selection to actual file index + let mut j = next_i; + while j < entries.len() && entries[j].is_dir { j += 1; } + *selected = j; + } + _ => {} + } + + // compute next index (offline should skip dirs too later) + let len = match &next { + NextTarget::Online { len, .. } => *len, + NextTarget::Offline { len, .. } => *len, + }; + app.autoplay_next_index = if next_i + 1 < len { Some(next_i + 1) } else { None }; + + // Load next + match next { + NextTarget::Online { id: next_id, .. } => { + let cfg = match app.config.clone() { + Some(c) => c, + None => { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = "No config".into(); + return; + } + }; + + // If offline, MUST be local + let local = crate::core::downloads::local_media_path_for_item_id(&next_id); + if app.offline && local.is_none() { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = "Offline: next item not downloaded".into(); + return; + } + + let url = if let Some(p) = local { + p.to_string_lossy().to_string() + } else { + build_jellyfin_play_url(&cfg, &next_id) + }; + + if let Err(e) = mpv_load_url(&cfg, mpv, &url) { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = format!("autoplay load failed: {e}"); + return; + } + } + + NextTarget::Offline { path, .. } => { + let url = path.to_string_lossy().to_string(); + if let Err(e) = mpv_load_local(mpv, &url) { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = format!("offline autoplay load failed: {e}"); + return; + } + } + } + + let _ = mpv_set_pause(mpv, false); + app.autoplay_prev_time_pos = None; + if !app.debug_mpv { app.status = "$".into(); - return; } - }; - - app.mpv_now_playing = Some(next_name); - - - // Move selection highlight in UI - if let Some(View::Library { selected, .. }) = app.view_stack.last_mut() { - *selected = next_i; - } - - app.autoplay_next_index = if next_i + 1 < len { Some(next_i + 1) } else { None }; - - let cfg = match app.config.clone() { - Some(c) => c, - None => { - app.autoplay = false; - app.autoplay_next_index = None; - app.status = "No config".into(); - return; - } - }; - - // Load next item (prefer local if available) - let url = if let Some(p) = crate::core::downloads::local_media_path_for_item_id(&next_id) { - p.to_string_lossy().to_string() - } else { - build_jellyfin_play_url(&cfg, &next_id) - }; - - if let Err(e) = mpv_load_url(&cfg, mpv, &url) { - app.autoplay = false; - app.autoplay_next_index = None; - app.status = format!("autoplay load failed: {e}"); - return; - } - - // Ensure playback resumes - let _ = mpv_set_pause(mpv, false); - - // Reset tracking for new file - app.autoplay_prev_time_pos = None; - - if !app.debug_mpv { - app.status = "$".into(); - } } fn mpv_set_pause(mpv: &MpvSession, paused: bool) -> Result<(), String> { @@ -2701,6 +2831,55 @@ fn render_downloads_panel(frame: &mut Frame, app: &App, area: Rect) { frame.render_stateful_widget(list, area, &mut state); } +fn next_offline_playable(entries: &[OfflineEntry], from_index: usize) -> Option { + if entries.is_empty() { + return None; + } + let mut i = from_index + 1; + while i < entries.len() { + if !entries[i].is_dir { + return Some(i); + } + i += 1; + } + None +} + +fn spawn_mpv_local(url: &str) -> Result { + let sock = mpv_socket_path(); + let _ = std::fs::remove_file(&sock); + + let mut child = Command::new("mpv") + .arg(url) + .arg("--force-window=yes") + .arg("--keep-open=yes") + .arg("--idle=yes") + .arg(format!("--input-ipc-server={}", sock.display())) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("Failed to spawn mpv: {e}"))?; + + let start = Instant::now(); + while start.elapsed() < Duration::from_millis(800) { + if sock.exists() { + return Ok(MpvSession { socket_path: sock, child }); + } + thread::sleep(Duration::from_millis(10)); + } + + let _ = child.kill(); + Err("mpv IPC socket did not appear".to_string()) +} + +fn mpv_load_local(mpv: &MpvSession, url: &str) -> Result<(), String> { + mpv_cmd( + &mpv.socket_path, + &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url), + ) +} + + // Returns e.g. "56% (4:32) " // If total is unknown, returns "--% (--:--) " fn format_download_pct_eta(downloaded: u64, total: Option, added_at: Instant) -> String {