mecomp_tui/ui/components/
control_panel.rs1use std::time::Duration;
7
8use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
9use mecomp_core::state::{SeekType, StateRuntime, Status};
10use ratatui::{
11 layout::{Alignment, Constraint, Direction, Layout, Position},
12 prelude::Rect,
13 style::{Style, Stylize},
14 text::{Line, Span},
15 widgets::{Block, Borders, LineGauge},
16};
17use tokio::sync::mpsc::UnboundedSender;
18
19use crate::{
20 state::{
21 action::{Action, AudioAction, ComponentAction, PlaybackAction, VolumeAction},
22 component::ActiveComponent,
23 },
24 ui::colors::{GAUGE_FILLED, GAUGE_UNFILLED, TEXT_HIGHLIGHT_ALT, TEXT_NORMAL, border_color},
25};
26
27use super::{AppState, Component, ComponentRender, RenderProps};
28
29pub struct ControlPanel {
30 pub action_tx: UnboundedSender<Action>,
32 pub(crate) props: Props,
34}
35
36pub struct Props {
37 pub(crate) is_playing: bool,
38 pub(crate) muted: bool,
39 pub(crate) volume: f32,
40 pub(crate) song_runtime: Option<StateRuntime>,
41 pub(crate) song_title: Option<String>,
42 pub(crate) song_artist: Option<String>,
43}
44
45impl From<&AppState> for Props {
46 fn from(value: &AppState) -> Self {
47 let value = &value.audio;
48 Self {
49 is_playing: value.status == Status::Playing,
50 muted: value.muted,
51 volume: value.volume,
52 song_runtime: value.runtime,
53 song_title: value.current_song.as_ref().map(|song| song.title.clone()),
54 song_artist: value
55 .current_song
56 .as_ref()
57 .map(|song| song.artist.as_slice().join(", ")),
58 }
59 }
60}
61
62impl Component for ControlPanel {
63 fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
64 where
65 Self: Sized,
66 {
67 Self {
68 action_tx,
69 props: Props::from(state),
70 }
71 .move_with_state(state)
72 }
73
74 fn move_with_state(self, state: &AppState) -> Self
75 where
76 Self: Sized,
77 {
78 Self {
79 props: Props::from(state),
80 ..self
81 }
82 }
83
84 fn name(&self) -> &'static str {
85 "ControlPanel"
86 }
87
88 fn handle_key_event(&mut self, key: KeyEvent) {
89 match key.code {
90 KeyCode::Char(' ') => {
91 self.action_tx
92 .send(Action::Audio(AudioAction::Playback(PlaybackAction::Toggle)))
93 .unwrap();
94 }
95 KeyCode::Char('n') => {
96 self.action_tx
97 .send(Action::Audio(AudioAction::Playback(PlaybackAction::Next)))
98 .unwrap();
99 }
100 KeyCode::Char('p') => {
101 self.action_tx
102 .send(Action::Audio(AudioAction::Playback(
103 PlaybackAction::Previous,
104 )))
105 .unwrap();
106 }
107 KeyCode::Right => {
108 self.action_tx
109 .send(Action::Audio(AudioAction::Playback(PlaybackAction::Seek(
110 SeekType::RelativeForwards,
111 Duration::from_secs(5),
112 ))))
113 .unwrap();
114 }
115 KeyCode::Left => {
116 self.action_tx
117 .send(Action::Audio(AudioAction::Playback(PlaybackAction::Seek(
118 SeekType::RelativeBackwards,
119 Duration::from_secs(5),
120 ))))
121 .unwrap();
122 }
123 KeyCode::Char('+' | '=') => {
124 self.action_tx
125 .send(Action::Audio(AudioAction::Playback(
126 PlaybackAction::Volume(VolumeAction::Increase(0.05)),
127 )))
128 .unwrap();
129 }
130 KeyCode::Char('-' | '_') => {
131 self.action_tx
132 .send(Action::Audio(AudioAction::Playback(
133 PlaybackAction::Volume(VolumeAction::Decrease(0.05)),
134 )))
135 .unwrap();
136 }
137 KeyCode::Char('m') => {
138 self.action_tx
139 .send(Action::Audio(AudioAction::Playback(
140 PlaybackAction::ToggleMute,
141 )))
142 .unwrap();
143 }
144 _ => {}
146 }
147 }
148
149 fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
150 let MouseEvent {
151 kind, column, row, ..
152 } = mouse;
153 let mouse_position = Position::new(column, row);
154
155 if kind == MouseEventKind::Down(MouseButton::Left) && area.contains(mouse_position) {
156 self.action_tx
157 .send(Action::ActiveComponent(ComponentAction::Set(
158 ActiveComponent::ControlPanel,
159 )))
160 .unwrap();
161 }
162
163 let area = Rect {
165 y: area.y + 1,
166 height: area.height - 1,
167 ..area
168 };
169
170 let Areas {
172 play_pause,
173 song_progress,
174 volume,
175 ..
176 } = split_area(area);
177
178 let runtime_string_len =
180 u16::try_from(runtime_string(self.props.song_runtime).len()).unwrap_or(u16::MAX);
181 let song_progress = Rect {
182 x: song_progress.x + runtime_string_len,
183 width: song_progress.width - runtime_string_len,
184 ..song_progress
185 };
186 let play_pause = Rect {
188 x: play_pause.x + play_pause.width - 3,
189 width: 2,
190 ..play_pause
191 };
192 let volume = Rect {
194 width: u16::try_from(volume_string(self.props.muted, self.props.volume).len())
195 .unwrap_or(u16::MAX),
196 ..volume
197 };
198
199 match kind {
200 MouseEventKind::Down(MouseButton::Left) if play_pause.contains(mouse_position) => {
201 self.action_tx
202 .send(Action::Audio(AudioAction::Playback(PlaybackAction::Toggle)))
203 .unwrap();
204 }
205 MouseEventKind::Down(MouseButton::Left) if song_progress.contains(mouse_position) => {
206 let ratio =
208 f64::from(mouse_position.x - song_progress.x) / f64::from(song_progress.width);
209 self.action_tx
210 .send(Action::Audio(AudioAction::Playback(PlaybackAction::Seek(
211 SeekType::Absolute,
212 Duration::from_secs_f64(
213 self.props
214 .song_runtime
215 .map_or(0.0, |runtime| runtime.duration.as_secs_f64())
216 * ratio,
217 ),
218 ))))
219 .unwrap();
220 }
221 MouseEventKind::Down(MouseButton::Left) if volume.contains(mouse_position) => {
222 self.action_tx
223 .send(Action::Audio(AudioAction::Playback(
224 PlaybackAction::ToggleMute,
225 )))
226 .unwrap();
227 }
228 MouseEventKind::ScrollUp if volume.contains(mouse_position) => {
229 self.action_tx
230 .send(Action::Audio(AudioAction::Playback(
231 PlaybackAction::Volume(VolumeAction::Increase(0.05)),
232 )))
233 .unwrap();
234 }
235 MouseEventKind::ScrollDown if volume.contains(mouse_position) => {
236 self.action_tx
237 .send(Action::Audio(AudioAction::Playback(
238 PlaybackAction::Volume(VolumeAction::Decrease(0.05)),
239 )))
240 .unwrap();
241 }
242 _ => {}
243 }
244 }
245}
246
247fn runtime_string(runtime: Option<StateRuntime>) -> String {
248 runtime.map_or_else(
249 || String::from("0.0/0.0"),
250 |runtime| {
251 format!(
252 "{}:{:04.1}/{}:{:04.1}",
253 runtime.seek_position.as_secs() / 60,
254 runtime.seek_position.as_secs_f32() % 60.0,
255 runtime.duration.as_secs() / 60,
256 runtime.duration.as_secs_f32() % 60.0
257 )
258 },
259 )
260}
261
262fn volume_string(muted: bool, volume: f32) -> String {
263 format!(" {}: {:.1}", if muted { "🔇" } else { "🔊" }, volume * 100.)
264}
265
266#[derive(Debug)]
267struct Areas {
268 song_info: Rect,
269 play_pause: Rect,
270 song_progress: Rect,
271 volume: Rect,
272 instructions: Rect,
273}
274
275fn split_area(area: Rect) -> Areas {
276 let [song_info, playback_info_area, instructions] = Layout::default()
277 .direction(Direction::Vertical)
278 .constraints([
279 Constraint::Min(1), Constraint::Min(1), Constraint::Min(1), ])
283 .areas(area);
284
285 let [play_pause, song_progress, volume] = Layout::default()
287 .direction(Direction::Horizontal)
288 .constraints([
289 Constraint::Min(10), Constraint::Percentage(80), Constraint::Min(20), ])
293 .areas(playback_info_area);
294
295 Areas {
296 song_info,
297 play_pause,
298 song_progress,
299 volume,
300 instructions,
301 }
302}
303
304impl ComponentRender<RenderProps> for ControlPanel {
305 fn render_border(&self, frame: &mut ratatui::Frame<'_>, props: RenderProps) -> RenderProps {
306 let border_style = Style::default().fg(border_color(props.is_focused).into());
307
308 let block = Block::new()
309 .borders(Borders::TOP)
310 .border_style(border_style);
311 let block_area = block.inner(props.area);
312 frame.render_widget(block, props.area);
313
314 RenderProps {
315 area: block_area,
316 ..props
317 }
318 }
319
320 fn render_content(&self, frame: &mut ratatui::Frame<'_>, props: RenderProps) {
321 let Areas {
322 song_info,
323 play_pause,
324 song_progress,
325 volume,
326 instructions,
327 } = split_area(props.area);
328
329 let song_info_widget = self.props.song_title.clone().map_or_else(
331 || {
332 Line::from("No Song Playing")
333 .style(Style::default().bold().fg((*TEXT_NORMAL).into()))
334 .centered()
335 },
336 |song_title| {
337 Line::from(vec![
338 Span::styled(
339 song_title,
340 Style::default().bold().fg((*TEXT_HIGHLIGHT_ALT).into()),
341 ),
342 Span::raw(" "),
343 Span::styled(
344 self.props.song_artist.clone().unwrap_or_default(),
345 Style::default().italic().fg((*TEXT_NORMAL).into()),
346 ),
347 ])
348 .centered()
349 },
350 );
351
352 frame.render_widget(song_info_widget, song_info);
353
354 let play_pause_indicator = if self.props.is_playing {
357 "\u{23f8} " } else {
359 "\u{23f5} " };
361 frame.render_widget(
362 Line::from(play_pause_indicator)
363 .bold()
364 .alignment(Alignment::Right),
365 play_pause,
366 );
367
368 frame.render_widget(
370 LineGauge::default()
371 .label(Line::from(runtime_string(self.props.song_runtime)))
372 .filled_style(Style::default().fg((*GAUGE_FILLED).into()).bold())
373 .unfilled_style(Style::default().fg((*GAUGE_UNFILLED).into()).bold())
374 .ratio(self.props.song_runtime.map_or(0.0, |runtime| {
375 (runtime.seek_position.as_secs_f64() / runtime.duration.as_secs_f64())
376 .clamp(0.0, 1.0)
377 })),
378 song_progress,
379 );
380
381 frame.render_widget(
383 Line::from(volume_string(self.props.muted, self.props.volume))
385 .style(Style::default().bold().fg((*TEXT_NORMAL).into()))
386 .alignment(Alignment::Left),
387 volume,
388 );
389
390 frame.render_widget(
392 Line::from(
393 "n/p: next/previous | \u{2423}: play/pause | m: mute | +/-: volume | ←/→: seek",
394 )
395 .italic()
396 .alignment(Alignment::Center),
397 instructions,
398 );
399 }
400}