mecomp_tui/ui/components/
queuebar.rsuse crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use mecomp_core::state::RepeatMode;
use mecomp_storage::db::schemas::song::Song;
use ratatui::{
layout::{Constraint, Direction, Layout, Position, Rect},
style::{Modifier, Style},
text::{Line, Text},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
};
use tokio::sync::mpsc::UnboundedSender;
use crate::{
state::{
action::{Action, AudioAction, ComponentAction, QueueAction},
component::ActiveComponent,
},
ui::colors::{
BORDER_FOCUSED, BORDER_UNFOCUSED, TEXT_HIGHLIGHT, TEXT_HIGHLIGHT_ALT, TEXT_NORMAL,
},
};
use super::{AppState, Component, ComponentRender, RenderProps};
pub struct QueueBar {
pub action_tx: UnboundedSender<Action>,
pub(crate) props: Props,
list_state: ListState,
}
pub struct Props {
pub(crate) queue: Box<[Song]>,
pub(crate) current_position: Option<usize>,
pub(crate) repeat_mode: RepeatMode,
}
impl From<&AppState> for Props {
fn from(value: &AppState) -> Self {
Self {
current_position: value.audio.queue_position,
repeat_mode: value.audio.repeat_mode,
queue: value.audio.queue.clone(),
}
}
}
impl Component for QueueBar {
fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
where
Self: Sized,
{
let props = Props::from(state);
Self {
action_tx,
list_state: ListState::default().with_selected(props.current_position),
props,
}
}
fn move_with_state(self, state: &AppState) -> Self
where
Self: Sized,
{
let old_current_index = self.props.current_position;
let new_current_index = state.audio.queue_position;
let list_state = if old_current_index == new_current_index {
self.list_state
} else {
self.list_state.with_selected(new_current_index)
};
Self {
list_state,
props: Props::from(state),
..self
}
}
fn name(&self) -> &str {
"Queue"
}
fn handle_key_event(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Up => {
if let Some(index) = self.list_state.selected() {
let new_index = if index == 0 {
self.props.queue.len() - 1
} else {
index - 1
};
self.list_state.select(Some(new_index));
}
}
KeyCode::Down => {
if let Some(index) = self.list_state.selected() {
let new_index = if index == self.props.queue.len() - 1 {
0
} else {
index + 1
};
self.list_state.select(Some(new_index));
}
}
KeyCode::Enter => {
if let Some(index) = self.list_state.selected() {
self.action_tx
.send(Action::Audio(AudioAction::Queue(QueueAction::SetPosition(
index,
))))
.unwrap();
}
}
KeyCode::Char('c') => {
self.action_tx
.send(Action::Audio(AudioAction::Queue(QueueAction::Clear)))
.unwrap();
}
KeyCode::Char('d') => {
if let Some(index) = self.list_state.selected() {
self.action_tx
.send(Action::Audio(AudioAction::Queue(QueueAction::Remove(
index,
))))
.unwrap();
}
}
KeyCode::Char('s') => {
self.action_tx
.send(Action::Audio(AudioAction::Queue(QueueAction::Shuffle)))
.unwrap();
}
KeyCode::Char('r') => match self.props.repeat_mode {
RepeatMode::None => {
self.action_tx
.send(Action::Audio(AudioAction::Queue(
QueueAction::SetRepeatMode(RepeatMode::Once),
)))
.unwrap();
}
RepeatMode::Once => {
self.action_tx
.send(Action::Audio(AudioAction::Queue(
QueueAction::SetRepeatMode(RepeatMode::Continuous),
)))
.unwrap();
}
RepeatMode::Continuous => {
self.action_tx
.send(Action::Audio(AudioAction::Queue(
QueueAction::SetRepeatMode(RepeatMode::None),
)))
.unwrap();
}
},
_ => {}
}
}
fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
let MouseEvent {
kind, column, row, ..
} = mouse;
let mouse_position = Position::new(column, row);
match kind {
MouseEventKind::Down(MouseButton::Left) if area.contains(mouse_position) => {
self.action_tx
.send(Action::ActiveComponent(ComponentAction::Set(
ActiveComponent::QueueBar,
)))
.unwrap();
}
MouseEventKind::ScrollDown => self.handle_key_event(KeyEvent::from(KeyCode::Down)),
MouseEventKind::ScrollUp => self.handle_key_event(KeyEvent::from(KeyCode::Up)),
_ => {}
}
}
}
impl ComponentRender<RenderProps> for QueueBar {
fn render_border(&self, frame: &mut ratatui::Frame, props: RenderProps) -> RenderProps {
let border_style = if props.is_focused {
Style::default().fg(BORDER_FOCUSED.into())
} else {
Style::default().fg(BORDER_UNFOCUSED.into())
};
let border = Block::bordered().title("Queue").border_style(border_style);
frame.render_widget(&border, props.area);
let area = border.inner(props.area);
let [info_area, content_area, instructions_area] = *Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(2),
Constraint::Min(0),
Constraint::Length(3),
]
.as_ref(),
)
.split(area)
else {
panic!("Failed to split queue bar area");
};
let border = Block::default()
.borders(Borders::TOP | Borders::BOTTOM)
.title(format!("Songs ({})", self.props.queue.len()))
.border_style(border_style);
frame.render_widget(&border, content_area);
let content_area = border.inner(content_area);
let queue_info = format!(
"repeat: {}",
match self.props.repeat_mode {
RepeatMode::None => "none",
RepeatMode::Once => "once",
RepeatMode::Continuous => "continuous",
}
);
frame.render_widget(
Paragraph::new(queue_info)
.style(Style::default().fg(TEXT_NORMAL.into()))
.alignment(ratatui::layout::Alignment::Center),
info_area,
);
frame.render_widget(
Paragraph::new(Text::from(vec![
Line::from("↑/↓: Move | c: Clear"),
Line::from("\u{23CE} : Select | d: Delete"),
Line::from("s: Shuffle | r: Repeat"),
]))
.style(Style::default().fg(TEXT_NORMAL.into()))
.alignment(ratatui::layout::Alignment::Center),
instructions_area,
);
RenderProps {
area: content_area,
is_focused: props.is_focused,
}
}
fn render_content(&self, frame: &mut ratatui::Frame, props: RenderProps) {
let items = self
.props
.queue
.iter()
.enumerate()
.map(|(index, song)| {
let style = if Some(index) == self.props.current_position {
Style::default().fg(TEXT_HIGHLIGHT_ALT.into())
} else {
Style::default().fg(TEXT_NORMAL.into())
};
ListItem::new(song.title.as_ref()).style(style)
})
.collect::<Vec<_>>();
frame.render_stateful_widget(
List::new(items)
.highlight_style(
Style::default()
.fg(TEXT_HIGHLIGHT.into())
.add_modifier(Modifier::BOLD),
)
.scroll_padding(1)
.direction(ratatui::widgets::ListDirection::TopToBottom),
props.area,
&mut self.list_state.clone(),
);
}
}