Connected Devices: added remote control
This commit is contained in:
+63
-8
@@ -6,7 +6,7 @@ use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
|
||||
use super::theme;
|
||||
use crate::app::state::{AppState, FedRow, settings_rows};
|
||||
use crate::app::state::{AppState, DevicePresenceSection, FedRow, settings_rows};
|
||||
|
||||
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let block = Block::bordered()
|
||||
@@ -16,7 +16,8 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let rows_height = (settings_rows(state).len() + 5) as u16;
|
||||
let rows_height =
|
||||
(settings_rows(state).len() + 5 + device_presence_sections(state).len()) as u16;
|
||||
let [rows_area, _, status_area] = Layout::vertical([
|
||||
Constraint::Length(rows_height.min(inner.height)),
|
||||
Constraint::Length(1),
|
||||
@@ -28,6 +29,24 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
draw_status(frame, status_area, state);
|
||||
}
|
||||
|
||||
fn device_presence_sections(state: &AppState) -> Vec<DevicePresenceSection> {
|
||||
let Some(status) = &state.federation.devices else {
|
||||
return Vec::new();
|
||||
};
|
||||
let now = crate::app::state::unix_time_ms();
|
||||
let mut sections = Vec::new();
|
||||
for index in crate::app::state::device_status_order(state) {
|
||||
let Some(device) = status.devices.get(index) else {
|
||||
continue;
|
||||
};
|
||||
let section = crate::app::state::device_presence_section(state, device, now);
|
||||
if sections.last().copied() != Some(section) {
|
||||
sections.push(section);
|
||||
}
|
||||
}
|
||||
sections
|
||||
}
|
||||
|
||||
fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let settings = &state.federation.settings;
|
||||
let on_off = |on: bool| if on { "on" } else { "off" };
|
||||
@@ -134,13 +153,24 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
);
|
||||
cursor += 1;
|
||||
if let Some(status) = devices {
|
||||
for device in &status.devices {
|
||||
let now = crate::app::state::unix_time_ms();
|
||||
let mut current_section = None;
|
||||
for index in crate::app::state::device_status_order(state) {
|
||||
let Some(device) = status.devices.get(index) else {
|
||||
continue;
|
||||
};
|
||||
let section = crate::app::state::device_presence_section(state, device, now);
|
||||
if current_section != Some(section) {
|
||||
draw_subsection(frame, area, &mut y, section.title());
|
||||
current_section = Some(section);
|
||||
}
|
||||
let name = crate::app::state::device_display_name(device);
|
||||
let label = if device.is_self {
|
||||
format!("* {}", device.name)
|
||||
format!("* {name}")
|
||||
} else if device.revoked {
|
||||
format!(" {} (revoked)", device.name)
|
||||
format!(" {name} (revoked)")
|
||||
} else {
|
||||
format!(" {}", device.name)
|
||||
format!(" {name}")
|
||||
};
|
||||
let version = if device.client_version.is_empty() {
|
||||
"unknown".to_string()
|
||||
@@ -148,10 +178,15 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
format!("v{}", device.client_version)
|
||||
};
|
||||
let can_revoke = connected_devices_enabled && !device.is_self && !device.revoked;
|
||||
let presence = match section {
|
||||
DevicePresenceSection::Online => "online",
|
||||
DevicePresenceSection::Offline => "offline",
|
||||
DevicePresenceSection::Revoked => "revoked",
|
||||
};
|
||||
let value = if can_revoke {
|
||||
format!("{version} · revoke ↵")
|
||||
format!("{version} · {presence} · revoke ↵")
|
||||
} else if connected_devices_enabled {
|
||||
version
|
||||
format!("{version} · {presence}")
|
||||
} else {
|
||||
disabled_value.clone()
|
||||
};
|
||||
@@ -252,6 +287,26 @@ fn draw_section(frame: &mut Frame, area: Rect, y: &mut u16, title: &'static str)
|
||||
*y = (*y).saturating_add(1);
|
||||
}
|
||||
|
||||
fn draw_subsection(frame: &mut Frame, area: Rect, y: &mut u16, title: &'static str) {
|
||||
if *y >= area.y + area.height {
|
||||
return;
|
||||
}
|
||||
let rect = Rect {
|
||||
x: area.x,
|
||||
y: *y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(title, theme::dim()),
|
||||
])),
|
||||
rect,
|
||||
);
|
||||
*y = (*y).saturating_add(1);
|
||||
}
|
||||
|
||||
fn draw_row(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
|
||||
+15
-3
@@ -142,7 +142,7 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let player = &state.player;
|
||||
let block = Block::bordered()
|
||||
.title(format!(
|
||||
" Queue — {} tracks · enter: play · d: remove · shift-v: select · shift-c: clear ",
|
||||
" Queue — {} tracks · enter: play · d: remove · shift-v: select · :clear ",
|
||||
player.queue.len()
|
||||
))
|
||||
.title_style(theme::header())
|
||||
@@ -212,7 +212,8 @@ fn format_secs(secs: f64) -> String {
|
||||
|
||||
/// Playback time, progress bar, queue position, volume and mode flags.
|
||||
/// Wider consoles get a longer bar and full flags; narrow ones drop pieces.
|
||||
fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<'static> {
|
||||
fn player_right_line(state: &AppState, width: u16) -> Line<'static> {
|
||||
let player = &state.player;
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
if let Some(track) = &player.current
|
||||
&& player.playing
|
||||
@@ -268,6 +269,17 @@ fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<
|
||||
} else {
|
||||
spans.push(Span::styled(format!(" {}%", player.volume), theme::dim()));
|
||||
}
|
||||
if width >= 70 {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(
|
||||
format!(
|
||||
"{} · online {}",
|
||||
state.device_playback.role.label(),
|
||||
state.device_playback.online_devices.max(1)
|
||||
),
|
||||
theme::dim(),
|
||||
));
|
||||
}
|
||||
// Keep a gap between the flags and the username block to the right.
|
||||
spans.push(Span::raw(" "));
|
||||
Line::from(spans)
|
||||
@@ -281,7 +293,7 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
// Layout: track title left, time/progress/flags on the right. The
|
||||
// right block is built first and gets a fixed width; the title
|
||||
// truncates into whatever is left.
|
||||
let center = player_right_line(player, area.width);
|
||||
let center = player_right_line(state, area.width);
|
||||
let center_width = (center.width() as u16).min(area.width);
|
||||
let [title_area, right_area] =
|
||||
Layout::horizontal([Constraint::Min(8), Constraint::Length(center_width)])
|
||||
|
||||
+117
-1
@@ -4,7 +4,9 @@ use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
|
||||
|
||||
use super::theme;
|
||||
use crate::app::state::{AppState, EditField, Loadable, Popup, addable_playlists};
|
||||
use crate::app::state::{
|
||||
AppState, DevicePresenceSection, EditField, Loadable, Popup, addable_playlists,
|
||||
};
|
||||
use crate::library::models::{ArtistRef, TrackItem};
|
||||
|
||||
pub fn draw(frame: &mut Frame, state: &AppState) {
|
||||
@@ -54,10 +56,124 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
||||
Some(Popup::ConfirmDeviceRevoke { device_id, name }) => {
|
||||
draw_device_revoke(frame, device_id, name)
|
||||
}
|
||||
Some(Popup::ConnectedDevices { cursor }) => draw_connected_devices(frame, state, *cursor),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
|
||||
let rows = crate::app::popup::connected_device_rows(state);
|
||||
enum DisplayLine {
|
||||
Section(DevicePresenceSection),
|
||||
Row(usize),
|
||||
}
|
||||
let mut display_lines = Vec::new();
|
||||
let mut last_section = None;
|
||||
for (index, row) in rows.iter().enumerate() {
|
||||
if last_section != Some(row.section) {
|
||||
display_lines.push(DisplayLine::Section(row.section));
|
||||
last_section = Some(row.section);
|
||||
}
|
||||
display_lines.push(DisplayLine::Row(index));
|
||||
}
|
||||
let height =
|
||||
(display_lines.len() as u16 + 5).clamp(7, frame.area().height.saturating_sub(2).max(7));
|
||||
let area = centered(frame.area(), 76, height);
|
||||
let block = Block::bordered()
|
||||
.title(" Connected devices ")
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::accent());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let [summary_area, list_area, hint_area] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(inner);
|
||||
let active = state.device_playback.active_label();
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled("Role ", theme::dim()),
|
||||
Span::styled(state.device_playback.role.label(), theme::accent()),
|
||||
Span::raw(" "),
|
||||
Span::styled("Active ", theme::dim()),
|
||||
Span::raw(active),
|
||||
])),
|
||||
summary_area,
|
||||
);
|
||||
|
||||
let visible = usize::from(list_area.height.max(1));
|
||||
let selected = cursor.min(rows.len().saturating_sub(1));
|
||||
let selected_line = display_lines
|
||||
.iter()
|
||||
.position(|line| matches!(line, DisplayLine::Row(index) if *index == selected))
|
||||
.unwrap_or(0);
|
||||
let first = selected_line
|
||||
.saturating_sub(visible / 2)
|
||||
.min(display_lines.len().saturating_sub(visible));
|
||||
for (line_index, line) in display_lines.iter().enumerate().skip(first).take(visible) {
|
||||
let area = Rect {
|
||||
x: list_area.x,
|
||||
y: list_area.y + (line_index - first) as u16,
|
||||
width: list_area.width,
|
||||
height: 1,
|
||||
};
|
||||
let DisplayLine::Row(index) = line else {
|
||||
let DisplayLine::Section(section) = line else {
|
||||
continue;
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled(section.title(), theme::header())),
|
||||
area,
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let row = &rows[*index];
|
||||
let role = if row.revoked {
|
||||
"revoked"
|
||||
} else if row.active {
|
||||
"active"
|
||||
} else if row.is_self
|
||||
&& state.device_playback.role == crate::app::state::DevicePlaybackRole::Control
|
||||
{
|
||||
"control"
|
||||
} else {
|
||||
"device"
|
||||
};
|
||||
let play = if row.playing && row.paused {
|
||||
"paused"
|
||||
} else if row.playing {
|
||||
"playing"
|
||||
} else {
|
||||
"stopped"
|
||||
};
|
||||
let online = if row.online { "online" } else { "offline" };
|
||||
let marker = if row.is_self { "*" } else { " " };
|
||||
let line = Line::from(vec![
|
||||
Span::styled(format!("{marker} "), theme::accent()),
|
||||
Span::raw(row.name.clone()),
|
||||
Span::styled(format!(" {role} · {online} · {play}"), theme::dim()),
|
||||
Span::styled(format!(" · {} queued", row.queue_len), theme::dim()),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(line), area);
|
||||
if *index == selected {
|
||||
frame.buffer_mut().set_style(area, theme::tab_active());
|
||||
}
|
||||
}
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled(
|
||||
"enter: control selected / move active here · esc close",
|
||||
theme::dim(),
|
||||
))
|
||||
.alignment(Alignment::Center),
|
||||
hint_area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) {
|
||||
let area = centered(frame.area(), 46, 6);
|
||||
let block = Block::bordered()
|
||||
|
||||
Reference in New Issue
Block a user