a Jellyfin & Subsonic client for the terminal — powered by mpv, Chromecast and UPnP MediaRenderer
mpv chromecast mpris navidrome jellyfin upnp tui
0

Configure Feed

Select the types of activity you want to include in your feed.

fin / crates / fin-tui / src / widgets / player_bar.rs
9.0 kB 258 lines
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}