autoplay fixed
This commit is contained in:
343
src/main.rs
343
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<f64>,
|
||||
duration: Option<f64>,
|
||||
percent: Option<f64>,
|
||||
last_err: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum LoginFocus {
|
||||
User,
|
||||
@@ -152,10 +165,16 @@ struct App {
|
||||
|
||||
search: Option<SearchState>,
|
||||
mpv: Option<MpvSession>,
|
||||
mpv_poll_last: Instant,
|
||||
mpv_state: MpvState,
|
||||
mpv_state_rx: Option<mpsc::Receiver<MpvState>>,
|
||||
debug_mpv: bool,
|
||||
|
||||
autoplay: bool,
|
||||
autoplay_view_depth: usize,
|
||||
autoplay_next_index: Option<usize>,
|
||||
autoplay_eof_latched: bool,
|
||||
autoplay_prev_time_pos: Option<f64>,
|
||||
}
|
||||
|
||||
#[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<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) ---
|
||||
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<CrosstermBackend<io::Stdout>>) -> 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<String, String> {
|
||||
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<bool, String> {
|
||||
@@ -1235,13 +1302,30 @@ fn mpv_get_bool(mpv: &MpvSession, prop: &str) -> Result<bool, String> {
|
||||
&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<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> {
|
||||
@@ -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<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> {
|
||||
// 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<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 {
|
||||
top: Rect,
|
||||
left_main: Rect,
|
||||
|
||||
Reference in New Issue
Block a user