mecomp_tui/ui/components/
control_panel.rs

1//! The control panel is a fixed height panel at the bottom of the screen that:
2//!
3//! - displays the current state of the player (playing, paused, stopped, etc.), and
4//! - allows users to control the player (play, pause, stop, etc.), volume, etc.
5
6use 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::{
25        BORDER_FOCUSED, BORDER_UNFOCUSED, GAUGE_FILLED, GAUGE_UNFILLED, TEXT_HIGHLIGHT_ALT,
26        TEXT_NORMAL,
27    },
28};
29
30use super::{AppState, Component, ComponentRender, RenderProps};
31
32pub struct ControlPanel {
33    /// Action Sender
34    pub action_tx: UnboundedSender<Action>,
35    /// Mapped Props from state
36    pub(crate) props: Props,
37}
38
39pub struct Props {
40    pub(crate) is_playing: bool,
41    pub(crate) muted: bool,
42    pub(crate) volume: f32,
43    pub(crate) song_runtime: Option<StateRuntime>,
44    pub(crate) song_title: Option<String>,
45    pub(crate) song_artist: Option<String>,
46}
47
48impl From<&AppState> for Props {
49    fn from(value: &AppState) -> Self {
50        let value = &value.audio;
51        Self {
52            is_playing: value.status == Status::Playing,
53            muted: value.muted,
54            volume: value.volume,
55            song_runtime: value.runtime,
56            song_title: value
57                .current_song
58                .as_ref()
59                .map(|song| song.title.to_string()),
60            song_artist: value.current_song.as_ref().map(|song| {
61                song.artist
62                    .iter()
63                    .map(ToString::to_string)
64                    .collect::<Vec<String>>()
65                    .join(", ")
66            }),
67        }
68    }
69}
70
71impl Component for ControlPanel {
72    fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
73    where
74        Self: Sized,
75    {
76        Self {
77            action_tx,
78            props: Props::from(state),
79        }
80        .move_with_state(state)
81    }
82
83    fn move_with_state(self, state: &AppState) -> Self
84    where
85        Self: Sized,
86    {
87        Self {
88            props: Props::from(state),
89            ..self
90        }
91    }
92
93    fn name(&self) -> &'static str {
94        "ControlPanel"
95    }
96
97    fn handle_key_event(&mut self, key: KeyEvent) {
98        match key.code {
99            KeyCode::Char(' ') => {
100                self.action_tx
101                    .send(Action::Audio(AudioAction::Playback(PlaybackAction::Toggle)))
102                    .unwrap();
103            }
104            KeyCode::Char('n') => {
105                self.action_tx
106                    .send(Action::Audio(AudioAction::Playback(PlaybackAction::Next)))
107                    .unwrap();
108            }
109            KeyCode::Char('p') => {
110                self.action_tx
111                    .send(Action::Audio(AudioAction::Playback(
112                        PlaybackAction::Previous,
113                    )))
114                    .unwrap();
115            }
116            KeyCode::Right => {
117                self.action_tx
118                    .send(Action::Audio(AudioAction::Playback(PlaybackAction::Seek(
119                        SeekType::RelativeForwards,
120                        Duration::from_secs(5),
121                    ))))
122                    .unwrap();
123            }
124            KeyCode::Left => {
125                self.action_tx
126                    .send(Action::Audio(AudioAction::Playback(PlaybackAction::Seek(
127                        SeekType::RelativeBackwards,
128                        Duration::from_secs(5),
129                    ))))
130                    .unwrap();
131            }
132            KeyCode::Char('+' | '=') => {
133                self.action_tx
134                    .send(Action::Audio(AudioAction::Playback(
135                        PlaybackAction::Volume(VolumeAction::Increase(0.05)),
136                    )))
137                    .unwrap();
138            }
139            KeyCode::Char('-' | '_') => {
140                self.action_tx
141                    .send(Action::Audio(AudioAction::Playback(
142                        PlaybackAction::Volume(VolumeAction::Decrease(0.05)),
143                    )))
144                    .unwrap();
145            }
146            KeyCode::Char('m') => {
147                self.action_tx
148                    .send(Action::Audio(AudioAction::Playback(
149                        PlaybackAction::ToggleMute,
150                    )))
151                    .unwrap();
152            }
153            // ignore other keys
154            _ => {}
155        }
156    }
157
158    fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
159        let MouseEvent {
160            kind, column, row, ..
161        } = mouse;
162        let mouse_position = Position::new(column, row);
163
164        // adjust area to exclude the border
165        let area = Rect {
166            y: area.y + 1,
167            height: area.height - 1,
168            ..area
169        };
170
171        // split the area into sub-areas
172        let Areas {
173            play_pause,
174            song_progress,
175            volume,
176            ..
177        } = split_area(area);
178
179        // adjust song_progress area to exclude the runtime label
180        let runtime_string_len =
181            u16::try_from(runtime_string(self.props.song_runtime).len()).unwrap_or(u16::MAX);
182        let song_progress = Rect {
183            x: song_progress.x + runtime_string_len,
184            width: song_progress.width - runtime_string_len,
185            ..song_progress
186        };
187        // adjust play/pause area to only include the icon
188        let play_pause = Rect {
189            x: play_pause.x + play_pause.width - 3,
190            width: 2,
191            ..play_pause
192        };
193        // adjust volume area to only include the label
194        let volume = Rect {
195            width: u16::try_from(volume_string(self.props.muted, self.props.volume).len())
196                .unwrap_or(u16::MAX),
197            ..volume
198        };
199
200        if kind == MouseEventKind::Down(MouseButton::Left) && area.contains(mouse_position) {
201            self.action_tx
202                .send(Action::ActiveComponent(ComponentAction::Set(
203                    ActiveComponent::ControlPanel,
204                )))
205                .unwrap();
206        }
207
208        match kind {
209            MouseEventKind::Down(MouseButton::Left) if play_pause.contains(mouse_position) => {
210                self.action_tx
211                    .send(Action::Audio(AudioAction::Playback(PlaybackAction::Toggle)))
212                    .unwrap();
213            }
214            MouseEventKind::Down(MouseButton::Left) if song_progress.contains(mouse_position) => {
215                // calculate the ratio of the click position to the song progress bar
216                #[allow(clippy::cast_lossless)]
217                let ratio =
218                    (mouse_position.x - song_progress.x) as f64 / song_progress.width as f64;
219                self.action_tx
220                    .send(Action::Audio(AudioAction::Playback(PlaybackAction::Seek(
221                        SeekType::Absolute,
222                        Duration::from_secs_f64(
223                            self.props
224                                .song_runtime
225                                .map_or(0.0, |runtime| runtime.duration.as_secs_f64())
226                                * ratio,
227                        ),
228                    ))))
229                    .unwrap();
230            }
231            MouseEventKind::Down(MouseButton::Left) if volume.contains(mouse_position) => {
232                self.action_tx
233                    .send(Action::Audio(AudioAction::Playback(
234                        PlaybackAction::ToggleMute,
235                    )))
236                    .unwrap();
237            }
238            MouseEventKind::ScrollUp if volume.contains(mouse_position) => {
239                self.action_tx
240                    .send(Action::Audio(AudioAction::Playback(
241                        PlaybackAction::Volume(VolumeAction::Increase(0.05)),
242                    )))
243                    .unwrap();
244            }
245            MouseEventKind::ScrollDown if volume.contains(mouse_position) => {
246                self.action_tx
247                    .send(Action::Audio(AudioAction::Playback(
248                        PlaybackAction::Volume(VolumeAction::Decrease(0.05)),
249                    )))
250                    .unwrap();
251            }
252            _ => {}
253        }
254    }
255}
256
257fn runtime_string(runtime: Option<StateRuntime>) -> String {
258    runtime.map_or_else(
259        || String::from("0.0/0.0"),
260        |runtime| {
261            format!(
262                "{}:{:04.1}/{}:{:04.1}",
263                runtime.seek_position.as_secs() / 60,
264                runtime.seek_position.as_secs_f32() % 60.0,
265                runtime.duration.as_secs() / 60,
266                runtime.duration.as_secs_f32() % 60.0
267            )
268        },
269    )
270}
271
272fn volume_string(muted: bool, volume: f32) -> String {
273    format!(" {}: {:.1}", if muted { "🔇" } else { "🔊" }, volume * 100.)
274}
275
276#[derive(Debug)]
277struct Areas {
278    song_info: Rect,
279    play_pause: Rect,
280    song_progress: Rect,
281    volume: Rect,
282    instructions: Rect,
283}
284
285fn split_area(area: Rect) -> Areas {
286    let [song_info, playback_info_area, instructions] = *Layout::default()
287        .direction(Direction::Vertical)
288        .constraints([
289            Constraint::Fill(1),
290            Constraint::Fill(1),
291            Constraint::Fill(1),
292        ])
293        .split(area)
294    else {
295        panic!("Failed to split frame into areas");
296    };
297
298    // middle (song progress, volume, and paused/playing indicator)
299    let [play_pause, song_progress, volume] = *Layout::default()
300        .direction(Direction::Horizontal)
301        .constraints([
302            Constraint::Min(20),  // play/pause indicator
303            Constraint::Max(300), // song progress
304            Constraint::Min(20),  // volume indicator
305        ])
306        .split(playback_info_area)
307    else {
308        panic!("Failed to split frame into areas");
309    };
310
311    Areas {
312        song_info,
313        play_pause,
314        song_progress,
315        volume,
316        instructions,
317    }
318}
319
320impl ComponentRender<RenderProps> for ControlPanel {
321    fn render_border(&self, frame: &mut ratatui::Frame, props: RenderProps) -> RenderProps {
322        let border_style = if props.is_focused {
323            Style::default().fg(BORDER_FOCUSED.into())
324        } else {
325            Style::default().fg(BORDER_UNFOCUSED.into())
326        };
327
328        let block = Block::new()
329            .borders(Borders::TOP)
330            .border_style(border_style);
331        let block_area = block.inner(props.area);
332        frame.render_widget(block, props.area);
333
334        RenderProps {
335            area: block_area,
336            ..props
337        }
338    }
339
340    fn render_content(&self, frame: &mut ratatui::Frame, props: RenderProps) {
341        let Areas {
342            song_info,
343            play_pause,
344            song_progress,
345            volume,
346            instructions,
347        } = split_area(props.area);
348
349        // top (song title and artist)
350        if let Some(song_title) = self.props.song_title.clone() {
351            frame.render_widget(
352                Line::from(vec![
353                    Span::styled(
354                        song_title,
355                        Style::default().bold().fg(TEXT_HIGHLIGHT_ALT.into()),
356                    ),
357                    Span::raw("   "),
358                    Span::styled(
359                        self.props.song_artist.clone().unwrap_or_default(),
360                        Style::default().italic().fg(TEXT_NORMAL.into()),
361                    ),
362                ])
363                .centered(),
364                song_info,
365            );
366        } else {
367            frame.render_widget(
368                Line::from("No Song Playing")
369                    .style(Style::default().bold().fg(TEXT_NORMAL.into()))
370                    .alignment(Alignment::Center),
371                song_info,
372            );
373        }
374
375        // middle (song progress, volume, and paused/playing indicator)
376        // play/pause indicator
377        frame.render_widget(
378            Line::from(if self.props.is_playing {
379                "❚❚ "
380            } else {
381                "▶  "
382            })
383            .bold()
384            .alignment(Alignment::Right),
385            play_pause,
386        );
387
388        // song progress
389        frame.render_widget(
390            LineGauge::default()
391                .label(Line::from(runtime_string(self.props.song_runtime)))
392                .filled_style(Style::default().fg(GAUGE_FILLED.into()).bold())
393                .unfilled_style(Style::default().fg(GAUGE_UNFILLED.into()).bold())
394                .ratio(self.props.song_runtime.map_or(0.0, |runtime| {
395                    (runtime.seek_position.as_secs_f64() / runtime.duration.as_secs_f64())
396                        .clamp(0.0, 1.0)
397                })),
398            song_progress,
399        );
400
401        // volume indicator
402        frame.render_widget(
403            // muted icon if muted, otherwise a volume icon.
404            Line::from(volume_string(self.props.muted, self.props.volume))
405                .style(Style::default().bold().fg(TEXT_NORMAL.into()))
406                .alignment(Alignment::Left),
407            volume,
408        );
409
410        // bottom (instructions)
411        frame.render_widget(
412            Line::from(
413                "n/p: next/previous | \u{2423}: play/pause | m: mute | +/-: volume | ←/→: seek",
414            )
415            .italic()
416            .alignment(Alignment::Center),
417            instructions,
418        );
419    }
420}