| 1 | //! Screen 3: stored playlists and their contents. |
| 2 | |
| 3 | use crossterm::event::MouseEvent; |
| 4 | use ratatui::{ |
| 5 | Frame, |
| 6 | layout::{Constraint, Layout, Position, Rect}, |
| 7 | text::{Line, Span}, |
| 8 | widgets::{Cell, Row}, |
| 9 | }; |
| 10 | |
| 11 | use super::{Ctx, Screen}; |
| 12 | use 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)] |
| 24 | enum Focus { |
| 25 | #[default] |
| 26 | Playlists, |
| 27 | Songs, |
| 28 | } |
| 29 | |
| 30 | #[derive(Debug, Default)] |
| 31 | pub 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 | |
| 40 | impl 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 | |
| 104 | impl 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 | } |