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