almost finished mpv interface
This commit is contained in:
348
src/main.rs
348
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<f64>,
|
||||
percent: Option<f64>,
|
||||
last_err: Option<String>,
|
||||
|
||||
// new:
|
||||
media_title: Option<String>,
|
||||
audio: Option<String>,
|
||||
subs: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -169,6 +174,7 @@ struct App {
|
||||
mpv_state: MpvState,
|
||||
mpv_state_rx: Option<mpsc::Receiver<MpvState>>,
|
||||
debug_mpv: bool,
|
||||
mpv_now_playing: Option<String>,
|
||||
|
||||
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<CrosstermBackend<io::Stdout>>) -> 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)),
|
||||
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) -> Result<Va
|
||||
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)
|
||||
// IMPORTANT: read a whole JSON line (track-list can be >1024 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<Va
|
||||
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> {
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
fn mpv_toggle_fullscreen(mpv: &MpvSession) -> 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<MpvState> {
|
||||
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<MpvState> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
//💾
|
||||
Reference in New Issue
Block a user