init federation
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
//! The Federation tab: settings rows on top, a live status block below.
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
|
||||
use super::theme;
|
||||
use crate::app::state::{AppState, FedRow};
|
||||
|
||||
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let block = Block::bordered()
|
||||
.title(" Federation ")
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::dim());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let rows_height = FedRow::ALL.len() as u16;
|
||||
let [rows_area, _, status_area] = Layout::vertical([
|
||||
Constraint::Length(rows_height),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.areas(inner);
|
||||
|
||||
let settings = &state.federation.settings;
|
||||
let on_off = |on: bool| if on { "on" } else { "off" };
|
||||
for (index, row) in FedRow::ALL.iter().enumerate() {
|
||||
let (label, value) = match row {
|
||||
FedRow::Toggle => ("Federation", on_off(settings.enabled).to_string()),
|
||||
FedRow::NetworkId => (
|
||||
"Network ID (shared secret)",
|
||||
if settings.network_id.is_empty() {
|
||||
"(not set — press enter)".to_string()
|
||||
} else {
|
||||
settings.network_id.clone()
|
||||
},
|
||||
),
|
||||
FedRow::SaveOnListen => (
|
||||
"Save federated tracks to the library on listen",
|
||||
on_off(settings.save_on_listen).to_string(),
|
||||
),
|
||||
FedRow::SyncNow => ("Publish the library now", "↵".to_string()),
|
||||
FedRow::ShowTicket => ("Show my connection ticket", "↵".to_string()),
|
||||
FedRow::Connect => ("Connect to a peer by ticket…", "↵".to_string()),
|
||||
};
|
||||
let selected = index == state.federation.cursor;
|
||||
let rect = Rect {
|
||||
x: rows_area.x,
|
||||
y: rows_area.y + index as u16,
|
||||
width: rows_area.width,
|
||||
height: 1,
|
||||
};
|
||||
if rect.y >= rows_area.y + rows_area.height {
|
||||
break;
|
||||
}
|
||||
let marker = if selected { "▶ " } else { " " };
|
||||
let label_width = 48usize;
|
||||
let line = Line::from(vec![
|
||||
Span::styled(marker, theme::accent()),
|
||||
Span::styled(format!("{label:<label_width$}"), if selected {
|
||||
theme::accent()
|
||||
} else {
|
||||
ratatui::style::Style::default()
|
||||
}),
|
||||
Span::styled(value, theme::dim()),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(line), rect);
|
||||
}
|
||||
|
||||
draw_status(frame, status_area, state);
|
||||
}
|
||||
|
||||
fn status_line(label: &str, value: String) -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{label:<22}"), theme::dim()),
|
||||
Span::raw(value),
|
||||
])
|
||||
}
|
||||
|
||||
fn short_id(id: &str) -> String {
|
||||
id.chars().take(12).collect::<String>() + "…"
|
||||
}
|
||||
|
||||
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 {
|
||||
None => lines.push(Line::styled("loading…", theme::dim())),
|
||||
Some(status) if !status.running => {
|
||||
lines.push(status_line("Node", "stopped".to_string()));
|
||||
if let Some(error) = &status.last_error {
|
||||
lines.push(status_line("Error", error.clone()));
|
||||
}
|
||||
lines.push(Line::default());
|
||||
lines.push(Line::styled(
|
||||
"Enable federation and set a network id — every instance using the",
|
||||
theme::dim(),
|
||||
));
|
||||
lines.push(Line::styled(
|
||||
"same id (furumi TUI or furumi-fd) finds the others automatically.",
|
||||
theme::dim(),
|
||||
));
|
||||
}
|
||||
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()));
|
||||
let peers = if status.connected_peers.is_empty() {
|
||||
"none yet".to_string()
|
||||
} else {
|
||||
let names: Vec<String> =
|
||||
status.connected_peers.iter().map(|p| short_id(p)).collect();
|
||||
format!("{} — {}", status.connected_peers.len(), names.join(", "))
|
||||
};
|
||||
lines.push(status_line("Connected peers", peers));
|
||||
lines.push(status_line(
|
||||
"Known contacts",
|
||||
status.known_contacts.to_string(),
|
||||
));
|
||||
lines.push(status_line(
|
||||
"Published items",
|
||||
status.published_items.to_string(),
|
||||
));
|
||||
lines.push(status_line(
|
||||
"Last sync",
|
||||
status
|
||||
.last_sync
|
||||
.clone()
|
||||
.unwrap_or_else(|| "not yet".to_string()),
|
||||
));
|
||||
if let Some(error) = &status.last_error {
|
||||
lines.push(status_line("Error", error.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
frame.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
+58
-21
@@ -6,7 +6,7 @@ use ratatui::widgets::{Block, Paragraph, Row, Table};
|
||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
|
||||
use super::{art, theme};
|
||||
use crate::api::models::{ArtistCard, ReleaseCard};
|
||||
use crate::library::models::{ArtistCard, ReleaseCard, SearchResults};
|
||||
use crate::app::state::{
|
||||
ART_CELL_HEIGHT, ART_CELL_WIDTH, ART_HEADER_HEIGHT, ART_HEADER_WIDTH, AppState, ArtState,
|
||||
GlobalView, Loadable, TILE_HEIGHT, TILE_WIDTH, ViewMode, release_groups,
|
||||
@@ -315,7 +315,7 @@ fn draw_grid_tiles(frame: &mut Frame, inner: Rect, state: &AppState) {
|
||||
draw_tile(
|
||||
frame,
|
||||
tile,
|
||||
tile_art(state, artist.image_url.as_ref()),
|
||||
tile_art(state, artist.image_path.as_ref()),
|
||||
&artist.name,
|
||||
&artist_tile_meta(artist),
|
||||
index == global.selected,
|
||||
@@ -395,7 +395,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
||||
height: ART_HEADER_HEIGHT.min(art_area.height),
|
||||
..art_area
|
||||
},
|
||||
header_art(state, detail.image_url.as_ref()),
|
||||
header_art(state, detail.image_path.as_ref()),
|
||||
);
|
||||
let mut about = format!("{} releases", detail.releases.len());
|
||||
if !detail.featured_tracks.is_empty() {
|
||||
@@ -536,7 +536,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
||||
draw_tile(
|
||||
frame,
|
||||
tile,
|
||||
tile_art(state, release.cover_url.as_ref()),
|
||||
tile_art(state, release.cover_path.as_ref()),
|
||||
&release.title,
|
||||
&release_tile_meta(release),
|
||||
cursor == tracks + position,
|
||||
@@ -648,13 +648,12 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor
|
||||
height: ART_HEADER_HEIGHT.min(art_area.height),
|
||||
..art_area
|
||||
},
|
||||
header_art(state, detail.cover_url.as_ref()),
|
||||
header_art(state, detail.cover_path.as_ref()),
|
||||
);
|
||||
|
||||
let artists: Vec<&str> = detail.artists.iter().map(|a| a.name.as_str()).collect();
|
||||
let year = detail.year.map(|y| format!(" · {y}")).unwrap_or_default();
|
||||
let uploaders: Vec<&str> = detail.uploaders.iter().map(|u| u.name.as_str()).collect();
|
||||
let mut info = vec![
|
||||
let info = vec![
|
||||
Line::default(),
|
||||
Line::styled(detail.title.clone(), theme::header()),
|
||||
Line::raw(artists.join(", ")),
|
||||
@@ -668,12 +667,6 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor
|
||||
theme::dim(),
|
||||
),
|
||||
];
|
||||
if !uploaders.is_empty() {
|
||||
info.push(Line::styled(
|
||||
format!("uploaded by {}", uploaders.join(", ")),
|
||||
theme::dim(),
|
||||
));
|
||||
}
|
||||
frame.render_widget(Paragraph::new(info), info_area);
|
||||
|
||||
// Track list with centered scrolling.
|
||||
@@ -719,15 +712,20 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
}
|
||||
let inner = bordered(frame, area, title);
|
||||
|
||||
let Some(results) = &search.results else {
|
||||
let hint = if search.query.is_empty() {
|
||||
"type to search artists, releases and tracks"
|
||||
} else {
|
||||
"searching…"
|
||||
};
|
||||
return centered_line(frame, inner, Line::styled(hint, theme::dim()));
|
||||
let empty_results = SearchResults::default();
|
||||
let results = match &search.results {
|
||||
Some(results) => results,
|
||||
None if !state.search.fed_tracks.is_empty() || state.search.fed_loading => &empty_results,
|
||||
None => {
|
||||
let hint = if search.query.is_empty() {
|
||||
"type to search artists, releases and tracks"
|
||||
} else {
|
||||
"searching…"
|
||||
};
|
||||
return centered_line(frame, inner, Line::styled(hint, theme::dim()));
|
||||
}
|
||||
};
|
||||
if results.len() == 0 {
|
||||
if results.len() == 0 && state.search.fed_tracks.is_empty() && !state.search.fed_loading {
|
||||
return centered_line(frame, inner, Line::styled("nothing found", theme::dim()));
|
||||
}
|
||||
|
||||
@@ -784,6 +782,45 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
// Tracks found on the federated network, marked with the owning peer.
|
||||
if !state.search.fed_tracks.is_empty() || state.search.fed_loading {
|
||||
if !rows.is_empty() {
|
||||
rows.push((Line::default(), None, None));
|
||||
}
|
||||
let header = if state.search.fed_loading {
|
||||
"Federation · searching…"
|
||||
} else {
|
||||
"Federation"
|
||||
};
|
||||
rows.push((Line::styled(header, theme::header()), None, None));
|
||||
for fed in &state.search.fed_tracks {
|
||||
let origin = if fed.own {
|
||||
"your library".to_string()
|
||||
} else {
|
||||
format!("peer {}…", fed.owner_short())
|
||||
};
|
||||
let mut meta = fed.duration_label();
|
||||
if let Some(year) = fed.year {
|
||||
if !meta.is_empty() {
|
||||
meta.push_str(" · ");
|
||||
}
|
||||
meta.push_str(&year.to_string());
|
||||
}
|
||||
rows.push((
|
||||
Line::from(vec![
|
||||
Span::styled("⇅ ", theme::accent()),
|
||||
Span::raw(fed.title.clone()),
|
||||
Span::styled(
|
||||
format!(" {} · {}", fed.artist_line(), origin),
|
||||
theme::dim(),
|
||||
),
|
||||
]),
|
||||
Some(meta),
|
||||
Some(index),
|
||||
));
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let cursor_row = rows
|
||||
.iter()
|
||||
|
||||
-230
@@ -1,230 +0,0 @@
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph, Wrap};
|
||||
|
||||
use super::theme;
|
||||
use crate::app::state::{LoginField, LoginForm, LoginMode};
|
||||
|
||||
pub fn draw(frame: &mut Frame, form: &LoginForm) {
|
||||
match form.mode {
|
||||
LoginMode::Form => draw_form(frame, form),
|
||||
LoginMode::SsoPending => draw_sso_pending(frame, form),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_form(frame: &mut Frame, form: &LoginForm) {
|
||||
let area = centered(frame.area(), 52, 19);
|
||||
let block = Block::bordered()
|
||||
.title(" Sign in to furumi ")
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::accent());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
// SSO is the primary path: server URL + SSO button up top, the rarely
|
||||
// used password fallback below a separator.
|
||||
let [
|
||||
server,
|
||||
sso_button,
|
||||
separator,
|
||||
username,
|
||||
password,
|
||||
signin_button,
|
||||
message,
|
||||
hint,
|
||||
] = Layout::vertical([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(inner);
|
||||
|
||||
draw_field(
|
||||
frame,
|
||||
server,
|
||||
"Server URL",
|
||||
&form.server_url,
|
||||
false,
|
||||
form.focus == LoginField::ServerUrl,
|
||||
);
|
||||
draw_button(
|
||||
frame,
|
||||
sso_button,
|
||||
"[ Continue with SSO ]",
|
||||
form.focus == LoginField::SsoButton,
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled("── or sign in with password ──", theme::dim()))
|
||||
.alignment(Alignment::Center),
|
||||
separator,
|
||||
);
|
||||
draw_field(
|
||||
frame,
|
||||
username,
|
||||
"Username",
|
||||
&form.username,
|
||||
false,
|
||||
form.focus == LoginField::Username,
|
||||
);
|
||||
draw_field(
|
||||
frame,
|
||||
password,
|
||||
"Password",
|
||||
&form.password,
|
||||
true,
|
||||
form.focus == LoginField::Password,
|
||||
);
|
||||
draw_button(
|
||||
frame,
|
||||
signin_button,
|
||||
"[ Sign in ]",
|
||||
form.focus == LoginField::SignInButton,
|
||||
);
|
||||
|
||||
draw_message(frame, message, form);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled(
|
||||
"tab/↑↓ move · enter submit · ctrl-c quit",
|
||||
theme::dim(),
|
||||
))
|
||||
.alignment(Alignment::Center),
|
||||
hint,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_sso_pending(frame: &mut Frame, form: &LoginForm) {
|
||||
// The URL stays on ONE line (wrapping breaks copy-paste); the dialog is
|
||||
// as wide as the terminal allows and ctrl-l copies the full link.
|
||||
let width =
|
||||
(form.sso_url.len() as u16 + 4).clamp(48, frame.area().width.saturating_sub(2).max(40));
|
||||
let area = centered(frame.area(), width, 14.min(frame.area().height));
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Continue with SSO ")
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::accent());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let [steps, url_label, url, paste, message, hint] = Layout::vertical([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(inner);
|
||||
|
||||
let lines = if let Some(port) = form.sso_port {
|
||||
vec![
|
||||
Line::raw("1. Finish signing in, in the browser window."),
|
||||
Line::from(vec![
|
||||
Span::raw("2. Sign-in completes here automatically "),
|
||||
Span::styled(format!("(waiting on 127.0.0.1:{port})"), theme::dim()),
|
||||
]),
|
||||
Line::raw("3. If it doesn't, paste the code from the page below."),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
Line::raw("1. Finish signing in, in the browser window."),
|
||||
Line::raw("2. Copy the code shown on the final page."),
|
||||
Line::raw("3. Paste it below and press Enter."),
|
||||
]
|
||||
};
|
||||
frame.render_widget(Paragraph::new(lines), steps);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled(
|
||||
"If the browser didn't open — ctrl-l copies this link, ctrl-o retries:",
|
||||
theme::dim(),
|
||||
)),
|
||||
url_label,
|
||||
);
|
||||
// One line, never wrapped: a wrapped URL copies with a line break and
|
||||
// stops working. If it doesn't fit, ctrl-l still copies it whole.
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled(form.sso_url.clone(), theme::accent())),
|
||||
url,
|
||||
);
|
||||
|
||||
draw_field(frame, paste, "Link or code", &form.sso_paste, false, true);
|
||||
draw_message(frame, message, form);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled(
|
||||
"enter submit · ctrl-l copy link · esc back · ctrl-c quit",
|
||||
theme::dim(),
|
||||
))
|
||||
.alignment(Alignment::Center),
|
||||
hint,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_field(frame: &mut Frame, area: Rect, label: &str, value: &str, mask: bool, focused: bool) {
|
||||
let border = if focused {
|
||||
theme::accent()
|
||||
} else {
|
||||
theme::dim()
|
||||
};
|
||||
let block = Block::bordered().title(label).border_style(border);
|
||||
let shown = if mask {
|
||||
"•".repeat(value.chars().count())
|
||||
} else {
|
||||
value.to_string()
|
||||
};
|
||||
// Keep the tail visible when the value overflows the field.
|
||||
let width = block.inner(area).width.saturating_sub(1) as usize;
|
||||
let mut text: String = shown
|
||||
.chars()
|
||||
.skip(shown.chars().count().saturating_sub(width))
|
||||
.collect();
|
||||
if focused {
|
||||
text.push('█');
|
||||
}
|
||||
frame.render_widget(Paragraph::new(text).block(block), area);
|
||||
}
|
||||
|
||||
fn draw_button(frame: &mut Frame, area: Rect, label: &str, focused: bool) {
|
||||
let style = if focused {
|
||||
theme::tab_active()
|
||||
} else {
|
||||
theme::dim()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled(label, style)).alignment(Alignment::Center),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_message(frame: &mut Frame, area: Rect, form: &LoginForm) {
|
||||
let line = if form.busy {
|
||||
Line::styled("signing in…", theme::accent())
|
||||
} else if let Some(error) = &form.error {
|
||||
Line::styled(error.clone(), Style::new().fg(Color::Red))
|
||||
} else {
|
||||
Line::default()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(line)
|
||||
.wrap(Wrap { trim: true })
|
||||
.alignment(Alignment::Center),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn centered(area: Rect, width: u16, height: u16) -> Rect {
|
||||
let [rect] = Layout::horizontal([Constraint::Length(width.min(area.width))])
|
||||
.flex(Flex::Center)
|
||||
.areas(area);
|
||||
let [rect] = Layout::vertical([Constraint::Length(height.min(area.height))])
|
||||
.flex(Flex::Center)
|
||||
.areas(rect);
|
||||
rect
|
||||
}
|
||||
+10
-58
@@ -1,6 +1,6 @@
|
||||
pub mod art;
|
||||
mod federation;
|
||||
mod global;
|
||||
mod login;
|
||||
mod logs;
|
||||
mod playlists;
|
||||
mod popup;
|
||||
@@ -12,14 +12,10 @@ use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Clear, Paragraph, Tabs};
|
||||
|
||||
use crate::app::state::{AppState, Screen, Tab, TrackSelectionScope};
|
||||
use crate::app::state::{AppState, Tab, TrackSelectionScope};
|
||||
use crate::config::keymap::Keymap;
|
||||
|
||||
pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
|
||||
if state.screen == Screen::Login {
|
||||
login::draw(frame, &state.login);
|
||||
return;
|
||||
}
|
||||
let [tabs_area, main_area, status_area] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
@@ -32,6 +28,7 @@ pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
|
||||
Tab::Global => global::draw(frame, main_area, state),
|
||||
Tab::Playlists => playlists::draw(frame, main_area, state),
|
||||
Tab::Queue => draw_queue(frame, main_area, state),
|
||||
Tab::Federation => federation::draw(frame, main_area, state),
|
||||
Tab::Logs => logs::draw(frame, main_area, state),
|
||||
}
|
||||
draw_status(frame, status_area, state);
|
||||
@@ -60,7 +57,7 @@ pub(crate) fn track_row(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
state: &AppState,
|
||||
track: &crate::api::models::TrackItem,
|
||||
track: &crate::library::models::TrackItem,
|
||||
index_label: String,
|
||||
selected: bool,
|
||||
visual_selected: bool,
|
||||
@@ -92,7 +89,7 @@ pub(crate) fn track_row(
|
||||
}
|
||||
|
||||
pub(crate) fn track_meta_suffix(
|
||||
track: &crate::api::models::TrackItem,
|
||||
track: &crate::library::models::TrackItem,
|
||||
include_tech: bool,
|
||||
) -> String {
|
||||
let has_tech = track.audio_format.is_some()
|
||||
@@ -201,8 +198,8 @@ fn format_secs(secs: f64) -> String {
|
||||
/// 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> {
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
if let Some(track) = &player.current {
|
||||
if player.playing {
|
||||
if let Some(track) = &player.current
|
||||
&& player.playing {
|
||||
let bar_width: usize = match width {
|
||||
0..=59 => 0,
|
||||
60..=79 => 8,
|
||||
@@ -227,7 +224,6 @@ fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if width >= 80 {
|
||||
let volume_cells = usize::from(player.volume / 10);
|
||||
spans.extend([
|
||||
@@ -260,65 +256,21 @@ fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<
|
||||
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);
|
||||
|
||||
let player = &state.player;
|
||||
// Layout: track title left, time/progress/flags centered, user right.
|
||||
// The center block is built first and gets a fixed width; the title
|
||||
// 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_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()),
|
||||
Span::raw(user.name.clone()),
|
||||
])
|
||||
});
|
||||
let user_width = user_line.as_ref().map_or(0, |l| l.width() as u16);
|
||||
let [title_area, right_area, device_area, user_area] = Layout::horizontal([
|
||||
let [title_area, right_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),
|
||||
user_area,
|
||||
);
|
||||
}
|
||||
|
||||
let mut spans = Vec::new();
|
||||
match &player.current {
|
||||
|
||||
+1
-19
@@ -78,26 +78,8 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
} else {
|
||||
Span::raw(" ")
|
||||
};
|
||||
let mut flags = Vec::new();
|
||||
if !playlist.is_own {
|
||||
if let Some(owner) = &playlist.owner_name {
|
||||
flags.push(format!("by {owner}"));
|
||||
}
|
||||
}
|
||||
if playlist.is_public {
|
||||
flags.push("public".to_string());
|
||||
}
|
||||
let suffix = if flags.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", flags.join(" · "))
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
marker,
|
||||
Span::raw(playlist.title.clone()),
|
||||
Span::styled(suffix, theme::dim()),
|
||||
])),
|
||||
Paragraph::new(Line::from(vec![marker, Span::raw(playlist.title.clone())])),
|
||||
row,
|
||||
);
|
||||
frame.render_widget(
|
||||
|
||||
+130
-86
@@ -4,8 +4,8 @@ use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
|
||||
|
||||
use super::theme;
|
||||
use crate::api::models::{ArtistRef, TrackItem};
|
||||
use crate::app::state::{AppState, Loadable, Popup, addable_playlists};
|
||||
use crate::app::state::{AppState, EditField, Loadable, Popup, addable_playlists};
|
||||
use crate::library::models::{ArtistRef, TrackItem};
|
||||
|
||||
pub fn draw(frame: &mut Frame, state: &AppState) {
|
||||
match state.popup.as_ref() {
|
||||
@@ -13,102 +13,156 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
||||
draw_picker(frame, state, &track.title, *cursor)
|
||||
}
|
||||
Some(Popup::NewPlaylist { input, busy, .. }) => draw_name_entry(frame, input, *busy),
|
||||
Some(Popup::Devices { cursor }) => draw_devices(frame, state, *cursor),
|
||||
Some(Popup::Edit {
|
||||
title,
|
||||
fields,
|
||||
focus,
|
||||
error,
|
||||
..
|
||||
}) => draw_edit(frame, title, fields, *focus, error.as_deref()),
|
||||
Some(Popup::ConfirmDelete { label, .. }) => draw_confirm_delete(frame, label),
|
||||
Some(Popup::TrackInfo {
|
||||
tracks,
|
||||
cursor,
|
||||
scroll,
|
||||
}) => draw_track_info(frame, tracks, *cursor, *scroll),
|
||||
Some(Popup::LogDetail(entry)) => draw_log_detail(frame, entry),
|
||||
Some(Popup::FedInput { field, input }) => draw_fed_input(frame, field.title(), input),
|
||||
Some(Popup::FedText { title, text }) => draw_fed_text(frame, title, text),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
|
||||
let rows = state.devices.devices.len().max(1);
|
||||
let height = (rows as u16 + 4)
|
||||
.min(frame.area().height.saturating_sub(2))
|
||||
.max(7);
|
||||
let area = centered(frame.area(), 64, height);
|
||||
/// One-line text entry on the Federation tab (network id / peer ticket).
|
||||
fn draw_fed_input(frame: &mut Frame, title: &str, input: &str) {
|
||||
let area = centered(frame.area(), 64, 5);
|
||||
let block = Block::bordered()
|
||||
.title(" Connected devices ")
|
||||
.title(format!(" {title} "))
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::accent());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(block, area);
|
||||
let [entry_area, hint_area] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
|
||||
// Keep the tail visible when the value (a ticket) exceeds the width.
|
||||
let visible: String = {
|
||||
let width = usize::from(entry_area.width.saturating_sub(2));
|
||||
let chars: Vec<char> = input.chars().collect();
|
||||
let skip = chars.len().saturating_sub(width);
|
||||
chars[skip..].iter().collect()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::raw(visible),
|
||||
Span::styled("█", theme::accent()),
|
||||
])),
|
||||
entry_area,
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled("enter: apply · esc: cancel", theme::dim()))
|
||||
.alignment(Alignment::Center),
|
||||
hint_area,
|
||||
);
|
||||
}
|
||||
|
||||
/// Read-only wrapped text (this peer's federation ticket).
|
||||
fn draw_fed_text(frame: &mut Frame, title: &str, text: &str) {
|
||||
let width = frame.area().width.saturating_sub(8).clamp(24, 90);
|
||||
let text_width = usize::from(width.saturating_sub(2));
|
||||
let lines_needed = (text.chars().count() / text_width.max(1) + 3) as u16;
|
||||
let area = centered(frame.area(), width, lines_needed.clamp(5, frame.area().height));
|
||||
let block = Block::bordered()
|
||||
.title(format!(" {title} "))
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::accent());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(block, area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(text.to_string()).wrap(Wrap { trim: false }),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
/// Metadata edit form: one bordered input per field, the focused field gets
|
||||
/// the accent border and a cursor block.
|
||||
fn draw_edit(
|
||||
frame: &mut Frame,
|
||||
title: &str,
|
||||
fields: &[EditField],
|
||||
focus: usize,
|
||||
error: Option<&str>,
|
||||
) {
|
||||
let height = (fields.len() as u16 * 3 + 4).min(frame.area().height.saturating_sub(2));
|
||||
let area = centered(frame.area(), 60, height);
|
||||
let block = Block::bordered()
|
||||
.title(format!(" {title} "))
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::accent());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let [list_area, _, footer] = Layout::vertical([
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(inner);
|
||||
let mut constraints: Vec<Constraint> = fields.iter().map(|_| Constraint::Length(3)).collect();
|
||||
constraints.push(Constraint::Min(0));
|
||||
constraints.push(Constraint::Length(1));
|
||||
let areas = Layout::vertical(constraints).split(inner);
|
||||
|
||||
if state.devices.devices.is_empty() {
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled("waiting for device poll…", theme::dim()))
|
||||
.alignment(Alignment::Center),
|
||||
list_area,
|
||||
);
|
||||
} else {
|
||||
let visible = usize::from(list_area.height.max(1));
|
||||
let cursor = cursor.min(state.devices.devices.len() - 1);
|
||||
let first = cursor
|
||||
.saturating_sub(visible / 2)
|
||||
.min(state.devices.devices.len().saturating_sub(visible));
|
||||
for (index, device) in state
|
||||
.devices
|
||||
.devices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(first)
|
||||
.take(visible)
|
||||
{
|
||||
let row = Rect {
|
||||
x: list_area.x,
|
||||
y: list_area.y + (index - first) as u16,
|
||||
width: list_area.width,
|
||||
height: 1,
|
||||
};
|
||||
let marker = if device.is_active {
|
||||
Span::styled("▶ ", theme::accent())
|
||||
} else {
|
||||
Span::styled(" ", theme::dim())
|
||||
};
|
||||
let current = if device.is_current {
|
||||
" · this TUI"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let switching = if state.devices.switching_to.as_deref() == Some(device.id.as_str()) {
|
||||
" · switching"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let line = Line::from(vec![
|
||||
marker,
|
||||
Span::raw(device.name.clone()),
|
||||
Span::styled(
|
||||
format!(" · {}{current}{switching}", device.kind),
|
||||
theme::dim(),
|
||||
),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(line), row);
|
||||
if index == cursor {
|
||||
frame.buffer_mut().set_style(row, theme::tab_active());
|
||||
}
|
||||
for (index, field) in fields.iter().enumerate() {
|
||||
let focused = index == focus;
|
||||
let field_block = Block::bordered().title(field.label).border_style(if focused {
|
||||
theme::accent()
|
||||
} else {
|
||||
theme::dim()
|
||||
});
|
||||
let field_inner = field_block.inner(areas[index]);
|
||||
frame.render_widget(field_block, areas[index]);
|
||||
let width = usize::from(field_inner.width.saturating_sub(1));
|
||||
let mut shown: String = field
|
||||
.value
|
||||
.chars()
|
||||
.skip(field.value.chars().count().saturating_sub(width))
|
||||
.collect();
|
||||
if focused {
|
||||
shown.push('█');
|
||||
}
|
||||
frame.render_widget(Paragraph::new(shown), field_inner);
|
||||
}
|
||||
|
||||
let hint = if let Some(error) = &state.devices.poll_error {
|
||||
Line::styled(format!("sync error: {error}"), theme::dim())
|
||||
} else {
|
||||
Line::styled("enter make active · esc close", theme::dim())
|
||||
let footer = areas[areas.len() - 1];
|
||||
let hint = match error {
|
||||
Some(error) => Line::styled(error.to_string(), theme::accent()),
|
||||
None => Line::styled("tab/↑↓ field · enter save · esc cancel", theme::dim()),
|
||||
};
|
||||
frame.render_widget(Paragraph::new(hint).alignment(Alignment::Center), footer);
|
||||
}
|
||||
|
||||
fn draw_confirm_delete(frame: &mut Frame, label: &str) {
|
||||
let width = 64.min(frame.area().width.saturating_sub(4)).max(30);
|
||||
let area = centered(frame.area(), width, 7);
|
||||
let block = Block::bordered()
|
||||
.title(" Delete? ")
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::accent());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let [body, footer] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!("Delete {label}?"))
|
||||
.wrap(Wrap { trim: false })
|
||||
.alignment(Alignment::Center),
|
||||
body,
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled("enter/y delete · esc/n cancel", theme::dim()))
|
||||
.alignment(Alignment::Center),
|
||||
footer,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_log_detail(frame: &mut Frame, entry: &crate::config::logging::LogEntry) {
|
||||
let width = 90.min(frame.area().width.saturating_sub(4)).max(40);
|
||||
let height = 18.min(frame.area().height.saturating_sub(2)).max(7);
|
||||
@@ -203,7 +257,6 @@ fn track_info_lines(track: &TrackItem) -> Vec<Line<'static>> {
|
||||
track.duration_seconds
|
||||
),
|
||||
),
|
||||
field("Uploader", empty_dash(&track.uploader_name)),
|
||||
field("Audio format", opt_string(track.audio_format.clone())),
|
||||
field(
|
||||
"Bitrate",
|
||||
@@ -220,18 +273,9 @@ fn track_info_lines(track: &TrackItem) -> Vec<Line<'static>> {
|
||||
opt_map(track.audio_bit_depth, |v| format!("{v} bit")),
|
||||
),
|
||||
field("File size", file_size(track.file_size_bytes)),
|
||||
field("Last.fm listeners", opt_display(track.lastfm_listeners)),
|
||||
field("Last.fm plays", opt_display(track.lastfm_playcount)),
|
||||
field(
|
||||
"Last.fm rating",
|
||||
opt_map(track.lastfm_rating, |v| format!("{v:.3}")),
|
||||
),
|
||||
field(
|
||||
"Last.fm updated",
|
||||
opt_string(track.lastfm_updated_at.clone()),
|
||||
),
|
||||
field("Stream URL", empty_dash(&track.stream_url)),
|
||||
field("Cover URL", opt_string(track.cover_url.clone())),
|
||||
field("Plays", track.play_count.to_string()),
|
||||
field("File path", empty_dash(&track.file_path)),
|
||||
field("Cover path", opt_string(track.cover_path.clone())),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user