a Jellyfin & Subsonic client for the terminal — powered by mpv, Chromecast and UPnP MediaRenderer
mpv
chromecast
mpris
navidrome
jellyfin
upnp
tui
1use ratatui::buffer::Buffer;
2use ratatui::layout::{Constraint, Direction, Layout, Rect};
3use ratatui::style::{Modifier, Style};
4use ratatui::symbols::border;
5use ratatui::text::{Line, Span};
6use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Widget};
7
8use fin_player::{PlaybackState, PlaybackStatus, RendererKind, RepeatMode};
9
10use crate::theme::{border_style, muted_style, title_style, Palette};
11
12pub struct PlayerBar<'a> {
13 pub state: &'a PlaybackState,
14 pub renderer: RendererKind,
15 pub renderer_label: &'a str,
16}
17
18fn fmt_time(secs: f64) -> String {
19 if !secs.is_finite() || secs < 0.0 {
20 return "0:00".into();
21 }
22 let s = secs as u64;
23 let (h, rem) = (s / 3600, s % 3600);
24 let (m, sec) = (rem / 60, rem % 60);
25 if h > 0 {
26 format!("{}:{:02}:{:02}", h, m, sec)
27 } else {
28 format!("{}:{:02}", m, sec)
29 }
30}
31
32fn status_icon(status: PlaybackStatus) -> (&'static str, Style) {
33 match status {
34 PlaybackStatus::Playing => (
35 "▶",
36 Style::default()
37 .fg(Palette::HIGHLIGHT)
38 .add_modifier(Modifier::BOLD),
39 ),
40 PlaybackStatus::Paused => (
41 "⏸",
42 Style::default()
43 .fg(Palette::WARN)
44 .add_modifier(Modifier::BOLD),
45 ),
46 PlaybackStatus::Buffering => (
47 "⋯",
48 Style::default()
49 .fg(Palette::SKY)
50 .add_modifier(Modifier::BOLD),
51 ),
52 PlaybackStatus::Stopped | PlaybackStatus::Idle => (
53 "⏹",
54 Style::default()
55 .fg(Palette::MUTED)
56 .add_modifier(Modifier::BOLD),
57 ),
58 }
59}
60
61impl<'a> Widget for PlayerBar<'a> {
62 fn render(self, area: Rect, buf: &mut Buffer) {
63 let block = Block::default()
64 .borders(Borders::ALL)
65 .border_set(border::ROUNDED)
66 .border_style(border_style(true))
67 .title_style(title_style())
68 .style(Style::default().bg(Palette::BG))
69 .title(" ⚡ Now Playing ");
70 let inner = block.inner(area);
71 block.render(area, buf);
72
73 let rows = Layout::default()
74 .direction(Direction::Vertical)
75 .constraints([
76 Constraint::Length(1),
77 Constraint::Length(1),
78 Constraint::Min(1),
79 ])
80 .split(inner);
81
82 let (icon, icon_style) = status_icon(self.state.status);
83 let renderer_icon = match self.renderer {
84 RendererKind::Mpv => " local",
85 RendererKind::Chromecast => " chromecast",
86 RendererKind::Upnp => "◈ upnp",
87 };
88 let (title_text, subtitle_text) = match &self.state.now_playing {
89 Some(item) => (item.title.clone(), item.subtitle.clone()),
90 None => ("Nothing playing".to_string(), String::new()),
91 };
92
93 // Row 1: status icon + track title + right-aligned renderer + volume
94 let title_line = Line::from(vec![
95 Span::styled(format!("{} ", icon), icon_style),
96 Span::styled(
97 title_text,
98 Style::default()
99 .fg(Palette::FG)
100 .add_modifier(Modifier::BOLD),
101 ),
102 ]);
103 // Compact "modes" glyph — only rendered when either mode is active.
104 // Colored when on / dim when off keeps the row balanced without
105 // gaining/losing columns as the user toggles.
106 let shuffle_span = Span::styled(
107 "⇄ ",
108 if self.state.shuffle {
109 Style::default()
110 .fg(Palette::HIGHLIGHT)
111 .add_modifier(Modifier::BOLD)
112 } else {
113 Style::default().fg(Palette::MUTED)
114 },
115 );
116 let (repeat_glyph, repeat_active) = match self.state.repeat {
117 RepeatMode::Off => ("↻", false),
118 RepeatMode::All => ("↻", true),
119 RepeatMode::One => ("↺", true),
120 };
121 let repeat_span = Span::styled(
122 format!("{} ", repeat_glyph),
123 if repeat_active {
124 Style::default()
125 .fg(Palette::HIGHLIGHT)
126 .add_modifier(Modifier::BOLD)
127 } else {
128 Style::default().fg(Palette::MUTED)
129 },
130 );
131 let rg_span = if self.state.replaygain.mode.is_active() {
132 Span::styled(
133 format!("RG:{} ", self.state.replaygain.mode.label()),
134 Style::default()
135 .fg(Palette::HIGHLIGHT)
136 .add_modifier(Modifier::BOLD),
137 )
138 } else {
139 Span::raw("")
140 };
141 let xf_span = if self.state.crossfade.mode.is_active() {
142 let glyph = match self.state.crossfade.mode {
143 fin_player::CrossfadeMode::Crossfade => "⋈",
144 fin_player::CrossfadeMode::Mixed => "≈",
145 fin_player::CrossfadeMode::Off => unreachable!(),
146 };
147 Span::styled(
148 format!("{} {:.0}s ", glyph, self.state.crossfade.duration_secs),
149 Style::default()
150 .fg(Palette::HIGHLIGHT)
151 .add_modifier(Modifier::BOLD),
152 )
153 } else {
154 Span::raw("")
155 };
156 let eq_span = if self.state.eq_enabled {
157 Span::styled(
158 "EQ ",
159 Style::default()
160 .fg(Palette::HIGHLIGHT)
161 .add_modifier(Modifier::BOLD),
162 )
163 } else {
164 Span::raw("")
165 };
166 let tone_span = if self.state.bass_db != 0 || self.state.treble_db != 0 {
167 Span::styled(
168 format!("B{:+}/T{:+} ", self.state.bass_db, self.state.treble_db),
169 Style::default()
170 .fg(Palette::HIGHLIGHT)
171 .add_modifier(Modifier::BOLD),
172 )
173 } else {
174 Span::raw("")
175 };
176 let right_line = Line::from(vec![
177 shuffle_span,
178 repeat_span,
179 rg_span,
180 xf_span,
181 eq_span,
182 tone_span,
183 Span::styled(
184 format!("♪ {}% ", (self.state.volume * 100.0) as i32),
185 Style::default().fg(Palette::HIGHLIGHT),
186 ),
187 Span::styled(
188 format!("{} ", renderer_icon),
189 Style::default()
190 .fg(Palette::ACCENT)
191 .add_modifier(Modifier::BOLD),
192 ),
193 Span::styled(
194 format!("on {}", self.renderer_label),
195 Style::default().fg(Palette::MUTED),
196 ),
197 ]);
198 let cols = Layout::default()
199 .direction(Direction::Horizontal)
200 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
201 .split(rows[0]);
202 Paragraph::new(title_line).render(cols[0], buf);
203 Paragraph::new(right_line)
204 .alignment(ratatui::layout::Alignment::Right)
205 .render(cols[1], buf);
206
207 // Row 2: subtitle
208 let sub_line = Line::from(vec![Span::styled(subtitle_text, muted_style())]);
209 Paragraph::new(sub_line).render(rows[1], buf);
210
211 // Row 3: progress bar with elapsed/total on either side
212 let cols2 = Layout::default()
213 .direction(Direction::Horizontal)
214 .constraints([
215 Constraint::Length(8),
216 Constraint::Min(10),
217 Constraint::Length(8),
218 ])
219 .split(rows[2]);
220 let elapsed = fmt_time(self.state.position_secs);
221 let total = if self.state.duration_secs > 0.0 {
222 fmt_time(self.state.duration_secs)
223 } else if let Some(item) = &self.state.now_playing {
224 item.duration_secs
225 .map(|s| fmt_time(s as f64))
226 .unwrap_or_else(|| "--:--".into())
227 } else {
228 "--:--".into()
229 };
230 Paragraph::new(Line::from(Span::styled(
231 elapsed,
232 Style::default().fg(Palette::SKY),
233 )))
234 .render(cols2[0], buf);
235 let ratio = if self.state.duration_secs > 0.0 {
236 (self.state.position_secs / self.state.duration_secs).clamp(0.0, 1.0)
237 } else {
238 0.0
239 };
240 Gauge::default()
241 .gauge_style(
242 Style::default()
243 .fg(Palette::PRIMARY)
244 .bg(Palette::SURFACE)
245 .add_modifier(Modifier::BOLD),
246 )
247 .ratio(ratio)
248 .label("")
249 .use_unicode(true)
250 .render(cols2[1], buf);
251 Paragraph::new(Line::from(Span::styled(
252 total,
253 Style::default().fg(Palette::SKY),
254 )))
255 .alignment(ratatui::layout::Alignment::Right)
256 .render(cols2[2], buf);
257 }
258}