diff --git a/Cargo.lock b/Cargo.lock index 4d6608a..19ba6d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -174,6 +174,7 @@ dependencies = [ "ratatui", "reqwest", "serde", + "serde_json", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 67863f3..f3c956f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,5 +8,6 @@ authors = ["Christopher Warren Johnson III"] ratatui = "0.30.0" color-eyre = "0.6.3" crossterm = "0.29.0" +serde_json ="1" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } serde = { version = "1", features = ["derive"] } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 57e2c54..b8641a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,15 +3,18 @@ mod core; const PAGE_SIZE: usize = 50; use std::{ - io::{self, Write, BufRead, BufReader}, + io::{self, Write, Read, BufRead, BufReader}, path::PathBuf, process::{Child, Command, Stdio}, time::{Duration, Instant}, thread, }; +use std::sync::{Arc, Mutex, mpsc}; + use std::os::unix::net::UnixStream; +use serde_json::Value; use crossterm::{ event::{self, Event, KeyCode, KeyEventKind}, @@ -43,6 +46,16 @@ enum View { Login, } +#[derive(Clone, Debug, Default)] +struct MpvState { + eof: bool, + idle: bool, + time_pos: Option, + duration: Option, + percent: Option, + last_err: Option, +} + #[derive(Clone, Copy)] enum LoginFocus { User, @@ -152,10 +165,16 @@ struct App { search: Option, mpv: Option, + mpv_poll_last: Instant, + mpv_state: MpvState, + mpv_state_rx: Option>, + debug_mpv: bool, autoplay: bool, autoplay_view_depth: usize, autoplay_next_index: Option, + autoplay_eof_latched: bool, + autoplay_prev_time_pos: Option, } #[derive(Debug)] @@ -211,9 +230,15 @@ impl Default for App { login_error: None, search: None, mpv: None, + mpv_poll_last: Instant::now(), + mpv_state: MpvState::default(), + mpv_state_rx: None, + debug_mpv: false, autoplay: false, autoplay_view_depth: 0, autoplay_next_index: None, + autoplay_eof_latched: false, + autoplay_prev_time_pos: None, } } } @@ -260,6 +285,16 @@ fn run_app(terminal: &mut Terminal>) -> io::Result< } } + if let Some(rx) = &app.mpv_state_rx { + while let Ok(st) = rx.try_recv() { + app.mpv_state = st; + } + } + + if app.debug_mpv { + app.status = fmt_mpv_state(&app.mpv_state); + } + // --- mpv lifecycle (no borrow issues) --- let mpv_exited = match app.mpv.as_mut() { Some(mpv) => match mpv.child.try_wait() { @@ -279,6 +314,8 @@ fn run_app(terminal: &mut Terminal>) -> io::Result< // Your requirement: window closing disables autoplay app.autoplay = false; app.autoplay_next_index = None; + app.mpv_state_rx = None; + app.mpv_state = MpvState::default(); } // Option A: always tick autoplay by polling eof-reached (window stays open) @@ -457,6 +494,12 @@ fn handle_key(app: &mut App, code: KeyCode) { } } + if matches!(code, KeyCode::Char('D')) { + app.debug_mpv = !app.debug_mpv; + app.status = format!("mpv debug: {}", if app.debug_mpv { "ON" } else { "OFF" }); + return; + } + // mpv controls (only when not typing into search/login) if app.search.is_none() && !matches!(app.current_view(), View::Login) { match code { @@ -537,7 +580,12 @@ fn handle_key(app: &mut App, code: KeyCode) { } } else { match spawn_mpv(&cfg, &item_id) { - Ok(mpv) => { app.mpv = Some(mpv); app.status = "mpv: playing".into(); } + 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.status = format!("mpv spawn failed: {e}"), } } @@ -554,6 +602,8 @@ fn handle_key(app: &mut App, code: KeyCode) { }; 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 }; @@ -565,7 +615,12 @@ fn handle_key(app: &mut App, code: KeyCode) { } } else { match spawn_mpv(&cfg, &item_id) { - Ok(mpv) => { app.mpv = Some(mpv); app.status = "mpv: playing (autoplay)".into(); } + 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.status = format!("mpv spawn failed: {e}"), } } @@ -1216,17 +1271,29 @@ fn mpv_request(socket: &PathBuf, json_line: &str) -> Result { let mut stream = UnixStream::connect(socket) .map_err(|e| format!("mpv ipc connect failed: {e}"))?; + // Prevent UI hangs if mpv doesn't respond + let _ = stream.set_write_timeout(Some(Duration::from_millis(40))); + let _ = stream.set_read_timeout(Some(Duration::from_millis(40))); + stream .write_all(json_line.as_bytes()) .map_err(|e| format!("mpv ipc write failed: {e}"))?; - // mpv replies with one JSON line per request + let mut reader = BufReader::new(stream); let mut line = String::new(); - reader - .read_line(&mut line) - .map_err(|e| format!("mpv ipc read failed: {e}"))?; - Ok(line) + match reader.read_line(&mut line) { + Ok(0) => Err("mpv ipc: EOF".to_string()), + Ok(_) => Ok(line), + Err(e) => { + // timeout is normal; just treat it as "no data yet" + if e.kind() == io::ErrorKind::WouldBlock || e.kind() == io::ErrorKind::TimedOut { + Err("mpv ipc timeout".to_string()) + } else { + Err(format!("mpv ipc read failed: {e}")) + } + } + } } fn mpv_get_bool(mpv: &MpvSession, prop: &str) -> Result { @@ -1235,13 +1302,30 @@ fn mpv_get_bool(mpv: &MpvSession, prop: &str) -> Result { &format!(r#"{{"command":["get_property","{}"]}}\n"#, prop), )?; - // Response looks like: {"data":false,"error":"success"}\n - if !resp.contains(r#""error":"success""#) { + let v: Value = serde_json::from_str(resp.trim()) + .map_err(|e| format!("mpv json parse failed: {e} ({})", resp.trim()))?; + + if v.get("error").and_then(|e| e.as_str()) != Some("success") { return Err(format!("mpv get_property failed: {}", resp.trim())); } - // Very small parser: good enough for booleans. - Ok(resp.contains(r#""data":true"#)) + Ok(v.get("data").and_then(|d| d.as_bool()).unwrap_or(false)) +} + +fn mpv_get_f64(mpv: &MpvSession, prop: &str) -> Result, String> { + let resp = mpv_request( + &mpv.socket_path, + &format!(r#"{{"command":["get_property","{}"]}}\n"#, prop), + )?; + + let v: Value = serde_json::from_str(resp.trim()) + .map_err(|e| format!("mpv json parse failed: {e} ({})", resp.trim()))?; + + if v.get("error").and_then(|e| e.as_str()) != Some("success") { + return Err(format!("mpv get_property failed: {}", resp.trim())); + } + + Ok(v.get("data").and_then(|d| d.as_f64())) } fn mpv_send(socket: &PathBuf, json_line: &str) -> Result<(), String> { @@ -1331,6 +1415,61 @@ fn mpv_stop(mpv: &mut MpvSession) { let _ = std::fs::remove_file(&mpv.socket_path); } +fn mpv_get_prop_jtui(socket: &PathBuf, prop: &str, request_id: u32) -> Result { + let mut stream = UnixStream::connect(socket) + .map_err(|e| format!("mpv ipc connect failed: {e}"))?; + + // Keep this simple like jtui + let _ = stream.set_write_timeout(Some(Duration::from_millis(250))); + let _ = stream.set_read_timeout(Some(Duration::from_millis(250))); + + // jtui style: include request_id, newline terminated + let cmd = format!( + r#"{{"command":["get_property","{}"],"request_id":{}}}"#, + prop, request_id + ); + stream + .write_all(format!("{cmd}\n").as_bytes()) + .map_err(|e| format!("mpv ipc write failed: {e}"))?; + + // jtui style: raw read into fixed buffer + let mut buf = [0u8; 1024]; + let n = stream + .read(&mut buf) + .map_err(|e| format!("mpv ipc read failed: {e}"))?; + + if n == 0 { + return Err("mpv ipc: EOF".into()); + } + + // If mpv returns more than one JSON line in one read, cut at first newline. + let slice = &buf[..n]; + let upto = slice + .iter() + .position(|&b| b == b'\n') + .unwrap_or(slice.len()); + let slice = &slice[..upto]; + + let v: Value = serde_json::from_slice(slice) + .map_err(|e| format!("mpv json parse failed: {e} ({})", String::from_utf8_lossy(slice)))?; + + // mpv replies have {"error":"success","data":...} + if v.get("error").and_then(|e| e.as_str()) != Some("success") { + return Err(format!("mpv get_property({prop}) failed: {v}")); + } + + Ok(v.get("data").cloned().unwrap_or(Value::Null)) +} + +fn mpv_get_f64_jtui(socket: &PathBuf, prop: &str, request_id: u32) -> Result, String> { + Ok(mpv_get_prop_jtui(socket, prop, request_id)?.as_f64()) +} + +fn mpv_get_bool_jtui(socket: &PathBuf, prop: &str, request_id: u32) -> Result { + Ok(mpv_get_prop_jtui(socket, prop, request_id)?.as_bool().unwrap_or(false)) +} + + fn mpv_toggle_fullscreen(mpv: &MpvSession) -> Result<(), String> { // cycles fullscreen on/off mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","fullscreen"]}"#) @@ -1347,28 +1486,65 @@ fn autoplay_tick(app: &mut App) { return; } - // Must still be on the same "list" view depth we started autoplay from + // If user navigated away from the view where autoplay started, cancel it. if app.view_stack.len() != app.autoplay_view_depth { app.autoplay = false; app.autoplay_next_index = None; + app.autoplay_eof_latched = false; + app.autoplay_prev_time_pos = None; return; } + // Snapshot latest mpv state from poller thread + let st = app.mpv_state.clone(); + + // Optional: live debug in status (toggle with 'D') + if app.debug_mpv { + app.status = fmt_mpv_state(&st); + } + + // Track previous time to detect "snapped back to 0" + if let Some(tp) = st.time_pos { + if tp > 0.25 { + app.autoplay_prev_time_pos = Some(tp); + } + } + + let near_end = match (st.time_pos, st.duration) { + (Some(tp), Some(d)) if d.is_finite() && d > 1.0 => (d - tp) <= 0.50, + _ => false, + }; + + let near_100 = match st.percent { + Some(p) if p.is_finite() => p >= 99.8, + _ => false, + }; + + let snapped_to_start_idle = match (st.idle, st.time_pos, app.autoplay_prev_time_pos) { + (true, Some(tp), Some(prev)) if prev > 1.0 && tp < 0.10 => true, + _ => false, + }; + + let finished = st.eof || near_end || near_100 || snapped_to_start_idle; + + // If not finished, clear latch so we can trigger when it *does* finish later. + if !finished { + app.autoplay_eof_latched = false; + return; + } + + // Only trigger once per finish event + if app.autoplay_eof_latched { + return; + } + app.autoplay_eof_latched = true; + + // Need mpv running to advance let mpv = match &app.mpv { Some(m) => m, None => return, }; - // Only advance when mpv reports EOF reached - let eof = match mpv_get_bool(mpv, "eof-reached") { - Ok(v) => v, - Err(_e) => return, // don't spam status; just skip this tick - }; - - if !eof { - return; - } - let next_i = match app.autoplay_next_index { Some(i) => i, None => { @@ -1378,7 +1554,6 @@ fn autoplay_tick(app: &mut App) { } }; - // Grab next id from current library view let (next_id, len) = match app.current_view() { View::Library { items, .. } if next_i < items.len() => (items[next_i].id.clone(), items.len()), _ => { @@ -1389,28 +1564,124 @@ fn autoplay_tick(app: &mut App) { } }; - // update selection so UI follows playback + // Move selection highlight in UI if let Some(View::Library { selected, .. }) = app.view_stack.last_mut() { *selected = next_i; } - // set following next index app.autoplay_next_index = if next_i + 1 < len { Some(next_i + 1) } else { None }; - // Load next item in the *same* mpv window - if let Some(cfg) = app.config.clone() { - if let Some(mpv) = &app.mpv { - if let Err(e) = mpv_load_item(&cfg, mpv, &next_id) { - app.autoplay = false; - app.autoplay_next_index = None; - app.status = format!("autoplay load failed: {e}"); - } else { - app.status = "autoplay next".into(); - } + 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 + if let Err(e) = mpv_load_item(&cfg, mpv, &next_id) { + 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 = "autoplay next".into(); } } +fn mpv_set_pause(mpv: &MpvSession, paused: bool) -> Result<(), String> { + // pause is a boolean property in mpv JSON IPC + let v = if paused { "true" } else { "false" }; + mpv_cmd( + &mpv.socket_path, + &format!(r#"{{"command":["set_property","pause",{}]}}"#, v), + ) +} + +fn fmt_mpv_state(st: &MpvState) -> String { + let pct = match st.percent { + Some(p) if p.is_finite() => p, + _ => match (st.time_pos, st.duration) { + (Some(t), Some(d)) if t.is_finite() && d.is_finite() && d > 0.0 => (t / d) * 100.0, + _ => 0.0, + }, + }; + + format!("{pct:5.1}%") +} + +fn start_mpv_poller(socket_path: PathBuf) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel::(); + + std::thread::spawn(move || { + let mut request_id: u32 = 1; + + loop { + let mut st = MpvState::default(); + + // jtui pattern: connect->write->read(1024)->parse->close (per property call) + let tp = mpv_get_f64_jtui(&socket_path, "time-pos", request_id).ok().flatten(); + request_id = request_id.wrapping_add(1); + + let dur = mpv_get_f64_jtui(&socket_path, "duration", request_id).ok().flatten(); + request_id = request_id.wrapping_add(1); + + let eof = mpv_get_bool_jtui(&socket_path, "eof-reached", request_id).unwrap_or(false); + request_id = request_id.wrapping_add(1); + + let idle = mpv_get_bool_jtui(&socket_path, "idle-active", request_id).unwrap_or(false); + request_id = request_id.wrapping_add(1); + + st.time_pos = tp; + st.duration = dur; + st.eof = eof; + st.idle = idle; + + // compute percent yourself (jtui style) + st.percent = match (tp, dur) { + (Some(t), Some(d)) if t.is_finite() && d.is_finite() && d > 0.0 => Some((t / d) * 100.0), + _ => None, + }; + + // Optional: populate last_err if we got nothing useful + if st.time_pos.is_none() && st.duration.is_none() { + st.last_err = Some("mpv poll: no time-pos/duration (check socket / mpv running)".into()); + } + + if tx.send(st).is_err() { + break; + } + + std::thread::sleep(Duration::from_millis(200)); + } + }); + + rx +} + + + + + + + + + + + + + struct Panes { top: Rect, left_main: Rect,