From fb0de69a51fd1713d7ac4098fe3fbb65bbc9be16 Mon Sep 17 00:00:00 2001 From: cwj3rcb Date: Sun, 15 Feb 2026 01:41:41 -0600 Subject: [PATCH] almost finished mpv interface --- src/main.rs | 356 ++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 307 insertions(+), 49 deletions(-) diff --git a/src/main.rs b/src/main.rs index d22aa23..a77655e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,7 @@ use crossterm::{ use ratatui::{ layout::{Constraint, Layout, Rect, Spacing}, symbols::merge::MergeStrategy, - widgets::{Block, List, ListItem, ListState, Paragraph, Wrap}, + widgets::{Block, List, ListItem, ListState, Paragraph, Wrap, Gauge}, style::{Modifier, Style}, Frame, Terminal, }; @@ -54,6 +54,11 @@ struct MpvState { duration: Option, percent: Option, last_err: Option, + + // new: + media_title: Option, + audio: Option, + subs: Option, } #[derive(Clone, Copy)] @@ -169,6 +174,7 @@ struct App { mpv_state: MpvState, mpv_state_rx: Option>, debug_mpv: bool, + mpv_now_playing: Option, autoplay: bool, autoplay_view_depth: usize, @@ -234,6 +240,7 @@ impl Default for App { mpv_state: MpvState::default(), mpv_state_rx: None, debug_mpv: false, + mpv_now_playing: None, autoplay: false, autoplay_view_depth: 0, autoplay_next_index: None, @@ -314,6 +321,7 @@ fn run_app(terminal: &mut Terminal>) -> io::Result< // Your requirement: window closing disables autoplay app.autoplay = false; app.autoplay_next_index = None; + app.mpv_now_playing = None; app.mpv_state_rx = None; app.mpv_state = MpvState::default(); } @@ -567,11 +575,15 @@ fn handle_key(app: &mut App, code: KeyCode) { }; // Determine what is “selected” depending on view - let item_id = match app.current_view() { - View::Library { items, selected, .. } if !items.is_empty() => items[*selected].id.clone(), + let (item_id, item_name) = match app.current_view() { + 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.mpv_now_playing = Some(item_name); + // If mpv running -> replace, else spawn if let Some(mpv) = &app.mpv { match mpv_load_item(&cfg, mpv, &item_id) { @@ -594,13 +606,15 @@ fn handle_key(app: &mut App, code: KeyCode) { 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() { + let (item_id, item_name, selected, len) = match app.current_view() { 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.mpv_now_playing = Some(item_name); + app.autoplay = true; app.status = format!("autoplay ON (next index: {:?})", app.autoplay_next_index); app.autoplay_prev_time_pos = None; @@ -1186,37 +1200,32 @@ fn ui(frame: &mut Frame, app: &App) { // Bottom Left // ---- Bottom Left: prompt / mpv ---- - let left_text = if let Some(s) = &app.search { - // search takes over temporarily + // Bottom Left: Search (temporary) > MPV (persistent) > "$" + if let Some(s) = &app.search { let scope = match &s.scope { SearchScope::All => "All".to_string(), SearchScope::Library { title, .. } => title.clone(), }; let mode = s.mode.label(); - if s.input.is_empty() { + let line = if s.input.is_empty() { format!("/ ({scope}) [{mode}]") } else { 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( - Paragraph::new(left_text) - // no title - .block(Block::bordered().merge_borders(MergeStrategy::Exact)), - panes.left_output, - ); + frame.render_widget( + Paragraph::new(line).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 ---- 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) -> Result1024 bytes) + 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}"))?; - if n == 0 { - return Err("mpv ipc: EOF".into()); + if line.trim().is_empty() { + return Err("mpv ipc: empty response".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_str(line.trim()) + .map_err(|e| format!("mpv json parse failed: {e} ({})", line.trim()))?; - 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}")); } @@ -1638,6 +1638,34 @@ fn mpv_get_prop_jtui(socket: &PathBuf, prop: &str, request_id: u32) -> Result (Option, Option) { + let arr = match track_list.as_array() { + Some(a) => a, + None => return (None, None), + }; + + let mut audio: Option = None; + let mut subs: Option = 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, String> { 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 Result<(), String> { // cycles fullscreen on/off 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() { - View::Library { items, .. } if next_i < items.len() => (items[next_i].id.clone(), items.len()), + let (next_id, next_name, len) = match app.current_view() { + 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_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 if let Some(View::Library { selected, .. }) = app.view_stack.last_mut() { *selected = next_i; @@ -1820,10 +1854,32 @@ fn start_mpv_poller(socket_path: PathBuf) -> mpsc::Receiver { let idle = mpv_get_bool_jtui(&socket_path, "idle-active", request_id).unwrap_or(false); 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.duration = dur; st.eof = eof; st.idle = idle; + st.media_title = media_title; + st.audio = audio; + st.subs = subs; // compute percent yourself (jtui style) st.percent = match (tp, dur) { @@ -1847,15 +1903,214 @@ fn start_mpv_poller(socket_path: PathBuf) -> mpsc::Receiver { rx } +fn track_label(title: Option<&str>, lang: Option<&str>, id: Option, kind: &str) -> String { + let mut parts: Vec = 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 = 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, sid: Option) -> (Option, Option) { + let arr = match track_list.as_array() { + Some(a) => a, + None => return (None, None), + }; + let mut audio: Option = None; + let mut subs: Option = 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, ); } + + +//💾 \ No newline at end of file