Krolyxon
Commit 96dd350

rmptuisrcscreensplaylists.rs

348 lines · 12.1 KB · Rust

1//! Screen 3: stored playlists and their contents.
2
3use crossterm::event::MouseEvent;
4use ratatui::{
5 Frame,
6 layout::{Constraint, Layout, Position, Rect},
7 text::{Line, Span},
8 widgets::{Cell, Row},
9};
10
11use super::{Ctx, Screen};
12use crate::{
13 action::Action,
14 mpd::{MpdEvent, Query, Reply, Request, Selection, Song, Subsystem, commands},
15 popups::{AddToPlaylistPopup, ConfirmPopup, InputPopup, InputPurpose},
16 ui::{
17 self,
18 list::{Click, ListView},
19 },
20 util::format_duration,
21};
22
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
24enum Focus {
25 #[default]
26 Playlists,
27 Songs,
28}
29
30#[derive(Debug, Default)]
31pub struct PlaylistsScreen {
32 focus: Focus,
33 playlists: ListView,
34 songs: ListView,
35 /// Contents of `songs_of`.
36 contents: Vec<Song>,
37 songs_of: Option<String>,
38}
39
40impl PlaylistsScreen {
41 fn selected_name<'a>(&self, ctx: &Ctx<'a>) -> Option<&'a str> {
42 let playlists = &ctx.mpd.playlists;
43 self.playlists
44 .selected(playlists.len())
45 .map(|i| playlists[i].name.as_str())
46 }
47
48 /// Requests the contents of the selected playlist if they are not shown.
49 fn sync_contents(&mut self, ctx: &Ctx) {
50 let name = self.selected_name(ctx);
51 if name != self.songs_of.as_deref() {
52 self.songs_of = name.map(str::to_owned);
53 self.contents.clear();
54 self.songs.reset();
55 if let Some(name) = name {
56 ctx.client.query(Query::PlaylistSongs(name.to_owned()));
57 }
58 }
59 }
60
61 fn selected_song(&self) -> Option<(usize, &Song)> {
62 self.songs
63 .selected(self.contents.len())
64 .map(|i| (i, &self.contents[i]))
65 }
66
67 fn move_song(&mut self, down: bool, ctx: &Ctx) {
68 let (Some(name), Some((index, _))) = (self.songs_of.as_deref(), self.selected_song())
69 else {
70 return;
71 };
72 let target = if down {
73 index + 1
74 } else {
75 index.wrapping_sub(1)
76 };
77 if target < self.contents.len() {
78 ctx.client
79 .exec(commands::playlist_move(name, index as u32, target as u32));
80 self.songs.select(target, self.contents.len());
81 }
82 }
83
84 fn playlist_action(&mut self, action: Action, ctx: &mut Ctx) -> bool {
85 let Some(name) = self.selected_name(ctx).map(str::to_owned) else {
86 return false;
87 };
88 match action {
89 Action::DeletePlaylist => ctx.popup(ConfirmPopup::new(
90 format!("Delete the playlist “{name}”?"),
91 vec![commands::remove_playlist(&name)],
92 )),
93 Action::RenamePlaylist => ctx.popup(InputPopup::new(
94 "Rename playlist",
95 &name,
96 InputPurpose::RenamePlaylist(name.clone()),
97 )),
98 _ => return false,
99 }
100 true
101 }
102}
103
104impl Screen for PlaylistsScreen {
105 fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) {
106 let theme = ctx.theme();
107 let [left, right] =
108 Layout::horizontal([Constraint::Percentage(30), Constraint::Percentage(70)])
109 .areas(area);
110
111 let playlists = &ctx.mpd.playlists;
112 let list_focused = self.focus == Focus::Playlists;
113 let block = ui::pane(
114 format!(" Playlists ({}) ", playlists.len()),
115 list_focused,
116 theme,
117 );
118 if playlists.is_empty() {
119 let inner = block.inner(left);
120 frame.render_widget(block, left);
121 ui::draw_placeholder(
122 frame,
123 inner,
124 &["No playlists", "", "Create one with `a` on any song"],
125 theme,
126 );
127 } else {
128 self.playlists.render(
129 frame,
130 left,
131 block,
132 None,
133 &[Constraint::Fill(1)],
134 playlists.len(),
135 list_focused,
136 theme,
137 |i| Row::new([Cell::from(playlists[i].name.as_str())]),
138 );
139 }
140
141 let contents = &self.contents;
142 let total: std::time::Duration = contents.iter().filter_map(|s| s.duration).sum();
143 let title = match &self.songs_of {
144 Some(name) => Line::from(vec![
145 Span::raw(format!(" {name} ")),
146 Span::styled(
147 format!("{} songs · {} ", contents.len(), format_duration(total)),
148 theme.dim(),
149 ),
150 ]),
151 None => Line::from(" Songs "),
152 };
153 let block = ui::pane(title, !list_focused, theme);
154 if contents.is_empty() {
155 let inner = block.inner(right);
156 frame.render_widget(block, right);
157 let text = if self.songs_of.is_some() {
158 "This playlist is empty"
159 } else {
160 ""
161 };
162 ui::draw_placeholder(frame, inner, &[text], theme);
163 return;
164 }
165 self.songs.render(
166 frame,
167 right,
168 block,
169 None,
170 &[
171 Constraint::Fill(3),
172 Constraint::Fill(4),
173 Constraint::Fill(3),
174 Constraint::Length(7),
175 ],
176 contents.len(),
177 !list_focused,
178 theme,
179 |i| {
180 let song = &contents[i];
181 let style = if ctx
182 .mpd
183 .current
184 .as_ref()
185 .is_some_and(|c| c.file == song.file)
186 {
187 theme.playing()
188 } else {
189 theme.text()
190 };
191 Row::new([
192 Cell::from(song.artist().unwrap_or_default().to_owned()),
193 Cell::from(song.display_title().to_owned()),
194 Cell::from(song.album().unwrap_or_default().to_owned()),
195 Cell::from(Span::styled(
196 song.duration.map(format_duration).unwrap_or_default(),
197 theme.dim(),
198 )),
199 ])
200 .style(style)
201 },
202 );
203 }
204
205 fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool {
206 if self.playlist_action(action, ctx) {
207 return true;
208 }
209 match self.focus {
210 Focus::Playlists => {
211 if self.playlists.navigate(action, ctx.mpd.playlists.len()) {
212 self.sync_contents(ctx);
213 return true;
214 }
215 let Some(name) = self.selected_name(ctx).map(str::to_owned) else {
216 return false;
217 };
218 match action {
219 Action::Activate => {
220 ctx.client.request(Request::LoadAndPlay {
221 name: name.clone(),
222 offset: 0,
223 });
224 ctx.notify(format!("Loaded playlist “{name}”"));
225 }
226 Action::AddToQueue => {
227 ctx.client.exec(commands::load(&name));
228 ctx.notify(format!("Added playlist “{name}” to the queue"));
229 }
230 Action::Right if !self.contents.is_empty() => self.focus = Focus::Songs,
231 Action::AddToPlaylist if !self.contents.is_empty() => {
232 let files = self.contents.iter().map(|s| s.file.clone()).collect();
233 let popup = AddToPlaylistPopup::new(Selection::Uris(files), ctx);
234 ctx.popup(popup);
235 }
236 _ => return false,
237 }
238 }
239 Focus::Songs => {
240 if self.songs.navigate(action, self.contents.len()) {
241 return true;
242 }
243 let Some(name) = self.songs_of.clone() else {
244 return false;
245 };
246 match action {
247 Action::Left => self.focus = Focus::Playlists,
248 Action::Activate | Action::Right => {
249 if let Some((index, _)) = self.selected_song() {
250 ctx.client.request(Request::LoadAndPlay {
251 name,
252 offset: index as u32,
253 });
254 }
255 }
256 Action::AddToQueue => {
257 if let Some((_, song)) = self.selected_song() {
258 ctx.client.exec(commands::add(&song.file));
259 ctx.notify(format!("Added “{}” to the queue", song.display_title()));
260 }
261 self.songs.navigate(Action::Down, self.contents.len());
262 }
263 Action::Delete => {
264 if let Some((index, _)) = self.selected_song() {
265 ctx.client
266 .exec(commands::playlist_delete(&name, index as u32));
267 }
268 }
269 Action::MoveItemUp => self.move_song(false, ctx),
270 Action::MoveItemDown => self.move_song(true, ctx),
271 Action::AddToPlaylist => {
272 if let Some((_, song)) = self.selected_song() {
273 let selection = Selection::Uris(vec![song.file.clone()]);
274 let popup = AddToPlaylistPopup::new(selection, ctx);
275 ctx.popup(popup);
276 }
277 }
278 _ => return false,
279 }
280 }
281 }
282 true
283 }
284
285 fn on_mpd(&mut self, event: &MpdEvent, ctx: &mut Ctx) {
286 match event {
287 MpdEvent::Reply(Reply::PlaylistSongs { name, songs })
288 if Some(name) == self.songs_of.as_ref() =>
289 {
290 self.contents.clone_from(songs);
291 if self.contents.is_empty() {
292 self.focus = Focus::Playlists;
293 }
294 }
295 MpdEvent::Reply(Reply::Playlists(_)) => {
296 // Follow the selected playlist when names change (e.g. a rename).
297 self.sync_contents(ctx);
298 }
299 MpdEvent::Changed(subsystems) if subsystems.contains(&Subsystem::StoredPlaylist) => {
300 if let Some(name) = &self.songs_of {
301 ctx.client.query(Query::PlaylistSongs(name.clone()));
302 }
303 }
304 _ => {}
305 }
306 }
307
308 fn on_enter(&mut self, ctx: &mut Ctx) {
309 self.sync_contents(ctx);
310 }
311
312 fn on_mouse(&mut self, event: MouseEvent, ctx: &mut Ctx) {
313 let position = Position::new(event.column, event.row);
314 if self.playlists.contains(position) {
315 self.focus = Focus::Playlists;
316 let click = self.playlists.mouse(event, ctx.mpd.playlists.len());
317 self.sync_contents(ctx);
318 if let Some(Click::Activate(_)) = click {
319 self.handle_action(Action::Activate, ctx);
320 }
321 } else if self.songs.contains(position) {
322 self.focus = Focus::Songs;
323 if let Some(Click::Activate(_)) = self.songs.mouse(event, self.contents.len()) {
324 self.handle_action(Action::Activate, ctx);
325 }
326 }
327 }
328
329 fn search(&mut self, query: &str, ctx: &Ctx) -> bool {
330 match self.focus {
331 Focus::Playlists => {
332 let playlists = &ctx.mpd.playlists;
333 self.playlists.search(
334 query,
335 playlists.iter().map(|p| p.name.as_str()),
336 playlists.len(),
337 );
338 self.sync_contents(ctx);
339 }
340 Focus::Songs => {
341 let texts: Vec<String> = self.contents.iter().map(ui::song_search_text).collect();
342 self.songs
343 .search(query, texts.iter().map(String::as_str), texts.len());
344 }
345 }
346 true
347 }
348}