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::Fill(1),
280            Constraint::Fill(1),
281            Constraint::Fill(1),
282        ])
283        .split(area)
284    else {
285        panic!("Failed to split frame into areas");
286    };
287
288    // middle (song progress, volume, and paused/playing indicator)
289    let [play_pause, song_progress, volume] = *Layout::default()
290        .direction(Direction::Horizontal)
291        .constraints([
292            Constraint::Min(20),  // play/pause indicator
293            Constraint::Max(300), // song progress
294            Constraint::Min(20),  // volume indicator
295        ])
296        .split(playback_info_area)
297    else {
298        panic!("Failed to split frame into areas");
299    };
300
301    Areas {
302        song_info,
303        play_pause,
304        song_progress,
305        volume,
306        instructions,
307    }
308}
309
310impl ComponentRender<RenderProps> for ControlPanel {
311    fn render_border(&self, frame: &mut ratatui::Frame<'_>, props: RenderProps) -> RenderProps {
312        let border_style = Style::default().fg(border_color(props.is_focused).into());
313
314        let block = Block::new()
315            .borders(Borders::TOP)
316            .border_style(border_style);
317        let block_area = block.inner(props.area);
318        frame.render_widget(block, props.area);
319
320        RenderProps {
321            area: block_area,
322            ..props
323        }
324    }
325
326    fn render_content(&self, frame: &mut ratatui::Frame<'_>, props: RenderProps) {
327        let Areas {
328            song_info,
329            play_pause,
330            song_progress,
331            volume,
332            instructions,
333        } = split_area(props.area);
334
335        // top (song title and artist)
336        let song_info_widget = self.props.song_title.clone().map_or_else(
337            || {
338                Line::from("No Song Playing")
339                    .style(Style::default().bold().fg((*TEXT_NORMAL).into()))
340                    .centered()
341            },
342            |song_title| {
343                Line::from(vec![
344                    Span::styled(
345                        song_title,
346                        Style::default().bold().fg((*TEXT_HIGHLIGHT_ALT).into()),
347                    ),
348                    Span::raw("   "),
349                    Span::styled(
350                        self.props.song_artist.clone().unwrap_or_default(),
351                        Style::default().italic().fg((*TEXT_NORMAL).into()),
352                    ),
353                ])
354                .centered()
355            },
356        );
357
358        frame.render_widget(song_info_widget, song_info);
359
360        // middle (song progress, volume, and paused/playing indicator)
361        // play/pause indicator
362        let play_pause_indicator = if self.props.is_playing {
363            "\u{23f8} " // pause symbol
364        } else {
365            "\u{23f5} " // play symbol
366        };
367        frame.render_widget(
368            Line::from(play_pause_indicator)
369                .bold()
370                .alignment(Alignment::Right),
371            play_pause,
372        );
373
374        // song progress
375        frame.render_widget(
376            LineGauge::default()
377                .label(Line::from(runtime_string(self.props.song_runtime)))
378                .filled_style(Style::default().fg((*GAUGE_FILLED).into()).bold())
379                .unfilled_style(Style::default().fg((*GAUGE_UNFILLED).into()).bold())
380                .ratio(self.props.song_runtime.map_or(0.0, |runtime| {
381                    (runtime.seek_position.as_secs_f64() / runtime.duration.as_secs_f64())
382                        .clamp(0.0, 1.0)
383                })),
384            song_progress,
385        );
386
387        // volume indicator
388        frame.render_widget(
389            // muted icon if muted, otherwise a volume icon.
390            Line::from(volume_string(self.props.muted, self.props.volume))
391                .style(Style::default().bold().fg((*TEXT_NORMAL).into()))
392                .alignment(Alignment::Left),
393            volume,
394        );
395
396        // bottom (instructions)
397        frame.render_widget(
398            Line::from(
399                "n/p: next/previous | \u{2423}: play/pause | m: mute | +/-: volume | ←/→: seek",
400            )
401            .italic()
402            .alignment(Alignment::Center),
403            instructions,
404        );
405    }
406}