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::{
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 pub action_tx: UnboundedSender<Action>,
35 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 _ => {}
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 let area = Rect {
166 y: area.y + 1,
167 height: area.height - 1,
168 ..area
169 };
170
171 let Areas {
173 play_pause,
174 song_progress,
175 volume,
176 ..
177 } = split_area(area);
178
179 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 let play_pause = Rect {
189 x: play_pause.x + play_pause.width - 3,
190 width: 2,
191 ..play_pause
192 };
193 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 #[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 let [play_pause, song_progress, volume] = *Layout::default()
300 .direction(Direction::Horizontal)
301 .constraints([
302 Constraint::Min(20), Constraint::Max(300), Constraint::Min(20), ])
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 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 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 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 frame.render_widget(
403 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 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}