autoplay fixed

This commit is contained in:
2026-02-14 21:33:20 -06:00
parent 22dab3e3ee
commit 6223bb88b4
3 changed files with 309 additions and 36 deletions

1
Cargo.lock generated
View File

@@ -174,6 +174,7 @@ dependencies = [
"ratatui", "ratatui",
"reqwest", "reqwest",
"serde", "serde",
"serde_json",
] ]
[[package]] [[package]]

View File

@@ -8,5 +8,6 @@ authors = ["Christopher Warren Johnson III"]
ratatui = "0.30.0" ratatui = "0.30.0"
color-eyre = "0.6.3" color-eyre = "0.6.3"
crossterm = "0.29.0" crossterm = "0.29.0"
serde_json ="1"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }

View File

@@ -3,15 +3,18 @@ mod core;
const PAGE_SIZE: usize = 50; const PAGE_SIZE: usize = 50;
use std::{ use std::{
io::{self, Write, BufRead, BufReader}, io::{self, Write, Read, BufRead, BufReader},
path::PathBuf, path::PathBuf,
process::{Child, Command, Stdio}, process::{Child, Command, Stdio},
time::{Duration, Instant}, time::{Duration, Instant},
thread, thread,
}; };
use std::sync::{Arc, Mutex, mpsc};
use std::os::unix::net::UnixStream; use std::os::unix::net::UnixStream;
use serde_json::Value;
use crossterm::{ use crossterm::{
event::{self, Event, KeyCode, KeyEventKind}, event::{self, Event, KeyCode, KeyEventKind},
@@ -43,6 +46,16 @@ enum View {
Login, Login,
} }
#[derive(Clone, Debug, Default)]
struct MpvState {
eof: bool,
idle: bool,
time_pos: Option<f64>,
duration: Option<f64>,
percent: Option<f64>,
last_err: Option<String>,
}
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
enum LoginFocus { enum LoginFocus {
User, User,
@@ -152,10 +165,16 @@ struct App {
search: Option<SearchState>, search: Option<SearchState>,
mpv: Option<MpvSession>, mpv: Option<MpvSession>,
mpv_poll_last: Instant,
mpv_state: MpvState,
mpv_state_rx: Option<mpsc::Receiver<MpvState>>,
debug_mpv: bool,
autoplay: bool, autoplay: bool,
autoplay_view_depth: usize, autoplay_view_depth: usize,
autoplay_next_index: Option<usize>, autoplay_next_index: Option<usize>,
autoplay_eof_latched: bool,
autoplay_prev_time_pos: Option<f64>,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -211,9 +230,15 @@ impl Default for App {
login_error: None, login_error: None,
search: None, search: None,
mpv: None, mpv: None,
mpv_poll_last: Instant::now(),
mpv_state: MpvState::default(),
mpv_state_rx: None,
debug_mpv: false,
autoplay: false, autoplay: false,
autoplay_view_depth: 0, autoplay_view_depth: 0,
autoplay_next_index: None, autoplay_next_index: None,
autoplay_eof_latched: false,
autoplay_prev_time_pos: None,
} }
} }
} }
@@ -260,6 +285,16 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> 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) --- // --- mpv lifecycle (no borrow issues) ---
let mpv_exited = match app.mpv.as_mut() { let mpv_exited = match app.mpv.as_mut() {
Some(mpv) => match mpv.child.try_wait() { Some(mpv) => match mpv.child.try_wait() {
@@ -279,6 +314,8 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<
// Your requirement: window closing disables autoplay // Your requirement: window closing disables autoplay
app.autoplay = false; app.autoplay = false;
app.autoplay_next_index = None; 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) // 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) // mpv controls (only when not typing into search/login)
if app.search.is_none() && !matches!(app.current_view(), View::Login) { if app.search.is_none() && !matches!(app.current_view(), View::Login) {
match code { match code {
@@ -537,7 +580,12 @@ fn handle_key(app: &mut App, code: KeyCode) {
} }
} else { } else {
match spawn_mpv(&cfg, &item_id) { 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}"), 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.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_view_depth = app.view_stack.len();
app.autoplay_next_index = if selected + 1 < len { Some(selected + 1) } else { None }; 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 { } else {
match spawn_mpv(&cfg, &item_id) { 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}"), Err(e) => app.status = format!("mpv spawn failed: {e}"),
} }
} }
@@ -1216,17 +1271,29 @@ fn mpv_request(socket: &PathBuf, json_line: &str) -> Result<String, String> {
let mut stream = UnixStream::connect(socket) let mut stream = UnixStream::connect(socket)
.map_err(|e| format!("mpv ipc connect failed: {e}"))?; .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 stream
.write_all(json_line.as_bytes()) .write_all(json_line.as_bytes())
.map_err(|e| format!("mpv ipc write failed: {e}"))?; .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 reader = BufReader::new(stream);
let mut line = String::new(); 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<bool, String> { fn mpv_get_bool(mpv: &MpvSession, prop: &str) -> Result<bool, String> {
@@ -1235,13 +1302,30 @@ fn mpv_get_bool(mpv: &MpvSession, prop: &str) -> Result<bool, String> {
&format!(r#"{{"command":["get_property","{}"]}}\n"#, prop), &format!(r#"{{"command":["get_property","{}"]}}\n"#, prop),
)?; )?;
// Response looks like: {"data":false,"error":"success"}\n let v: Value = serde_json::from_str(resp.trim())
if !resp.contains(r#""error":"success""#) { .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())); return Err(format!("mpv get_property failed: {}", resp.trim()));
} }
// Very small parser: good enough for booleans. Ok(v.get("data").and_then(|d| d.as_bool()).unwrap_or(false))
Ok(resp.contains(r#""data":true"#)) }
fn mpv_get_f64(mpv: &MpvSession, prop: &str) -> Result<Option<f64>, 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> { 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); let _ = std::fs::remove_file(&mpv.socket_path);
} }
fn mpv_get_prop_jtui(socket: &PathBuf, prop: &str, request_id: u32) -> Result<Value, String> {
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<Option<f64>, 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<bool, String> {
Ok(mpv_get_prop_jtui(socket, prop, request_id)?.as_bool().unwrap_or(false))
}
fn mpv_toggle_fullscreen(mpv: &MpvSession) -> Result<(), String> { fn mpv_toggle_fullscreen(mpv: &MpvSession) -> Result<(), String> {
// cycles fullscreen on/off // cycles fullscreen on/off
mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","fullscreen"]}"#) mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","fullscreen"]}"#)
@@ -1347,28 +1486,65 @@ fn autoplay_tick(app: &mut App) {
return; 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 { if app.view_stack.len() != app.autoplay_view_depth {
app.autoplay = false; app.autoplay = false;
app.autoplay_next_index = None; app.autoplay_next_index = None;
app.autoplay_eof_latched = false;
app.autoplay_prev_time_pos = None;
return; 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 { let mpv = match &app.mpv {
Some(m) => m, Some(m) => m,
None => return, 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 { let next_i = match app.autoplay_next_index {
Some(i) => i, Some(i) => i,
None => { 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() { let (next_id, len) = match app.current_view() {
View::Library { items, .. } if next_i < items.len() => (items[next_i].id.clone(), items.len()), 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() { if let Some(View::Library { selected, .. }) = app.view_stack.last_mut() {
*selected = next_i; *selected = next_i;
} }
// set following next index
app.autoplay_next_index = if next_i + 1 < len { Some(next_i + 1) } else { None }; app.autoplay_next_index = if next_i + 1 < len { Some(next_i + 1) } else { None };
// Load next item in the *same* mpv window let cfg = match app.config.clone() {
if let Some(cfg) = app.config.clone() { Some(c) => c,
if let Some(mpv) = &app.mpv { 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) { if let Err(e) = mpv_load_item(&cfg, mpv, &next_id) {
app.autoplay = false; app.autoplay = false;
app.autoplay_next_index = None; app.autoplay_next_index = None;
app.status = format!("autoplay load failed: {e}"); app.status = format!("autoplay load failed: {e}");
} else { 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(); 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<MpvState> {
let (tx, rx) = mpsc::channel::<MpvState>();
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 { struct Panes {
top: Rect, top: Rect,
left_main: Rect, left_main: Rect,