src/ui/widgets/status_bar.rs (6579B)
1 use ratatui::layout::{Constraint, Direction, Layout, Rect, Alignment};
2 use ratatui::style::{Color, Modifier, Style};
3 use ratatui::text::{Line, Span};
4 use ratatui::widgets::{Block, Borders, Paragraph};
5 use ratatui::Frame;
6
7 use crate::app::App;
8 use crate::app::state::{PlayState, SyncState};
9 use crate::ui::theme::Theme;
10 use crate::ui::text::format_time;
11
12 pub fn render_status_bar(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) {
13 let block = if let Some(err) = &app.audio_error {
14 // Nothing will ever play; without this the UI shows a play state and a
15 // moving progress bar over silence.
16 Block::default()
17 .borders(Borders::ALL)
18 .border_style(Style::default().fg(Color::Rgb(255, 90, 90)))
19 .title(format!(" [NO AUDIO] {} ", err))
20 .title_style(
21 Style::default()
22 .fg(Color::Rgb(255, 90, 90))
23 .add_modifier(Modifier::BOLD),
24 )
25 } else if app.sync_state == SyncState::Scanning {
26 Block::default()
27 .borders(Borders::ALL)
28 .border_style(Style::default().fg(Color::Rgb(255, 200, 80)))
29 .title(" [SYNCING] ")
30 .title_style(Style::default().fg(Color::Rgb(255, 200, 80)).add_modifier(Modifier::BOLD))
31 } else {
32 Block::default()
33 .borders(Borders::ALL)
34 .border_style(Style::default().fg(theme.border_unfocused))
35 };
36
37 let inner = block.inner(area);
38 frame.render_widget(block, area);
39
40 let cols = Layout::default()
41 .direction(Direction::Horizontal)
42 .constraints([
43 Constraint::Percentage(25),
44 Constraint::Percentage(50),
45 Constraint::Percentage(25),
46 ])
47 .split(inner);
48
49 // Left: Play state + time
50 let state_icon = match app.playback.state {
51 PlayState::Playing => "\u{F04B}", // nf-fa-play
52 PlayState::Paused => "\u{F04C}", // nf-fa-pause
53 PlayState::Stopped => "\u{F04D}", // nf-fa-stop
54 };
55
56 let pos = format_time(app.playback.position_secs);
57 let dur = format_time(app.playback.duration_secs);
58
59 let bitrate = app
60 .current_track()
61 .and_then(|t| t.bitrate)
62 .map(|b| format!(" ({}kbps)", b))
63 .unwrap_or_default();
64
65 let state_color = match app.playback.state {
66 PlayState::Playing => Color::Rgb(80, 255, 120), // bright green
67 PlayState::Paused => Color::Rgb(255, 200, 80), // amber
68 PlayState::Stopped => Color::Rgb(255, 100, 100), // soft red
69 };
70
71 let left_line1 = Line::from(vec![
72 Span::styled(
73 format!(" {} {}", state_icon, match app.playback.state {
74 PlayState::Playing => "Playing",
75 PlayState::Paused => "Paused",
76 PlayState::Stopped => "Stopped",
77 }),
78 Style::default().fg(state_color).add_modifier(Modifier::BOLD),
79 ),
80 ]);
81
82 let left_line2 = Line::from(vec![
83 Span::styled(
84 format!(" {}/{}{}", pos, dur, bitrate),
85 Style::default().fg(Color::Gray),
86 ),
87 ]);
88
89 let left = Paragraph::new(vec![left_line1, left_line2]);
90 frame.render_widget(left, cols[0]);
91
92 // Center: Track info
93 let (title, artist_album) = if let Some(track) = app.current_track() {
94 (
95 track.title.clone(),
96 format!("{} - {}", track.display_artist(), track.display_album()),
97 )
98 } else {
99 ("No track playing".to_string(), String::new())
100 };
101
102 let center_line1 = Line::from(Span::styled(
103 title,
104 Style::default().fg(Color::Rgb(100, 220, 255)).add_modifier(Modifier::BOLD),
105 )).alignment(Alignment::Center);
106
107 let center_line2 = Line::from(Span::styled(
108 artist_album,
109 Style::default().fg(Color::Rgb(200, 170, 255)),
110 )).alignment(Alignment::Center);
111
112 let center = Paragraph::new(vec![center_line1, center_line2]);
113 frame.render_widget(center, cols[1]);
114
115 // Right: Volume + shuffle/repeat
116 let vol_pct = (app.playback.volume * 100.0) as u8;
117
118 let shuffle_style = if app.playback.shuffle {
119 Style::default().fg(Color::Rgb(100, 220, 255)).add_modifier(Modifier::BOLD)
120 } else {
121 Style::default().fg(Color::DarkGray)
122 };
123
124 let repeat_style = match app.playback.repeat {
125 crate::app::state::RepeatMode::Off => Style::default().fg(Color::DarkGray),
126 crate::app::state::RepeatMode::All => Style::default().fg(Color::Rgb(120, 255, 180)).add_modifier(Modifier::BOLD),
127 crate::app::state::RepeatMode::One => Style::default().fg(Color::Rgb(255, 220, 100)).add_modifier(Modifier::BOLD),
128 };
129
130 // Volume staircase with gradient: green → yellow → orange → red
131 const STEPS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
132 let vol_colors: [Color; 8] = [
133 Color::Rgb(80, 200, 120), // green
134 Color::Rgb(120, 220, 100), // green-yellow
135 Color::Rgb(180, 230, 80), // yellow-green
136 Color::Rgb(230, 220, 60), // yellow
137 Color::Rgb(255, 190, 50), // amber
138 Color::Rgb(255, 150, 40), // orange
139 Color::Rgb(255, 110, 50), // red-orange
140 Color::Rgb(255, 70, 70), // red
141 ];
142 let filled = (vol_pct as u16 * 8 / 100).min(8) as usize;
143 let mut vol_spans = Vec::with_capacity(10);
144 for (i, &ch) in STEPS.iter().enumerate() {
145 let style = if i < filled {
146 Style::default().fg(vol_colors[i]).add_modifier(Modifier::BOLD)
147 } else {
148 Style::default().fg(Color::Indexed(238))
149 };
150 vol_spans.push(Span::styled(String::from(ch), style));
151 }
152 vol_spans.push(Span::styled(format!(" {}% ", vol_pct), Style::default().fg(Color::White)));
153
154 let right_line1 = Line::from(vol_spans).alignment(Alignment::Right);
155
156 let is_bookmarked = app.queue.current_index
157 .and_then(|qi| app.queue.tracks.get(qi))
158 .is_some_and(|&ti| app.is_in_playlist(ti));
159 let bookmark_style = if is_bookmarked {
160 Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
161 } else {
162 Style::default().fg(Color::DarkGray)
163 };
164
165 let right_line2 = Line::from(vec![
166 Span::styled("\u{F005} ", bookmark_style), // nf-fa-star
167 Span::styled("\u{F074} ", shuffle_style), // nf-fa-random
168 Span::styled(format!("{} ", app.playback.repeat.symbol()), repeat_style),
169 ]).alignment(Alignment::Right);
170
171 let right = Paragraph::new(vec![right_line1, right_line2]);
172 frame.render_widget(right, cols[2]);
173 }
174