half way working autoplay

This commit is contained in:
2026-02-14 17:57:20 -06:00
parent 529ea7b49a
commit 22dab3e3ee

View File

@@ -3,7 +3,7 @@ mod core;
const PAGE_SIZE: usize = 50; const PAGE_SIZE: usize = 50;
use std::{ use std::{
io::{self, Write}, io::{self, Write, BufRead, BufReader},
path::PathBuf, path::PathBuf,
process::{Child, Command, Stdio}, process::{Child, Command, Stdio},
time::{Duration, Instant}, time::{Duration, Instant},
@@ -152,6 +152,10 @@ struct App {
search: Option<SearchState>, search: Option<SearchState>,
mpv: Option<MpvSession>, mpv: Option<MpvSession>,
autoplay: bool,
autoplay_view_depth: usize,
autoplay_next_index: Option<usize>,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -207,6 +211,9 @@ impl Default for App {
login_error: None, login_error: None,
search: None, search: None,
mpv: None, mpv: None,
autoplay: false,
autoplay_view_depth: 0,
autoplay_next_index: None,
} }
} }
} }
@@ -253,14 +260,31 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<
} }
} }
if let Some(mpv) = &mut app.mpv { // --- mpv lifecycle (no borrow issues) ---
if let Ok(Some(status)) = mpv.child.try_wait() { let mpv_exited = match app.mpv.as_mut() {
app.status = format!("mpv exited: {status}"); Some(mpv) => match mpv.child.try_wait() {
Ok(Some(status)) => Some(status),
_ => None,
},
None => None,
};
if let Some(status) = mpv_exited {
// take ownership so we can clean up
if let Some(mpv) = app.mpv.take() {
let _ = std::fs::remove_file(&mpv.socket_path); let _ = std::fs::remove_file(&mpv.socket_path);
app.mpv = None; app.status = format!("mpv exited: {status}");
} }
// Your requirement: window closing disables autoplay
app.autoplay = false;
app.autoplay_next_index = None;
} }
// Option A: always tick autoplay by polling eof-reached (window stays open)
autoplay_tick(&mut app);
if last_tick.elapsed() >= tick_rate { if last_tick.elapsed() >= tick_rate {
// tick-based updates go here later (smooth nav, download polling, etc.) // tick-based updates go here later (smooth nav, download polling, etc.)
last_tick = Instant::now(); last_tick = Instant::now();
@@ -418,9 +442,10 @@ fn handle_key(app: &mut App, code: KeyCode) {
other => other, other => other,
}; };
// q behavior: if not home, go home; if home, quit // q behavior: if not home, go home; if home, quit
if matches!(code, KeyCode::Char('q')) { if matches!(code, KeyCode::Char('q')) {
app.autoplay = false;
app.autoplay_next_index = None;
if !matches!(app.current_view(), View::Categories) { if !matches!(app.current_view(), View::Categories) {
app.view_stack = vec![View::Categories]; app.view_stack = vec![View::Categories];
app.selected = 0; app.selected = 0;
@@ -460,6 +485,8 @@ fn handle_key(app: &mut App, code: KeyCode) {
return; return;
} }
KeyCode::Char('x') => { KeyCode::Char('x') => {
app.autoplay = false;
app.autoplay_next_index = None;
if let Some(mut mpv) = app.mpv.take() { if let Some(mut mpv) = app.mpv.take() {
mpv_stop(&mut mpv); mpv_stop(&mut mpv);
app.status = "Stopped mpv".into(); app.status = "Stopped mpv".into();
@@ -487,6 +514,9 @@ fn handle_key(app: &mut App, code: KeyCode) {
return; return;
} }
KeyCode::Char('p') => { KeyCode::Char('p') => {
app.autoplay = false;
app.autoplay_next_index = None;
// p = play selected item (spawn or replace) // p = play selected item (spawn or replace)
let cfg = match &app.config { let cfg = match &app.config {
Some(c) => c.clone(), Some(c) => c.clone(),
@@ -513,6 +543,34 @@ fn handle_key(app: &mut App, code: KeyCode) {
} }
return; return;
} }
KeyCode::Char('P') => {
let cfg = match &app.config { Some(c) => c.clone(), None => { app.status="No config".into(); return; } };
let (item_id, selected, len) = match app.current_view() {
View::Library { items, selected, .. } if !items.is_empty() => {
(items[*selected].id.clone(), *selected, items.len())
}
_ => { app.status = "Nothing to play here".into(); return; }
};
app.autoplay = true;
app.autoplay_view_depth = app.view_stack.len();
app.autoplay_next_index = if selected + 1 < len { Some(selected + 1) } else { None };
// load/replace or spawn
if let Some(mpv) = &app.mpv {
match mpv_load_item(&cfg, mpv, &item_id) {
Ok(_) => app.status = "mpv: playing (autoplay)".into(),
Err(e) => app.status = format!("mpv load failed: {e}"),
}
} else {
match spawn_mpv(&cfg, &item_id) {
Ok(mpv) => { app.mpv = Some(mpv); app.status = "mpv: playing (autoplay)".into(); }
Err(e) => app.status = format!("mpv spawn failed: {e}"),
}
}
return;
}
_ => {} _ => {}
} }
} }
@@ -1154,6 +1212,38 @@ fn mpv_socket_path() -> PathBuf {
p p
} }
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}"))?;
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)
}
fn mpv_get_bool(mpv: &MpvSession, prop: &str) -> Result<bool, String> {
let resp = mpv_request(
&mpv.socket_path,
&format!(r#"{{"command":["get_property","{}"]}}\n"#, prop),
)?;
// Response looks like: {"data":false,"error":"success"}\n
if !resp.contains(r#""error":"success""#) {
return Err(format!("mpv get_property failed: {}", resp.trim()));
}
// Very small parser: good enough for booleans.
Ok(resp.contains(r#""data":true"#))
}
fn mpv_send(socket: &PathBuf, json_line: &str) -> Result<(), String> { fn mpv_send(socket: &PathBuf, json_line: &str) -> Result<(), 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}"))?;
@@ -1252,6 +1342,75 @@ fn mpv_cycle_pause(mpv: &MpvSession) -> Result<(), String> {
mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","pause"]}"#) mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","pause"]}"#)
} }
fn autoplay_tick(app: &mut App) {
if !app.autoplay {
return;
}
// Must still be on the same "list" view depth we started autoplay from
if app.view_stack.len() != app.autoplay_view_depth {
app.autoplay = false;
app.autoplay_next_index = None;
return;
}
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 => {
app.autoplay = false;
app.status = "autoplay finished".into();
return;
}
};
// 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()),
_ => {
app.autoplay = false;
app.autoplay_next_index = None;
app.status = "autoplay stopped".into();
return;
}
};
// update selection so UI follows playback
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();
}
}
}
}
struct Panes { struct Panes {
top: Rect, top: Rect,
left_main: Rect, left_main: Rect,