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