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
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 _ => {}
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 if kind == MouseEventKind::Down(MouseButton::Left) && area.contains(mouse_position) {
162 self.action_tx
163 .send(Action::ActiveComponent(ComponentAction::Set(
164 ActiveComponent::ControlPanel,
165 )))
166 .unwrap();
167 }
168
169 let area = Rect {
171 y: area.y + 1,
172 height: area.height - 1,
173 ..area
174 };
175
176 let Areas {
178 play_pause,
179 song_progress,
180 volume,
181 ..
182 } = split_area(area);
183
184 let runtime_string_len =
186 u16::try_from(runtime_string(self.props.song_runtime).len()).unwrap_or(u16::MAX);
187 let song_progress = Rect {
188 x: song_progress.x + runtime_string_len,
189 width: song_progress.width - runtime_string_len,
190 ..song_progress
191 };
192 let play_pause = Rect {
194 x: play_pause.x + play_pause.width - 3,
195 width: 2,
196 ..play_pause
197 };
198 let volume = Rect {
200 width: u16::try_from(volume_string(self.props.muted, self.props.volume).len())
201 .unwrap_or(u16::MAX),
202 ..volume
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 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 let [play_pause, song_progress, volume] = *Layout::default()
296 .direction(Direction::Horizontal)
297 .constraints([
298 Constraint::Min(20), Constraint::Max(300), Constraint::Min(20), ])
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 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 let play_pause_indicator = if self.props.is_playing {
369 "\u{23f8} " } else {
371 "\u{23f5} " };
373 frame.render_widget(
374 Line::from(play_pause_indicator)
375 .bold()
376 .alignment(Alignment::Right),
377 play_pause,
378 );
379
380 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 frame.render_widget(
395 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 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}