Krolyxon
Commit 96dd350

rmptuisrcscreensqueue.rs

284 lines · 9.3 KB · Rust

1//! Screen 1: the play queue.
2
3use std::{collections::HashSet, time::Duration};
4
5use crossterm::event::MouseEvent;
6use ratatui::{
7 Frame,
8 layout::{Constraint, Layout, Rect},
9 style::Modifier,
10 text::{Line, Span},
11 widgets::{Cell, Row},
12};
13
14use super::{Ctx, Screen, draw_lyrics};
15use crate::{
16 action::Action,
17 config::{ColumnField, Config},
18 mpd::{MpdEvent, Reply, Selection, commands},
19 popups::{AddToPlaylistPopup, ConfirmPopup, InputPopup, InputPurpose},
20 ui::{
21 self,
22 list::{Click, ListView},
23 },
24 util::format_duration,
25};
26
27pub struct QueueScreen {
28 list: ListView,
29 /// Marked songs by queue id.
30 marks: HashSet<u32>,
31 lyrics_pane: bool,
32}
33
34impl QueueScreen {
35 pub fn new(config: &Config) -> Self {
36 Self {
37 list: ListView::default(),
38 marks: HashSet::new(),
39 lyrics_pane: config.ui.lyrics_pane,
40 }
41 }
42
43 pub fn lyrics_pane_visible(&self) -> bool {
44 self.lyrics_pane
45 }
46
47 /// Ids of the marked songs in queue order, or of the selected song.
48 fn targets(&self, ctx: &Ctx) -> Vec<u32> {
49 let queue = &ctx.mpd.queue;
50 if self.marks.is_empty() {
51 self.list
52 .selected(queue.len())
53 .and_then(|i| queue[i].id)
54 .into_iter()
55 .collect()
56 } else {
57 queue
58 .iter()
59 .filter_map(|s| s.id.filter(|id| self.marks.contains(id)))
60 .collect()
61 }
62 }
63
64 fn target_files(&self, ctx: &Ctx) -> Vec<String> {
65 let ids: HashSet<u32> = self.targets(ctx).into_iter().collect();
66 ctx.mpd
67 .queue
68 .iter()
69 .filter(|s| s.id.is_some_and(|id| ids.contains(&id)))
70 .map(|s| s.file.clone())
71 .collect()
72 }
73
74 fn play_selected(&self, ctx: &Ctx) {
75 let queue = &ctx.mpd.queue;
76 if let Some(id) = self.list.selected(queue.len()).and_then(|i| queue[i].id) {
77 ctx.client.exec(commands::play_id(id));
78 }
79 }
80
81 fn move_selected(&mut self, down: bool, ctx: &Ctx) {
82 let len = ctx.mpd.queue.len();
83 let Some(index) = self.list.selected(len) else {
84 return;
85 };
86 let target = if down {
87 index + 1
88 } else {
89 index.wrapping_sub(1)
90 };
91 if target >= len {
92 return;
93 }
94 ctx.client
95 .exec(commands::move_pos(index as u32, target as u32));
96 self.list.select(target, len);
97 }
98}
99
100impl Screen for QueueScreen {
101 fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) {
102 let area = if self.lyrics_pane {
103 let width = ctx.config.ui.lyrics_pane_width;
104 let [queue, lyrics] = Layout::horizontal([
105 Constraint::Percentage(100 - width),
106 Constraint::Percentage(width),
107 ])
108 .areas(area);
109 draw_lyrics(frame, lyrics, ctx, false);
110 queue
111 } else {
112 area
113 };
114
115 let theme = ctx.theme();
116 let queue = &ctx.mpd.queue;
117 let columns = &ctx.config.ui.queue_columns;
118 let total: Duration = queue.iter().filter_map(|s| s.duration).sum();
119 let title = Line::from(vec![
120 Span::raw(" Queue "),
121 Span::styled(
122 format!("{} songs · {} ", queue.len(), format_duration(total)),
123 theme.dim(),
124 ),
125 ]);
126 let mut widths = vec![Constraint::Length(1)];
127 widths.extend(columns.iter().map(|c| match c.field {
128 ColumnField::Duration => Constraint::Length(c.width.max(5)),
129 ColumnField::Track | ColumnField::Disc => Constraint::Length(c.width.max(2)),
130 _ => Constraint::Fill(c.width),
131 }));
132 let header = Row::new(
133 std::iter::once(Cell::from(""))
134 .chain(columns.iter().map(|c| Cell::from(c.field.title()))),
135 )
136 .style(theme.dim().add_modifier(Modifier::BOLD));
137
138 if queue.is_empty() {
139 let block = ui::pane(title, true, theme);
140 let inner = block.inner(area);
141 frame.render_widget(block, area);
142 ui::draw_placeholder(
143 frame,
144 inner,
145 &[
146 "The queue is empty.",
147 "",
148 "Add songs from the Browser (2) or the Library (4).",
149 ],
150 theme,
151 );
152 return;
153 }
154
155 let marks = &self.marks;
156 self.list.render(
157 frame,
158 area,
159 ui::pane(title, true, theme),
160 Some(header),
161 &widths,
162 queue.len(),
163 true,
164 theme,
165 |i| {
166 let song = &queue[i];
167 let marked = song.id.is_some_and(|id| marks.contains(&id));
168 let (marker, style) = if ctx.mpd.is_current(song) {
169 ("▶", theme.playing())
170 } else if marked {
171 ("●", theme.marked())
172 } else {
173 (" ", theme.text())
174 };
175 let style = if marked {
176 style.patch(theme.marked())
177 } else {
178 style
179 };
180 Row::new(
181 std::iter::once(Cell::from(Span::styled(marker, style))).chain(
182 columns
183 .iter()
184 .map(|c| Cell::from(ui::song_field(song, c.field))),
185 ),
186 )
187 .style(style)
188 },
189 );
190 }
191
192 fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool {
193 let len = ctx.mpd.queue.len();
194 if self.list.navigate(action, len) {
195 return true;
196 }
197 match action {
198 Action::Activate | Action::Right => self.play_selected(ctx),
199 Action::Delete | Action::AddToQueue if self.marks.is_empty() => {
200 // By position, so that repeated presses delete successive rows
201 // even before the queue has been refreshed.
202 if let Some(index) = self.list.selected(len) {
203 ctx.client.exec(commands::delete_pos(index as u32));
204 }
205 }
206 Action::Delete | Action::AddToQueue => {
207 let ids = self.targets(ctx);
208 ctx.client
209 .exec_all(ids.into_iter().map(commands::delete_id).collect());
210 self.marks.clear();
211 }
212 Action::MoveItemUp => self.move_selected(false, ctx),
213 Action::MoveItemDown => self.move_selected(true, ctx),
214 Action::ClearQueue if len > 0 => {
215 ctx.popup(ConfirmPopup::new(
216 format!("Remove all {len} songs from the queue?"),
217 vec![commands::clear()],
218 ));
219 }
220 Action::ShuffleQueue => ctx.client.exec(commands::shuffle()),
221 Action::SaveQueue if len > 0 => {
222 ctx.popup(InputPopup::new(
223 "Save queue as",
224 "",
225 InputPurpose::SaveQueue,
226 ));
227 }
228 Action::JumpToCurrent => match ctx.mpd.status.song {
229 Some(pos) => self.list.select(pos as usize, len),
230 None => ctx.notify("Nothing is playing"),
231 },
232 Action::ToggleLyricsPane => self.lyrics_pane = !self.lyrics_pane,
233 Action::ToggleMark => {
234 if let Some(id) = self.list.selected(len).and_then(|i| ctx.mpd.queue[i].id) {
235 if !self.marks.remove(&id) {
236 self.marks.insert(id);
237 }
238 self.list.navigate(Action::Down, len);
239 }
240 }
241 Action::ClearMarks => self.marks.clear(),
242 Action::AddToPlaylist => {
243 let files = self.target_files(ctx);
244 if !files.is_empty() {
245 let popup = AddToPlaylistPopup::new(Selection::Uris(files), ctx);
246 ctx.popup(popup);
247 }
248 }
249 _ => return false,
250 }
251 true
252 }
253
254 fn on_mpd(&mut self, event: &MpdEvent, ctx: &mut Ctx) {
255 if let MpdEvent::Reply(Reply::QueueChanges { .. }) = event
256 && !self.marks.is_empty()
257 {
258 let ids: HashSet<u32> = ctx.mpd.queue.iter().filter_map(|s| s.id).collect();
259 self.marks.retain(|id| ids.contains(id));
260 }
261 }
262
263 fn on_enter(&mut self, ctx: &mut Ctx) {
264 // Start on the playing song the first time.
265 if self.list.selected(ctx.mpd.queue.len()) == Some(0)
266 && let Some(pos) = ctx.mpd.status.song
267 {
268 self.list.select(pos as usize, ctx.mpd.queue.len());
269 }
270 }
271
272 fn on_mouse(&mut self, event: MouseEvent, ctx: &mut Ctx) {
273 if let Some(Click::Activate(_)) = self.list.mouse(event, ctx.mpd.queue.len()) {
274 self.play_selected(ctx);
275 }
276 }
277
278 fn search(&mut self, query: &str, ctx: &Ctx) -> bool {
279 let texts: Vec<String> = ctx.mpd.queue.iter().map(ui::song_search_text).collect();
280 self.list
281 .search(query, texts.iter().map(String::as_str), texts.len());
282 true
283 }
284}