Added streaming. Improved UI
This commit is contained in:
+201
-53
@@ -16,6 +16,21 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
if inner.width >= 132 {
|
||||
let desired_settings_width = ((inner.width as usize * 44) / 100).clamp(68, 92) as u16;
|
||||
let settings_width = desired_settings_width.min(inner.width.saturating_sub(56));
|
||||
let [rows_area, _, status_area] = Layout::horizontal([
|
||||
Constraint::Length(settings_width),
|
||||
Constraint::Length(2),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.areas(inner);
|
||||
|
||||
draw_settings_rows(frame, rows_area, state);
|
||||
draw_status(frame, status_area, state);
|
||||
return;
|
||||
}
|
||||
|
||||
let rows_height =
|
||||
(settings_rows(state).len() + 5 + device_presence_sections(state).len()) as u16;
|
||||
let [rows_area, _, status_area] = Layout::vertical([
|
||||
@@ -340,7 +355,7 @@ fn draw_row_enabled(
|
||||
height: 1,
|
||||
};
|
||||
let marker = if selected { "▶ " } else { " " };
|
||||
let label_width = 48usize;
|
||||
let label_width = settings_label_width(area.width);
|
||||
let line = Line::from(vec![
|
||||
Span::styled(
|
||||
marker,
|
||||
@@ -366,27 +381,142 @@ fn draw_row_enabled(
|
||||
*y = (*y).saturating_add(1);
|
||||
}
|
||||
|
||||
fn settings_label_width(width: u16) -> usize {
|
||||
let width = width as usize;
|
||||
if width >= 88 {
|
||||
48
|
||||
} else {
|
||||
width.saturating_sub(28).clamp(24, 48)
|
||||
}
|
||||
}
|
||||
|
||||
fn status_line(label: &str, value: String) -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{label:<22}"), theme::dim()),
|
||||
Span::styled(format!("{label:<18}"), theme::dim()),
|
||||
Span::raw(value),
|
||||
])
|
||||
}
|
||||
|
||||
fn bytes_label(bytes: u64) -> String {
|
||||
fn short_bytes_label(bytes: u64) -> String {
|
||||
if bytes >= 1024 * 1024 {
|
||||
format!("{bytes} B ({:.1} MiB)", bytes as f64 / 1024.0 / 1024.0)
|
||||
format!("{:.1} MiB", bytes as f64 / 1024.0 / 1024.0)
|
||||
} else if bytes >= 1024 {
|
||||
format!("{bytes} B ({:.1} KiB)", bytes as f64 / 1024.0)
|
||||
format!("{:.1} KiB", bytes as f64 / 1024.0)
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
|
||||
fn rtt_label(ms: Option<u64>) -> String {
|
||||
ms.map(|ms| format!("{ms} ms"))
|
||||
.unwrap_or_else(|| "rtt n/a".to_string())
|
||||
}
|
||||
|
||||
fn short_id(id: &str) -> String {
|
||||
id.chars().take(12).collect::<String>() + "…"
|
||||
}
|
||||
|
||||
fn push_transport_status(lines: &mut Vec<Line<'static>>, status: &crate::federation::FedStatus) {
|
||||
lines.push(Line::default());
|
||||
lines.push(Line::styled("Iroh Transport", theme::header()));
|
||||
let transport = &status.transport;
|
||||
if transport.total_samples == 0 {
|
||||
lines.push(status_line("Streams", "no samples yet".to_string()));
|
||||
return;
|
||||
}
|
||||
let runtime_total = transport
|
||||
.runtime_tx_bytes
|
||||
.saturating_add(transport.runtime_rx_bytes);
|
||||
lines.push(status_line(
|
||||
"Runtime traffic",
|
||||
format!(
|
||||
"{} · tx {} · rx {} · active {}",
|
||||
short_bytes_label(runtime_total),
|
||||
short_bytes_label(transport.runtime_tx_bytes),
|
||||
short_bytes_label(transport.runtime_rx_bytes),
|
||||
transport.active_streams
|
||||
),
|
||||
));
|
||||
if transport.runtime_lost_packets > 0 || transport.runtime_lost_bytes > 0 {
|
||||
lines.push(status_line(
|
||||
"Runtime loss",
|
||||
format!(
|
||||
"{} pkts · {}",
|
||||
transport.runtime_lost_packets,
|
||||
short_bytes_label(transport.runtime_lost_bytes)
|
||||
),
|
||||
));
|
||||
}
|
||||
lines.push(status_line(
|
||||
"Samples",
|
||||
format!(
|
||||
"{} total · direct {} · relay {} · custom {} · unknown {}",
|
||||
transport.total_samples,
|
||||
transport.direct_samples,
|
||||
transport.relay_samples,
|
||||
transport.custom_samples,
|
||||
transport.unknown_samples
|
||||
),
|
||||
));
|
||||
lines.push(status_line(
|
||||
"Protocols",
|
||||
format!(
|
||||
"audio {} · catalog {} · sync {}",
|
||||
transport.audio_samples, transport.catalog_samples, transport.sync_samples
|
||||
),
|
||||
));
|
||||
if let Some(sample) = transport.last.first() {
|
||||
lines.push(status_line(
|
||||
"Last stream",
|
||||
format!(
|
||||
"{} {} {} · {} · {}",
|
||||
sample.protocol,
|
||||
sample.direction,
|
||||
sample.phase,
|
||||
sample.selected_path,
|
||||
rtt_label(sample.selected_rtt_ms)
|
||||
),
|
||||
));
|
||||
lines.push(status_line(
|
||||
"Last peer",
|
||||
format!(
|
||||
"{} · paths d/r/c/open {}/{}/{}/{}",
|
||||
short_id(&sample.peer_id),
|
||||
sample.direct_paths,
|
||||
sample.relay_paths,
|
||||
sample.custom_paths,
|
||||
sample.open_paths
|
||||
),
|
||||
));
|
||||
lines.push(status_line(
|
||||
"Last bytes",
|
||||
format!(
|
||||
"sel {}/{} · total {}/{} · lost {} / {}",
|
||||
short_bytes_label(sample.selected_tx_bytes),
|
||||
short_bytes_label(sample.selected_rx_bytes),
|
||||
short_bytes_label(sample.total_tx_bytes),
|
||||
short_bytes_label(sample.total_rx_bytes),
|
||||
sample.lost_packets,
|
||||
short_bytes_label(sample.lost_bytes)
|
||||
),
|
||||
));
|
||||
}
|
||||
for sample in transport.last.iter().take(3) {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!("{:<14}", sample.at), theme::dim()),
|
||||
Span::raw(format!(
|
||||
"{} {} {} · {} · tx {} rx {}",
|
||||
sample.protocol,
|
||||
sample.direction,
|
||||
sample.phase,
|
||||
sample.selected_path,
|
||||
short_bytes_label(sample.total_tx_bytes),
|
||||
short_bytes_label(sample.total_rx_bytes)
|
||||
)),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let mut lines: Vec<Line> = vec![Line::styled("Status", theme::header())];
|
||||
match &state.federation.status {
|
||||
@@ -407,39 +537,53 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
));
|
||||
}
|
||||
Some(status) => {
|
||||
lines.push(status_line("Node", "running".to_string()));
|
||||
lines.push(status_line("Network", status.network.clone()));
|
||||
lines.push(status_line("Endpoint ID", status.endpoint_id.clone()));
|
||||
lines.push(status_line("DHT node ID", status.dht_node_id.clone()));
|
||||
lines.push(status_line("Node", format!("running · {}", status.network)));
|
||||
lines.push(status_line(
|
||||
"Endpoint",
|
||||
format!(
|
||||
"{} · dht {}",
|
||||
short_id(&status.endpoint_id),
|
||||
short_id(&status.dht_node_id)
|
||||
),
|
||||
));
|
||||
let peers = if status.connected_peers.is_empty() {
|
||||
"none yet".to_string()
|
||||
format!("none · contacts {}", status.known_contacts)
|
||||
} else {
|
||||
let names: Vec<String> =
|
||||
status.connected_peers.iter().map(|p| short_id(p)).collect();
|
||||
format!("{} — {}", status.connected_peers.len(), names.join(", "))
|
||||
let names: Vec<String> = status
|
||||
.connected_peers
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|p| short_id(p))
|
||||
.collect();
|
||||
let more = status.connected_peers.len().saturating_sub(names.len());
|
||||
let more = if more > 0 {
|
||||
format!(" +{more}")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"{} connected{} · contacts {} · {}",
|
||||
status.connected_peers.len(),
|
||||
more,
|
||||
status.known_contacts,
|
||||
names.join(", ")
|
||||
)
|
||||
};
|
||||
lines.push(status_line("Connected peers", peers));
|
||||
lines.push(status_line("Peers", peers));
|
||||
lines.push(status_line(
|
||||
"Known contacts",
|
||||
status.known_contacts.to_string(),
|
||||
));
|
||||
lines.push(status_line(
|
||||
"Stored DHT records",
|
||||
status
|
||||
.stored_dht_records
|
||||
.map(|count| count.to_string())
|
||||
.unwrap_or_else(|| "unavailable".to_string()),
|
||||
));
|
||||
lines.push(status_line(
|
||||
"Stored DHT bytes",
|
||||
status
|
||||
.stored_dht_bytes
|
||||
.map(bytes_label)
|
||||
.unwrap_or_else(|| "unavailable".to_string()),
|
||||
));
|
||||
lines.push(status_line(
|
||||
"Published items",
|
||||
status.published_items.to_string(),
|
||||
"DHT",
|
||||
format!(
|
||||
"{} records · {} · {} published",
|
||||
status
|
||||
.stored_dht_records
|
||||
.map(|count| count.to_string())
|
||||
.unwrap_or_else(|| "unavailable".to_string()),
|
||||
status
|
||||
.stored_dht_bytes
|
||||
.map(short_bytes_label)
|
||||
.unwrap_or_else(|| "unavailable".to_string()),
|
||||
status.published_items
|
||||
),
|
||||
));
|
||||
lines.push(status_line(
|
||||
"Last sync",
|
||||
@@ -451,6 +595,7 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
if let Some(error) = &status.last_error {
|
||||
lines.push(status_line("Error", error.clone()));
|
||||
}
|
||||
push_transport_status(&mut lines, status);
|
||||
}
|
||||
}
|
||||
lines.push(Line::default());
|
||||
@@ -458,29 +603,32 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
match &state.federation.devices {
|
||||
None => lines.push(Line::styled("loading…", theme::dim())),
|
||||
Some(status) => {
|
||||
lines.push(status_line("This device", status.this_device_id.clone()));
|
||||
lines.push(status_line("Sync group", status.group_id.clone()));
|
||||
lines.push(status_line(
|
||||
"Active devices",
|
||||
status.active_devices.to_string(),
|
||||
));
|
||||
lines.push(status_line(
|
||||
"Revoked devices",
|
||||
status.revoked_devices.to_string(),
|
||||
));
|
||||
lines.push(status_line(
|
||||
"Pending requests",
|
||||
status.pending_requests.to_string(),
|
||||
));
|
||||
lines.push(status_line("Ops in log", status.ops_total.to_string()));
|
||||
lines.push(status_line(
|
||||
"Tombstones",
|
||||
"This device",
|
||||
format!(
|
||||
"{} ({} compactable)",
|
||||
status.tombstone_ops, status.compactable_tombstones
|
||||
"{} · {}",
|
||||
status.this_device_name,
|
||||
short_id(&status.this_device_id)
|
||||
),
|
||||
));
|
||||
lines.push(status_line("Sync group", short_id(&status.group_id)));
|
||||
lines.push(status_line(
|
||||
"Devices",
|
||||
format!(
|
||||
"{} active · {} revoked · {} pending",
|
||||
status.active_devices, status.revoked_devices, status.pending_requests
|
||||
),
|
||||
));
|
||||
lines.push(status_line(
|
||||
"Sync log",
|
||||
format!(
|
||||
"{} ops · {} outbox · {} tombstones ({} gc)",
|
||||
status.ops_total,
|
||||
status.outbox_ops,
|
||||
status.tombstone_ops,
|
||||
status.compactable_tombstones
|
||||
),
|
||||
));
|
||||
lines.push(status_line("Outbox ops", status.outbox_ops.to_string()));
|
||||
lines.push(status_line(
|
||||
"Snapshot",
|
||||
format!(
|
||||
|
||||
+11
-6
@@ -287,7 +287,7 @@ fn draw_grid(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let message = if let Some(error) = &global.error {
|
||||
Line::styled(error.clone(), error_style())
|
||||
} else if global.loading {
|
||||
Line::styled("loading artists…", theme::dim())
|
||||
super::loading_line(state, "loading artists…")
|
||||
} else {
|
||||
Line::styled("no artists in the library", theme::dim())
|
||||
};
|
||||
@@ -386,7 +386,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
||||
Some(Loadable::Failed(error)) => {
|
||||
return centered_line(frame, inner, Line::styled(error.clone(), error_style()));
|
||||
}
|
||||
_ => return centered_line(frame, inner, Line::styled("loading…", theme::dim())),
|
||||
_ => return centered_line(frame, inner, super::loading_line(state, "loading…")),
|
||||
};
|
||||
|
||||
let header_height = (ART_HEADER_HEIGHT + 1).min(inner.height);
|
||||
@@ -653,7 +653,7 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor
|
||||
Some(Loadable::Failed(error)) => {
|
||||
return centered_line(frame, inner, Line::styled(error.clone(), error_style()));
|
||||
}
|
||||
_ => return centered_line(frame, inner, Line::styled("loading…", theme::dim())),
|
||||
_ => return centered_line(frame, inner, super::loading_line(state, "loading…")),
|
||||
};
|
||||
|
||||
let header_height = (ART_HEADER_HEIGHT + 1).min(inner.height);
|
||||
@@ -751,7 +751,12 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
} else {
|
||||
"searching…"
|
||||
};
|
||||
return centered_line(frame, inner, Line::styled(hint, theme::dim()));
|
||||
let line = if search.query.is_empty() {
|
||||
Line::styled(hint, theme::dim())
|
||||
} else {
|
||||
super::loading_line(state, hint)
|
||||
};
|
||||
return centered_line(frame, inner, line);
|
||||
}
|
||||
};
|
||||
if results.len() == 0
|
||||
@@ -795,7 +800,7 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
if !results.tracks.is_empty() {
|
||||
rows.push((Line::styled("Tracks", theme::header()), None, None));
|
||||
for track in &results.tracks {
|
||||
let heart = if state.likes.contains(&track.id) {
|
||||
let heart = if state.track_liked(track) {
|
||||
Span::styled("♥ ", theme::accent())
|
||||
} else {
|
||||
Span::raw(" ")
|
||||
@@ -936,7 +941,7 @@ fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usiz
|
||||
return centered_line(
|
||||
frame,
|
||||
inner,
|
||||
Line::styled("assembling the card from peers…", theme::dim()),
|
||||
super::loading_line(state, "assembling the card from peers…"),
|
||||
);
|
||||
}
|
||||
Loadable::Failed(message) => {
|
||||
|
||||
+50
-6
@@ -45,6 +45,26 @@ pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
|
||||
popup::draw(frame, state);
|
||||
}
|
||||
|
||||
pub(crate) fn loading_line(state: &AppState, text: impl Into<String>) -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{} ", state.spinner()), theme::accent()),
|
||||
Span::styled(text.into(), theme::dim()),
|
||||
])
|
||||
}
|
||||
|
||||
fn is_waiting_message(message: &str) -> bool {
|
||||
let normalized = message.to_ascii_lowercase();
|
||||
normalized.contains("loading")
|
||||
|| normalized.contains("searching")
|
||||
|| normalized.contains("fetching")
|
||||
|| normalized.contains("downloading")
|
||||
|| normalized.contains("locating")
|
||||
|| normalized.contains("importing")
|
||||
|| normalized.contains("waiting")
|
||||
|| normalized.contains("assembling")
|
||||
|| normalized.contains("resolving")
|
||||
}
|
||||
|
||||
fn draw_tabs(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let titles = Tab::ALL
|
||||
.iter()
|
||||
@@ -68,11 +88,31 @@ pub(crate) fn track_row(
|
||||
selected: bool,
|
||||
visual_selected: bool,
|
||||
) {
|
||||
let fed_liked = track
|
||||
.fed
|
||||
.as_ref()
|
||||
.is_some_and(|fed| state.fed_track_liked(fed));
|
||||
let heart = if state.likes.contains(&track.id) || fed_liked {
|
||||
track_row_with_like_marker(
|
||||
frame,
|
||||
area,
|
||||
state,
|
||||
track,
|
||||
index_label,
|
||||
selected,
|
||||
visual_selected,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn track_row_with_like_marker(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
state: &AppState,
|
||||
track: &crate::library::models::TrackItem,
|
||||
index_label: String,
|
||||
selected: bool,
|
||||
visual_selected: bool,
|
||||
show_like_marker: bool,
|
||||
) {
|
||||
let heart = if !show_like_marker {
|
||||
Span::raw("")
|
||||
} else if state.track_liked(track) {
|
||||
Span::styled("♥ ", theme::accent())
|
||||
} else {
|
||||
Span::raw(" ")
|
||||
@@ -311,7 +351,7 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
} else {
|
||||
spans.push(Span::styled("▶ ", theme::accent()));
|
||||
}
|
||||
if state.likes.contains(&track.id) {
|
||||
if state.track_liked(track) {
|
||||
spans.push(Span::styled("♥ ", theme::accent()));
|
||||
}
|
||||
spans.push(Span::raw(track.title.clone()));
|
||||
@@ -340,6 +380,10 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
}
|
||||
|
||||
let message = match &state.status_message {
|
||||
Some(message) if is_waiting_message(message) => Line::from(vec![
|
||||
Span::styled(format!("{} ", state.spinner()), theme::accent()),
|
||||
Span::styled(message.clone(), theme::accent()),
|
||||
]),
|
||||
Some(message) => Line::styled(message.clone(), theme::accent()),
|
||||
None => match &state.player.current {
|
||||
// Idle line doubles as the current track's tech data display.
|
||||
|
||||
+6
-9
@@ -4,8 +4,8 @@ use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
|
||||
use super::{theme, track_row};
|
||||
use crate::app::state::{AppState, Loadable, TrackSelectionScope};
|
||||
use super::{loading_line, theme, track_row_with_like_marker};
|
||||
use crate::app::state::{AppState, LIKES_PLAYLIST_ID, Loadable, TrackSelectionScope};
|
||||
use crate::app::update::playlist_tracks;
|
||||
|
||||
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
@@ -51,11 +51,7 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
return centered_line(
|
||||
frame,
|
||||
inner,
|
||||
Line::styled("loading playlists…", theme::dim()),
|
||||
);
|
||||
return centered_line(frame, inner, loading_line(state, "loading playlists…"));
|
||||
}
|
||||
};
|
||||
if list.is_empty() {
|
||||
@@ -112,7 +108,7 @@ fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
||||
);
|
||||
}
|
||||
let Some(tracks) = playlist_tracks(state, id) else {
|
||||
return centered_line(frame, inner, Line::styled("loading…", theme::dim()));
|
||||
return centered_line(frame, inner, loading_line(state, "loading…"));
|
||||
};
|
||||
if tracks.is_empty() {
|
||||
return centered_line(
|
||||
@@ -133,7 +129,7 @@ fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
};
|
||||
track_row(
|
||||
track_row_with_like_marker(
|
||||
frame,
|
||||
row,
|
||||
state,
|
||||
@@ -143,6 +139,7 @@ fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
||||
state
|
||||
.track_selection
|
||||
.contains(&TrackSelectionScope::Playlist(id), index),
|
||||
id != LIKES_PLAYLIST_ID,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -197,7 +197,7 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled(
|
||||
"enter: activate this device / control selected · esc close",
|
||||
"enter: activate selected device / control active selected · esc close",
|
||||
theme::dim(),
|
||||
))
|
||||
.alignment(Alignment::Center),
|
||||
|
||||
Reference in New Issue
Block a user