almost finished mpv interface

This commit is contained in:
2026-02-15 01:41:41 -06:00
parent c54c4e148d
commit fb0de69a51

View File

@@ -24,7 +24,7 @@ use crossterm::{
use ratatui::{ use ratatui::{
layout::{Constraint, Layout, Rect, Spacing}, layout::{Constraint, Layout, Rect, Spacing},
symbols::merge::MergeStrategy, symbols::merge::MergeStrategy,
widgets::{Block, List, ListItem, ListState, Paragraph, Wrap}, widgets::{Block, List, ListItem, ListState, Paragraph, Wrap, Gauge},
style::{Modifier, Style}, style::{Modifier, Style},
Frame, Terminal, Frame, Terminal,
}; };
@@ -54,6 +54,11 @@ struct MpvState {
duration: Option<f64>, duration: Option<f64>,
percent: Option<f64>, percent: Option<f64>,
last_err: Option<String>, last_err: Option<String>,
// new:
media_title: Option<String>,
audio: Option<String>,
subs: Option<String>,
} }
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -169,6 +174,7 @@ struct App {
mpv_state: MpvState, mpv_state: MpvState,
mpv_state_rx: Option<mpsc::Receiver<MpvState>>, mpv_state_rx: Option<mpsc::Receiver<MpvState>>,
debug_mpv: bool, debug_mpv: bool,
mpv_now_playing: Option<String>,
autoplay: bool, autoplay: bool,
autoplay_view_depth: usize, autoplay_view_depth: usize,
@@ -234,6 +240,7 @@ impl Default for App {
mpv_state: MpvState::default(), mpv_state: MpvState::default(),
mpv_state_rx: None, mpv_state_rx: None,
debug_mpv: false, debug_mpv: false,
mpv_now_playing: None,
autoplay: false, autoplay: false,
autoplay_view_depth: 0, autoplay_view_depth: 0,
autoplay_next_index: None, autoplay_next_index: None,
@@ -314,6 +321,7 @@ 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_now_playing = None;
app.mpv_state_rx = None; app.mpv_state_rx = None;
app.mpv_state = MpvState::default(); app.mpv_state = MpvState::default();
} }
@@ -567,11 +575,15 @@ fn handle_key(app: &mut App, code: KeyCode) {
}; };
// Determine what is “selected” depending on view // Determine what is “selected” depending on view
let item_id = match app.current_view() { let (item_id, item_name) = match app.current_view() {
View::Library { items, selected, .. } if !items.is_empty() => items[*selected].id.clone(), View::Library { items, selected, .. } if !items.is_empty() => {
(items[*selected].id.clone(), items[*selected].name.clone())
}
_ => { app.status = "Nothing to play here".into(); return; } _ => { app.status = "Nothing to play here".into(); return; }
}; };
app.mpv_now_playing = Some(item_name);
// If mpv running -> replace, else spawn // If mpv running -> replace, else spawn
if let Some(mpv) = &app.mpv { if let Some(mpv) = &app.mpv {
match mpv_load_item(&cfg, mpv, &item_id) { match mpv_load_item(&cfg, mpv, &item_id) {
@@ -594,13 +606,15 @@ fn handle_key(app: &mut App, code: KeyCode) {
KeyCode::Char('P') => { KeyCode::Char('P') => {
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, selected, len) = match app.current_view() { let (item_id, item_name, selected, len) = match app.current_view() {
View::Library { items, selected, .. } if !items.is_empty() => { View::Library { items, selected, .. } if !items.is_empty() => {
(items[*selected].id.clone(), *selected, items.len()) (items[*selected].id.clone(), items[*selected].name.clone(), *selected, items.len())
} }
_ => { app.status = "Nothing to play here".into(); return; } _ => { app.status = "Nothing to play here".into(); return; }
}; };
app.mpv_now_playing = Some(item_name);
app.autoplay = true; app.autoplay = true;
app.status = format!("autoplay ON (next index: {:?})", app.autoplay_next_index); app.status = format!("autoplay ON (next index: {:?})", app.autoplay_next_index);
app.autoplay_prev_time_pos = None; app.autoplay_prev_time_pos = None;
@@ -1186,37 +1200,32 @@ fn ui(frame: &mut Frame, app: &App) {
// Bottom Left // Bottom Left
// ---- Bottom Left: prompt / mpv ---- // ---- Bottom Left: prompt / mpv ----
let left_text = if let Some(s) = &app.search { // Bottom Left: Search (temporary) > MPV (persistent) > "$"
// search takes over temporarily if let Some(s) = &app.search {
let scope = match &s.scope { let scope = match &s.scope {
SearchScope::All => "All".to_string(), SearchScope::All => "All".to_string(),
SearchScope::Library { title, .. } => title.clone(), SearchScope::Library { title, .. } => title.clone(),
}; };
let mode = s.mode.label(); let mode = s.mode.label();
if s.input.is_empty() { let line = if s.input.is_empty() {
format!("/ ({scope}) [{mode}]") format!("/ ({scope}) [{mode}]")
} else { } else {
format!("/ ({scope}) [{mode}] {}", s.input) format!("/ ({scope}) [{mode}] {}", s.input)
} };
} else if app.mpv.is_some() {
// mpv persists while browsing
if app.debug_mpv {
fmt_mpv_state(&app.mpv_state)
} else if let Some(p) = app.mpv_state.percent {
if p.is_finite() { format!("mpv: {p:5.1}%") } else { "mpv: playing".into() }
} else {
"mpv: playing".into()
}
} else {
"$".into()
};
frame.render_widget( frame.render_widget(
Paragraph::new(left_text) Paragraph::new(line).block(Block::bordered().merge_borders(MergeStrategy::Exact)),
// no title panes.left_output,
.block(Block::bordered().merge_borders(MergeStrategy::Exact)), );
panes.left_output, } else if app.mpv.is_some() {
); // mpv panel takes over the whole bottom-left area
render_mpv_panel(frame, app, panes.left_output);
} else {
frame.render_widget(
Paragraph::new("$").block(Block::bordered().merge_borders(MergeStrategy::Exact)),
panes.left_output,
);
}
// ---- Bottom Right: always-on status/help ---- // ---- Bottom Right: always-on status/help ----
let connected = if app.config.as_ref().and_then(|c| c.access_token.as_ref()).is_some() { let connected = if app.config.as_ref().and_then(|c| c.access_token.as_ref()).is_some() {
@@ -1596,41 +1605,32 @@ fn mpv_get_prop_jtui(socket: &PathBuf, prop: &str, request_id: u32) -> Result<Va
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}"))?;
// Keep this simple like jtui
let _ = stream.set_write_timeout(Some(Duration::from_millis(250))); let _ = stream.set_write_timeout(Some(Duration::from_millis(250)));
let _ = stream.set_read_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!( let cmd = format!(
r#"{{"command":["get_property","{}"],"request_id":{}}}"#, r#"{{"command":["get_property","{}"],"request_id":{}}}"#,
prop, request_id prop, request_id
); );
stream stream
.write_all(format!("{cmd}\n").as_bytes()) .write_all(format!("{cmd}\n").as_bytes())
.map_err(|e| format!("mpv ipc write failed: {e}"))?; .map_err(|e| format!("mpv ipc write failed: {e}"))?;
// jtui style: raw read into fixed buffer // IMPORTANT: read a whole JSON line (track-list can be >1024 bytes)
let mut buf = [0u8; 1024]; let mut reader = BufReader::new(stream);
let n = stream let mut line = String::new();
.read(&mut buf) reader
.read_line(&mut line)
.map_err(|e| format!("mpv ipc read failed: {e}"))?; .map_err(|e| format!("mpv ipc read failed: {e}"))?;
if n == 0 { if line.trim().is_empty() {
return Err("mpv ipc: EOF".into()); return Err("mpv ipc: empty response".into());
} }
// If mpv returns more than one JSON line in one read, cut at first newline. let v: Value = serde_json::from_str(line.trim())
let slice = &buf[..n]; .map_err(|e| format!("mpv json parse failed: {e} ({})", line.trim()))?;
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") { if v.get("error").and_then(|e| e.as_str()) != Some("success") {
return Err(format!("mpv get_property({prop}) failed: {v}")); return Err(format!("mpv get_property({prop}) failed: {v}"));
} }
@@ -1638,6 +1638,34 @@ fn mpv_get_prop_jtui(socket: &PathBuf, prop: &str, request_id: u32) -> Result<Va
Ok(v.get("data").cloned().unwrap_or(Value::Null)) Ok(v.get("data").cloned().unwrap_or(Value::Null))
} }
fn selected_tracks_from_track_list(track_list: &Value) -> (Option<String>, Option<String>) {
let arr = match track_list.as_array() {
Some(a) => a,
None => return (None, None),
};
let mut audio: Option<String> = None;
let mut subs: Option<String> = None;
for t in arr {
let ty = t.get("type").and_then(|x| x.as_str()).unwrap_or("");
let selected = t.get("selected").and_then(|x| x.as_bool()).unwrap_or(false);
if !selected {
continue;
}
match ty {
"audio" => audio = Some(track_label_from_entry(t, "Audio")),
"sub" => subs = Some(track_label_from_entry(t, "Sub")),
_ => {}
}
}
(audio, subs)
}
fn mpv_get_f64_jtui(socket: &PathBuf, prop: &str, request_id: u32) -> Result<Option<f64>, String> { 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()) Ok(mpv_get_prop_jtui(socket, prop, request_id)?.as_f64())
} }
@@ -1646,7 +1674,6 @@ fn mpv_get_bool_jtui(socket: &PathBuf, prop: &str, request_id: u32) -> Result<bo
Ok(mpv_get_prop_jtui(socket, prop, request_id)?.as_bool().unwrap_or(false)) 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"]}"#)
@@ -1731,8 +1758,12 @@ fn autoplay_tick(app: &mut App) {
} }
}; };
let (next_id, len) = match app.current_view() { let (next_id, next_name, 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[next_i].name.clone(),
items.len(),
),
_ => { _ => {
app.autoplay = false; app.autoplay = false;
app.autoplay_next_index = None; app.autoplay_next_index = None;
@@ -1741,6 +1772,9 @@ fn autoplay_tick(app: &mut App) {
} }
}; };
app.mpv_now_playing = Some(next_name);
// Move selection highlight in UI // 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;
@@ -1820,10 +1854,32 @@ fn start_mpv_poller(socket_path: PathBuf) -> mpsc::Receiver<MpvState> {
let idle = mpv_get_bool_jtui(&socket_path, "idle-active", request_id).unwrap_or(false); let idle = mpv_get_bool_jtui(&socket_path, "idle-active", request_id).unwrap_or(false);
request_id = request_id.wrapping_add(1); request_id = request_id.wrapping_add(1);
let aid = mpv_get_prop_jtui(&socket_path, "aid", request_id).ok().and_then(|v| v.as_i64());
request_id = request_id.wrapping_add(1);
let sid = mpv_get_prop_jtui(&socket_path, "sid", request_id).ok().and_then(|v| v.as_i64());
request_id = request_id.wrapping_add(1);
let track_list = mpv_get_prop_jtui(&socket_path, "track-list", request_id).ok();
request_id = request_id.wrapping_add(1);
let (audio, subs) = match track_list.as_ref() {
Some(v) => selected_tracks_from_track_list(v),
None => (None, None),
};
let media_title = mpv_get_prop_jtui(&socket_path, "media-title", request_id)
.ok()
.and_then(|v| v.as_str().map(|s| s.to_string()));
request_id = request_id.wrapping_add(1);
st.time_pos = tp; st.time_pos = tp;
st.duration = dur; st.duration = dur;
st.eof = eof; st.eof = eof;
st.idle = idle; st.idle = idle;
st.media_title = media_title;
st.audio = audio;
st.subs = subs;
// compute percent yourself (jtui style) // compute percent yourself (jtui style)
st.percent = match (tp, dur) { st.percent = match (tp, dur) {
@@ -1847,15 +1903,214 @@ fn start_mpv_poller(socket_path: PathBuf) -> mpsc::Receiver<MpvState> {
rx rx
} }
fn track_label(title: Option<&str>, lang: Option<&str>, id: Option<i64>, kind: &str) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(t) = title.map(|s| s.trim()).filter(|s| !s.is_empty()) {
parts.push(t.to_string());
}
if let Some(l) = lang.map(|s| s.trim()).filter(|s| !s.is_empty()) {
parts.push(l.to_string());
}
if parts.is_empty() {
if let Some(i) = id {
return format!("{kind} {i}");
}
return "-".into();
}
parts.join("")
}
fn track_label_from_entry(t: &serde_json::Value, kind: &str) -> String {
let title = t.get("title").and_then(|x| x.as_str()).map(str::trim).filter(|s| !s.is_empty());
let lang = t.get("lang").and_then(|x| x.as_str()).map(str::trim).filter(|s| !s.is_empty());
// audio extras are often useful (codec/channels)
let codec = t.get("codec").and_then(|x| x.as_str()).map(str::trim).filter(|s| !s.is_empty());
let ch = t.get("channels").and_then(|x| x.as_str()).map(str::trim).filter(|s| !s.is_empty());
let mut parts: Vec<String> = Vec::new();
if let Some(v) = title { parts.push(v.to_string()); }
if let Some(v) = lang { parts.push(v.to_string()); }
if kind == "Audio" {
if let Some(v) = codec { parts.push(v.to_string()); }
if let Some(v) = ch { parts.push(v.to_string()); }
}
if parts.is_empty() { "-".into() } else { parts.join("") }
}
fn tracks_from_ids(track_list: &serde_json::Value, aid: Option<i64>, sid: Option<i64>) -> (Option<String>, Option<String>) {
let arr = match track_list.as_array() {
Some(a) => a,
None => return (None, None),
};
let mut audio: Option<String> = None;
let mut subs: Option<String> = None;
for t in arr {
let ty = t.get("type").and_then(|x| x.as_str()).unwrap_or("");
let id = t.get("id").and_then(|x| x.as_i64());
match (ty, id) {
("audio", Some(id)) if Some(id) == aid => {
audio = Some(track_label_from_entry(t, "Audio"));
}
("sub", Some(id)) if Some(id) == sid => {
subs = Some(track_label_from_entry(t, "Sub"));
}
_ => {}
}
}
(audio, subs)
}
fn fmt_hms(t: f64) -> String {
if !t.is_finite() || t < 0.0 { return "--:--".into(); }
let total = t as u64;
let h = total / 3600;
let m = (total % 3600) / 60;
let s = total % 60;
if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") }
}
fn mpv_progress(st: &MpvState) -> (u16, String) {
let pct = st.percent.unwrap_or(0.0).clamp(0.0, 100.0);
let p_u16 = pct.round() as u16;
let cur = st.time_pos.map(fmt_hms).unwrap_or("--:--".into());
let dur = st.duration.map(fmt_hms).unwrap_or("--:--".into());
(p_u16, format!("{cur} / {dur} ({pct:0.1}%)"))
}
fn render_mpv_panel(frame: &mut Frame, app: &App, area: Rect) {
// Make the bar exactly 1 row and don't depend on bordered blocks.
let [c0, c1, c2, c3, c4, c5] = Layout::vertical([
Constraint::Length(1), // title (no border)
Constraint::Length(1), // audio
Constraint::Length(1), // subs
Constraint::Length(1), // BAR (raw)
Constraint::Length(1), // time
Constraint::Min(1), // controls
])
.areas(area);
let st = &app.mpv_state;
let title = app
.mpv_now_playing
.clone()
.or_else(|| st.media_title.clone())
.unwrap_or_else(|| "-".into());
let audio = st.audio.clone().unwrap_or_else(|| "-".into());
let subs = st.subs.clone().unwrap_or_else(|| "None".into());
frame.render_widget(Paragraph::new(format!("{title}")), c0);
frame.render_widget(Paragraph::new(format!("Audio: {audio}")), c1);
frame.render_widget(Paragraph::new(format!("Subs: {subs}")), c2);
let ratio = (st.percent.unwrap_or(0.0) / 100.0).clamp(0.0, 1.0);
let pct = st.percent.unwrap_or(0.0);
// reserve 1 col so we don't draw into the overlap w/ right pane border
let safe_w = c3.width.saturating_sub(1);
let bar_line = mpv_bar_line(safe_w, ratio, pct);
frame.render_widget(Paragraph::new(bar_line), c3);
let pct = st.percent.unwrap_or(0.0);
let cur = st.time_pos.map(fmt_hms).unwrap_or("--:--".into());
let dur = st.duration.map(fmt_hms).unwrap_or("--:--".into());
frame.render_widget(Paragraph::new(format!("{cur} / {dur} ({pct:0.1}%)")), c4);
let controls =
"p play P autoplay Space pause a audio s subs v sub vis f fullscreen x stop / search q quit";
frame.render_widget(Paragraph::new(controls).wrap(Wrap { trim: true }), c5);
}
fn mpv_bar_line(width: u16, ratio: f64, pct: f64) -> String {
let w = width as usize;
if w == 0 {
return String::new();
}
// If super narrow, just show percentage.
if w < 8 {
return format!("{:>4.0}%", pct.clamp(0.0, 100.0));
}
let prefix = "mpv ";
let mut suffix = format!(" {:>5.1}%", pct.clamp(0.0, 100.0));
let suffix_len = suffix.len();
// If we can't fit the suffix, drop it.
let bar_space = if prefix.len() + 2 + suffix_len <= w {
w - prefix.len() - suffix_len
} else {
suffix.clear();
w - prefix.len()
};
// Need at least room for "[]"
if bar_space < 2 {
return format!("{prefix}{:>4.0}%", pct.clamp(0.0, 100.0));
}
let bar = unicode_progress_bar(bar_space, ratio);
format!("{prefix}{bar}{suffix}")
}
fn unicode_progress_bar(width: usize, ratio: f64) -> String {
// width includes the brackets: [ .... ]
if width < 2 {
return String::new();
}
let inner = width - 2;
let ratio = ratio.clamp(0.0, 1.0);
// 8-level partial blocks
// 0..=7 maps to: (none), ▏▎▍▌▋▊▉
const PARTS: [char; 8] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
let total_units = inner * 8;
let filled_units = (ratio * total_units as f64).round() as usize;
let full = filled_units / 8;
let rem = filled_units % 8;
let mut s = String::with_capacity(width);
s.push('[');
// full blocks
let full = full.min(inner);
s.push_str(&"".repeat(full));
// partial block (if any room left)
if full < inner {
if rem > 0 {
s.push(PARTS[rem]);
} else {
s.push(' ');
}
// rest empty
let used = full + 1;
if used < inner {
s.push_str(&" ".repeat(inner - used));
}
}
s.push(']');
s
}
@@ -1928,3 +2183,6 @@ fn render_login(frame: &mut Frame, app: &App, left: Rect, right: Rect) {
right, right,
); );
} }
//💾