Added connected devices. Improved logging. UI fixes

This commit is contained in:
Ultradesu
2026-06-10 23:30:03 +01:00
parent bcee68eb4e
commit 02a396c146
25 changed files with 2540 additions and 314 deletions
+181 -44
View File
@@ -3,13 +3,14 @@ mod global;
mod login;
mod logs;
mod playlists;
mod popup;
pub mod theme;
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph, Row, Table, Tabs};
use ratatui::widgets::{Block, Clear, Paragraph, Tabs};
use crate::app::state::{AppState, Screen, Tab};
use crate::config::keymap::Keymap;
@@ -38,6 +39,7 @@ pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
if state.help_visible {
draw_help(frame, keymap);
}
popup::draw(frame, state);
}
fn draw_tabs(frame: &mut Frame, area: Rect, state: &AppState) {
@@ -75,12 +77,7 @@ pub(crate) fn track_row(
]);
frame.render_widget(Paragraph::new(line), area);
let tech = track.tech_label_short();
let right = if tech.is_empty() || area.width < 60 {
track.duration_label()
} else {
format!("{tech} · {}", track.duration_label())
};
let right = track_meta_suffix(track, area.width >= 60);
frame.render_widget(
Paragraph::new(Line::styled(right, theme::dim())).alignment(Alignment::Right),
area,
@@ -90,6 +87,38 @@ pub(crate) fn track_row(
}
}
pub(crate) fn track_meta_suffix(
track: &crate::api::models::TrackItem,
include_tech: bool,
) -> String {
let has_tech = track.audio_format.is_some()
|| track.audio_bitrate.is_some()
|| track.file_size_bytes.is_some();
if !include_tech || !has_tech {
return track.duration_label();
}
let format = track
.audio_format
.as_deref()
.map(|value| value.to_ascii_uppercase())
.unwrap_or_default();
let format: String = format.chars().take(4).collect();
let bitrate = track
.audio_bitrate
.map(|value| format!("{value}k"))
.unwrap_or_default();
let size = track
.file_size_bytes
.map(|bytes| format!("{:.1}MB", bytes as f64 / 1_048_576.0))
.unwrap_or_default();
format!(
"{format:<4} {bitrate:>5} {size:>8} · {:>5}",
track.duration_label()
)
}
/// Interactive queue: its own cursor, enter plays the selected track and
/// already-played tracks stay listed, greyed out.
fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
@@ -105,7 +134,11 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
frame.render_widget(block, area);
if player.queue.is_empty() {
let middle = Rect { y: inner.y + inner.height / 2, height: 1, ..inner };
let middle = Rect {
y: inner.y + inner.height / 2,
height: 1,
..inner
};
frame.render_widget(
Paragraph::new(Line::styled(
"queue is empty — open a track and press enter",
@@ -122,9 +155,7 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
let first = cursor
.saturating_sub(visible / 2)
.min(player.queue.len().saturating_sub(visible));
let played_style = Style::new()
.fg(Color::DarkGray)
.bg(Color::Rgb(28, 28, 32));
let played_style = Style::new().fg(Color::DarkGray).bg(Color::Rgb(28, 28, 32));
for (index, track) in player.queue.iter().enumerate().skip(first).take(visible) {
let row = Rect {
x: inner.x,
@@ -207,16 +238,36 @@ fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<
));
}
} else {
spans.push(Span::styled(
format!(" {}%", player.volume),
theme::dim(),
));
spans.push(Span::styled(format!(" {}%", player.volume), theme::dim()));
}
// Keep a gap between the flags and the username block to the right.
spans.push(Span::raw(" "));
Line::from(spans)
}
fn truncate_chars(value: &str, max: usize) -> String {
let mut out: String = value.chars().take(max).collect();
if value.chars().count() > max {
out.push('…');
}
out
}
fn device_status_line(state: &AppState) -> Line<'static> {
if state.devices.is_playback_device() {
return Line::from(vec![Span::styled("playing here", theme::accent())]);
}
let name = state
.devices
.active_device_name()
.map(|name| truncate_chars(name, 26))
.unwrap_or_else(|| "remote device".to_string());
Line::from(vec![
Span::styled("controlling ", theme::dim()),
Span::styled(name, theme::accent()),
])
}
fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
let [player_row, message_row] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
@@ -227,6 +278,8 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
// truncates into whatever is left.
let center = player_right_line(player, area.width);
let center_width = (center.width() as u16).min(area.width);
let device_line = device_status_line(state);
let device_width = (device_line.width() as u16).min(32);
let user_line = state.user.as_ref().map(|user| {
Line::from(vec![
Span::styled("", theme::accent()),
@@ -234,12 +287,17 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
])
});
let user_width = user_line.as_ref().map_or(0, |l| l.width() as u16);
let [title_area, right_area, user_area] = Layout::horizontal([
let [title_area, right_area, device_area, user_area] = Layout::horizontal([
Constraint::Min(8),
Constraint::Length(center_width),
Constraint::Length(device_width.saturating_add(2)),
Constraint::Length(user_width),
])
.areas(player_row);
frame.render_widget(
Paragraph::new(device_line).alignment(Alignment::Right),
device_area,
);
if let Some(user_line) = user_line {
frame.render_widget(
Paragraph::new(user_line).alignment(Alignment::Right),
@@ -271,7 +329,6 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
frame.render_widget(Paragraph::new(Line::from(spans)), title_area);
frame.render_widget(Paragraph::new(center), right_area);
if state.cmdline.active {
// Vim-style command line takes over the message row.
let line = Line::from(vec![
@@ -280,6 +337,7 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
Span::styled("", theme::accent()),
]);
frame.render_widget(Paragraph::new(line), message_row);
draw_version(frame, message_row);
return;
}
@@ -294,6 +352,7 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
},
};
frame.render_widget(Paragraph::new(message), message_row);
draw_version(frame, message_row);
if let Some(pending) = &state.pending_keys {
let pending = Paragraph::new(Line::styled(format!("{pending}"), theme::header()))
@@ -302,36 +361,115 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
}
}
fn draw_version(frame: &mut Frame, area: Rect) {
let version = format!("v{}", env!("CARGO_PKG_VERSION"));
frame.render_widget(
Paragraph::new(Line::styled(version, theme::dim())).alignment(Alignment::Right),
area,
);
}
/// Help window: bindings merged per action (j / down on one row), grouped
/// into titled sections and laid out in two balanced columns.
fn draw_help(frame: &mut Frame, keymap: &Keymap) {
let entries = keymap.help_entries();
let height = (entries.len() as u16 + 4).min(frame.area().height.saturating_sub(2));
let width = 56.min(frame.area().width.saturating_sub(2));
use crate::app::action::{Action, Category};
use crate::config::keymap::KeyContext;
struct MergedRow {
keys: Vec<String>,
action: Action,
context: KeyContext,
}
let mut merged: Vec<MergedRow> = Vec::new();
for (keys, action, context) in keymap.help_entries() {
match merged
.iter_mut()
.find(|row| row.action == action && row.context == context)
{
Some(row) => row.keys.push(keys),
None => merged.push(MergedRow {
keys: vec![keys],
action,
context,
}),
}
}
// One block of lines per category: section header + its rows.
let mut blocks: Vec<Vec<Line>> = Vec::new();
for category in Category::ALL {
let rows: Vec<&MergedRow> = merged
.iter()
.filter(|row| row.action.category() == category)
.collect();
if rows.is_empty() {
continue;
}
let mut lines = vec![Line::styled(category.title(), theme::header())];
for row in rows {
let keys = row.keys.join(" / ");
let context = if row.context == KeyContext::Global {
String::new()
} else {
format!(" [{}]", row.context.label())
};
let command = row.action.command_hint().unwrap_or("");
lines.push(Line::from(vec![
Span::styled(format!("{keys:<13}"), theme::accent()),
Span::raw(format!(
"{:<24}",
format!("{}{context}", row.action.describe())
)),
Span::styled(command.to_string(), theme::accent()),
]));
}
lines.push(Line::default());
blocks.push(lines);
}
// Balance the blocks across two columns.
let total: usize = blocks.iter().map(Vec::len).sum();
let mut left: Vec<Line> = Vec::new();
let mut right: Vec<Line> = Vec::new();
for block in blocks {
if left.len() < total.div_ceil(2) {
left.extend(block);
} else {
right.extend(block);
}
}
let column_height = left.len().max(right.len()) as u16;
let width = 110.min(frame.area().width.saturating_sub(2));
let height = (column_height + 4).min(frame.area().height.saturating_sub(2));
let area = centered_rect(frame.area(), width, height);
let rows = entries.into_iter().map(|(keys, description, context)| {
Row::new(vec![
Span::styled(keys, theme::accent()),
Span::raw(description),
Span::styled(context.label(), theme::dim()),
])
});
let table = Table::new(
rows,
[
Constraint::Length(12),
Constraint::Min(20),
Constraint::Length(9),
],
)
.header(Row::new(vec!["keys", "action", "context"]).style(theme::header()))
.block(
Block::bordered()
.title(" Keybindings ")
.title_style(theme::header()),
);
let block = Block::bordered()
.title(" Keybindings & commands ")
.title_style(theme::header())
.border_style(theme::accent());
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(table, area);
frame.render_widget(block, area);
let [columns_area, footer] =
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
let [left_area, _, right_area] = Layout::horizontal([
Constraint::Percentage(50),
Constraint::Length(2),
Constraint::Percentage(50),
])
.areas(columns_area);
frame.render_widget(Paragraph::new(left), left_area);
frame.render_widget(Paragraph::new(right), right_area);
frame.render_widget(
Paragraph::new(Line::styled(
": opens the command line · full forms: :seek +30|1:30 · :volume 0-100 · :repeat off|one|all · :logs [level]",
theme::dim(),
))
.alignment(Alignment::Center),
footer,
);
}
fn centered_rect(area: Rect, width: u16, height: u16) -> Rect {
@@ -343,4 +481,3 @@ fn centered_rect(area: Rect, width: u16, height: u16) -> Rect {
.areas(rect);
rect
}