Terminal-based Subsonic music client in Rust featuring bit-perfect audio playback via PipeWire sample rate switching, gapless playback, MPRIS2 desktop integration, cava audio visualizer with theme-matched gradients, 13 built-in color themes with custom TOML theme support, mouse controls, artist/album browser, playlist support, and play queue management. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
169 lines
5.7 KiB
Rust
169 lines
5.7 KiB
Rust
//! Header bar with page tabs and playback controls
|
|
|
|
use ratatui::{
|
|
buffer::Buffer,
|
|
layout::{Constraint, Layout, Rect},
|
|
style::{Modifier, Style},
|
|
text::{Line, Span},
|
|
widgets::{Tabs, Widget},
|
|
};
|
|
|
|
use crate::app::state::{Page, PlaybackState};
|
|
use crate::ui::theme::ThemeColors;
|
|
|
|
/// Header bar widget
|
|
pub struct Header {
|
|
current_page: Page,
|
|
playback_state: PlaybackState,
|
|
colors: ThemeColors,
|
|
}
|
|
|
|
impl Header {
|
|
pub fn new(current_page: Page, playback_state: PlaybackState, colors: ThemeColors) -> Self {
|
|
Self {
|
|
current_page,
|
|
playback_state,
|
|
colors,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Widget for Header {
|
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
|
if area.height < 1 {
|
|
return;
|
|
}
|
|
|
|
// Split header: [tabs] [playback controls]
|
|
let chunks = Layout::horizontal([Constraint::Min(40), Constraint::Length(30)]).split(area);
|
|
|
|
// Page tabs
|
|
let titles: Vec<Line> = vec![
|
|
Page::Artists,
|
|
Page::Queue,
|
|
Page::Playlists,
|
|
Page::Server,
|
|
Page::Settings,
|
|
]
|
|
.iter()
|
|
.map(|p: &Page| Line::from(format!("{} {}", p.shortcut(), p.label())))
|
|
.collect();
|
|
|
|
let tabs = Tabs::new(titles)
|
|
.select(self.current_page.index())
|
|
.highlight_style(
|
|
Style::default()
|
|
.fg(self.colors.primary)
|
|
.add_modifier(Modifier::BOLD),
|
|
)
|
|
.divider(" │ ");
|
|
|
|
tabs.render(chunks[0], buf);
|
|
|
|
// Playback controls
|
|
let nav_style = Style::default().fg(self.colors.muted);
|
|
let play_style = match self.playback_state {
|
|
PlaybackState::Playing => Style::default().fg(self.colors.accent),
|
|
_ => Style::default().fg(self.colors.muted),
|
|
};
|
|
let pause_style = match self.playback_state {
|
|
PlaybackState::Paused => Style::default().fg(self.colors.accent),
|
|
_ => Style::default().fg(self.colors.muted),
|
|
};
|
|
let stop_style = match self.playback_state {
|
|
PlaybackState::Stopped => Style::default().fg(self.colors.accent),
|
|
_ => Style::default().fg(self.colors.muted),
|
|
};
|
|
|
|
let controls = Line::from(vec![
|
|
Span::styled(" ⏮ ", nav_style),
|
|
Span::raw(" "),
|
|
Span::styled(" ▶ ", play_style),
|
|
Span::raw(" "),
|
|
Span::styled(" ⏸ ", pause_style),
|
|
Span::raw(" "),
|
|
Span::styled(" ⏹ ", stop_style),
|
|
Span::raw(" "),
|
|
Span::styled(" ⏭ ", nav_style),
|
|
]);
|
|
|
|
// Right-align controls - " ⏮ ▶ ⏸ ⏹ ⏭ " = 5*3 + 4*1 = 19
|
|
let controls_width = 19;
|
|
let x = chunks[1].x + chunks[1].width.saturating_sub(controls_width);
|
|
buf.set_line(x, chunks[1].y, &controls, controls_width);
|
|
}
|
|
}
|
|
|
|
/// Clickable region in the header
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum HeaderRegion {
|
|
Tab(Page),
|
|
PrevButton,
|
|
PlayButton,
|
|
PauseButton,
|
|
StopButton,
|
|
NextButton,
|
|
}
|
|
|
|
impl Header {
|
|
/// Determine which region was clicked
|
|
pub fn region_at(area: Rect, x: u16, _y: u16) -> Option<HeaderRegion> {
|
|
let chunks = Layout::horizontal([Constraint::Min(40), Constraint::Length(30)]).split(area);
|
|
|
|
if x >= chunks[0].x && x < chunks[0].x + chunks[0].width {
|
|
// Tab area — compute actual tab positions matching Tabs widget rendering.
|
|
// Tabs renders: [pad][title][pad] [divider] [pad][title][pad] ...
|
|
// Default padding = 1 space each side. Divider = " │ " = 3 chars.
|
|
let pages = [
|
|
Page::Artists,
|
|
Page::Queue,
|
|
Page::Playlists,
|
|
Page::Server,
|
|
Page::Settings,
|
|
];
|
|
let divider_width: u16 = 3; // " │ "
|
|
let padding: u16 = 1; // 1 space each side
|
|
|
|
let rel_x = x - chunks[0].x;
|
|
let mut cursor: u16 = 0;
|
|
for (i, page) in pages.iter().enumerate() {
|
|
let label = format!("{} {}", page.shortcut(), page.label());
|
|
let tab_width = padding + label.len() as u16 + padding;
|
|
if rel_x >= cursor && rel_x < cursor + tab_width {
|
|
return Some(HeaderRegion::Tab(*page));
|
|
}
|
|
cursor += tab_width;
|
|
// Add divider (except after the last tab)
|
|
if i < pages.len() - 1 {
|
|
cursor += divider_width;
|
|
}
|
|
}
|
|
return None;
|
|
}
|
|
|
|
if x >= chunks[1].x && x < chunks[1].x + chunks[1].width {
|
|
// Controls area — rendered as spans:
|
|
// " ⏮ " + " " + " ▶ " + " " + " ⏸ " + " " + " ⏹ " + " " + " ⏭ "
|
|
// Each button span: space + icon + space = 3 display cells (icon is 1 cell)
|
|
// Gap spans: 1 space each
|
|
// Total: 5*3 + 4*1 = 19 display cells
|
|
let controls_width: u16 = 19;
|
|
let control_start = chunks[1].x + chunks[1].width.saturating_sub(controls_width);
|
|
if x >= control_start {
|
|
let offset = x - control_start;
|
|
// Layout: [0..2] ⏮ [3] gap [4..6] ▶ [7] gap [8..10] ⏸ [11] gap [12..14] ⏹ [15] gap [16..18] ⏭
|
|
return match offset {
|
|
0..=2 => Some(HeaderRegion::PrevButton),
|
|
4..=6 => Some(HeaderRegion::PlayButton),
|
|
8..=10 => Some(HeaderRegion::PauseButton),
|
|
12..=14 => Some(HeaderRegion::StopButton),
|
|
16..=18 => Some(HeaderRegion::NextButton),
|
|
_ => None,
|
|
};
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
}
|