Skip to main content

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::{GAUGE_FILLED, GAUGE_UNFILLED, TEXT_HIGHLIGHT_ALT, TEXT_NORMAL, border_color},
25};
26
27use super::{AppState, Component, ComponentRender, RenderProps};
28
29pub struct ControlPanel {
30    /// Action Sender
31    pub action_tx: UnboundedSender<Action>,
32    /// Mapped Props from state
33    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            // ignore other keys
145            _ => {}
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        // adjust area to exclude the border
164        let area = Rect {
165            y: area.y + 1,
166            height: area.height - 1,
167            ..area
168        };
169
170        // split the area into sub-areas
171        let Areas {
172            play_pause,
173            song_progress,
174            volume,
175            ..
176        } = split_area(area);
177
178        // adjust song_progress area to exclude the runtime label
179        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        // adjust play/pause area to only include the icon
187        let play_pause = Rect {
188            x: play_pause.x + play_pause.width - 3,
189            width: 2,
190            ..play_pause
191        };
192        // adjust volume area to only include the label
193        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                // calculate the ratio of the click position to the song progress bar
207                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), // song info
280            Constraint::Min(1), // playback info
281            Constraint::Min(1), // instructions
282        ])
283        .areas(area);
284
285    // middle (song progress, volume, and paused/playing indicator)
286    let [play_pause, song_progress, volume] = Layout::default()
287        .direction(Direction::Horizontal)
288        .constraints([
289            Constraint::Min(10),        // play/pause indicator
290            Constraint::Percentage(80), // song progress
291            Constraint::Min(20),        // volume indicator
292        ])
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        // top (song title and artist)
330        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        // middle (song progress, volume, and paused/playing indicator)
355        // play/pause indicator
356        let play_pause_indicator = if self.props.is_playing {
357            "\u{23f8} " // pause symbol
358        } else {
359            "\u{23f5} " // play symbol
360        };
361        frame.render_widget(
362            Line::from(play_pause_indicator)
363                .bold()
364                .alignment(Alignment::Right),
365            play_pause,
366        );
367
368        // song progress
369        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        // volume indicator
382        frame.render_widget(
383            // muted icon if muted, otherwise a volume icon.
384            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        // bottom (instructions)
391        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}