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::{
|
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
|
|
||||||
.block(Block::bordered().merge_borders(MergeStrategy::Exact)),
|
|
||||||
panes.left_output,
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//💾
|
||||||
Reference in New Issue
Block a user