| 1 | //! Screen 4: the media library — artists, their albums, and album songs. |
| 2 | |
| 3 | use crossterm::event::MouseEvent; |
| 4 | use ratatui::{ |
| 5 | Frame, |
| 6 | layout::{Constraint, Position, Rect}, |
| 7 | style::{Modifier, Style}, |
| 8 | text::{Line, Span}, |
| 9 | widgets::{Cell, Row}, |
| 10 | }; |
| 11 | |
| 12 | use super::{Ctx, Screen}; |
| 13 | use crate::{ |
| 14 | action::Action, |
| 15 | mpd::{ |
| 16 | Album, MpdEvent, Query, Reply, Request, Selection, Song, Subsystem, commands, |
| 17 | protocol::{filter_and, filter_eq}, |
| 18 | }, |
| 19 | popups::AddToPlaylistPopup, |
| 20 | ui::{ |
| 21 | self, |
| 22 | list::{Click, Header, ListView}, |
| 23 | }, |
| 24 | util::format_duration, |
| 25 | }; |
| 26 | |
| 27 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] |
| 28 | enum Column { |
| 29 | #[default] |
| 30 | Artists, |
| 31 | Albums, |
| 32 | Songs, |
| 33 | } |
| 34 | |
| 35 | #[derive(Debug, Default)] |
| 36 | pub struct LibraryScreen { |
| 37 | /// Group by `Artist` instead of `AlbumArtist`. |
| 38 | use_artist: bool, |
| 39 | focus: Column, |
| 40 | artists: Vec<String>, |
| 41 | artists_loaded: bool, |
| 42 | artist_list: ListView, |
| 43 | albums: Vec<Album>, |
| 44 | albums_of: Option<String>, |
| 45 | album_list: ListView, |
| 46 | songs: Vec<Song>, |
| 47 | songs_filter: Option<String>, |
| 48 | song_list: ListView, |
| 49 | } |
| 50 | |
| 51 | impl LibraryScreen { |
| 52 | fn tag(&self) -> &'static str { |
| 53 | if self.use_artist { |
| 54 | "Artist" |
| 55 | } else { |
| 56 | "AlbumArtist" |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | fn reload(&mut self, ctx: &Ctx) { |
| 61 | self.artists_loaded = false; |
| 62 | self.albums_of = None; |
| 63 | self.songs_filter = None; |
| 64 | ctx.client.query(Query::TagValues(self.tag().to_owned())); |
| 65 | } |
| 66 | |
| 67 | fn selected_artist(&self) -> Option<&str> { |
| 68 | self.artist_list |
| 69 | .selected(self.artists.len()) |
| 70 | .map(|i| self.artists[i].as_str()) |
| 71 | } |
| 72 | |
| 73 | fn selected_album(&self) -> Option<&Album> { |
| 74 | self.album_list |
| 75 | .selected(self.albums.len()) |
| 76 | .map(|i| &self.albums[i]) |
| 77 | } |
| 78 | |
| 79 | fn artist_filter(&self) -> Option<String> { |
| 80 | self.selected_artist() |
| 81 | .map(|artist| filter_eq(self.tag(), artist)) |
| 82 | } |
| 83 | |
| 84 | fn album_filter(&self) -> Option<String> { |
| 85 | let artist = self.artist_filter()?; |
| 86 | let album = self.selected_album()?; |
| 87 | let mut parts = vec![artist, filter_eq("Album", &album.name)]; |
| 88 | if !album.date.is_empty() { |
| 89 | parts.push(filter_eq("Date", &album.date)); |
| 90 | } |
| 91 | Some(filter_and(parts)) |
| 92 | } |
| 93 | |
| 94 | /// Requests albums and songs for the current selection when it changed. |
| 95 | fn sync(&mut self, ctx: &Ctx) { |
| 96 | let artist = self.selected_artist().map(str::to_owned); |
| 97 | if artist != self.albums_of { |
| 98 | self.albums_of.clone_from(&artist); |
| 99 | self.albums.clear(); |
| 100 | self.album_list.reset(); |
| 101 | self.songs.clear(); |
| 102 | self.songs_filter = None; |
| 103 | if let Some(artist) = artist { |
| 104 | ctx.client.query(Query::Albums { |
| 105 | tag: self.tag().to_owned(), |
| 106 | value: artist, |
| 107 | }); |
| 108 | } |
| 109 | return; |
| 110 | } |
| 111 | let filter = self.album_filter(); |
| 112 | if filter != self.songs_filter { |
| 113 | self.songs_filter.clone_from(&filter); |
| 114 | self.songs.clear(); |
| 115 | self.song_list.reset(); |
| 116 | if let Some(filter) = filter { |
| 117 | ctx.client.query(Query::Find(filter)); |
| 118 | } |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | fn focused_list(&mut self) -> (&mut ListView, usize) { |
| 123 | match self.focus { |
| 124 | Column::Artists => (&mut self.artist_list, self.artists.len()), |
| 125 | Column::Albums => (&mut self.album_list, self.albums.len()), |
| 126 | Column::Songs => (&mut self.song_list, self.songs.len()), |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | fn selected_song(&self) -> Option<&Song> { |
| 131 | self.song_list |
| 132 | .selected(self.songs.len()) |
| 133 | .map(|i| &self.songs[i]) |
| 134 | } |
| 135 | |
| 136 | /// The songs the focused column refers to. |
| 137 | fn selection(&self) -> Option<Selection> { |
| 138 | match self.focus { |
| 139 | Column::Artists => self.artist_filter().map(Selection::Filter), |
| 140 | Column::Albums => self.album_filter().map(Selection::Filter), |
| 141 | Column::Songs => self |
| 142 | .selected_song() |
| 143 | .map(|s| Selection::Uris(vec![s.file.clone()])), |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | fn activate(&mut self, ctx: &mut Ctx) { |
| 148 | match self.focus { |
| 149 | Column::Artists if !self.albums.is_empty() => self.focus = Column::Albums, |
| 150 | Column::Artists => {} |
| 151 | Column::Albums => { |
| 152 | if let Some(filter) = self.album_filter() { |
| 153 | ctx.client.request(Request::FindAddAndPlay(filter)); |
| 154 | } |
| 155 | } |
| 156 | Column::Songs => { |
| 157 | if let Some(song) = self.selected_song() { |
| 158 | match ctx.mpd.queued_id(&song.file) { |
| 159 | Some(id) => ctx.client.exec(commands::play_id(id)), |
| 160 | None => ctx |
| 161 | .client |
| 162 | .request(Request::AddAndPlay(vec![song.file.clone()])), |
| 163 | } |
| 164 | } |
| 165 | } |
| 166 | } |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | fn unknown(value: &str) -> &str { |
| 171 | if value.is_empty() { "<unknown>" } else { value } |
| 172 | } |
| 173 | |
| 174 | impl Screen for LibraryScreen { |
| 175 | fn title(&self, _ctx: &Ctx) -> Line<'static> { |
| 176 | Line::from("Media library") |
| 177 | } |
| 178 | |
| 179 | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { |
| 180 | let theme = ctx.theme(); |
| 181 | let [artists_area, albums_area, songs_area] = <[Rect; 3]>::try_from(ui::split_columns( |
| 182 | frame, |
| 183 | area, |
| 184 | &[ |
| 185 | Constraint::Percentage(28), |
| 186 | Constraint::Percentage(32), |
| 187 | Constraint::Percentage(40), |
| 188 | ], |
| 189 | theme, |
| 190 | )) |
| 191 | .expect("three columns"); |
| 192 | |
| 193 | let artists = &self.artists; |
| 194 | let title = if self.use_artist { |
| 195 | "Artists" |
| 196 | } else { |
| 197 | "Album artists" |
| 198 | }; |
| 199 | self.artist_list.render( |
| 200 | frame, |
| 201 | artists_area, |
| 202 | Some(Header::Title(Line::from(title))), |
| 203 | &[Constraint::Fill(1)], |
| 204 | artists.len(), |
| 205 | self.focus == Column::Artists, |
| 206 | theme, |
| 207 | |i| Row::new([Cell::from(ui::clean(unknown(&artists[i])))]), |
| 208 | ); |
| 209 | |
| 210 | let albums = &self.albums; |
| 211 | self.album_list.render( |
| 212 | frame, |
| 213 | albums_area, |
| 214 | Some(Header::Title(Line::from("Albums"))), |
| 215 | &[Constraint::Fill(1)], |
| 216 | albums.len(), |
| 217 | self.focus == Column::Albums, |
| 218 | theme, |
| 219 | |i| { |
| 220 | let album = &albums[i]; |
| 221 | let date: String = album.date.chars().take(4).collect(); |
| 222 | let mut spans = Vec::new(); |
| 223 | if !date.is_empty() { |
| 224 | spans.push(Span::styled(format!("({date}) "), theme.dim())); |
| 225 | } |
| 226 | spans.push(Span::raw(ui::clean(unknown(&album.name)))); |
| 227 | Row::new([Cell::from(Line::from(spans))]) |
| 228 | }, |
| 229 | ); |
| 230 | |
| 231 | let songs = &self.songs; |
| 232 | self.song_list.render( |
| 233 | frame, |
| 234 | songs_area, |
| 235 | Some(Header::Title(Line::from("Songs"))), |
| 236 | &[ |
| 237 | Constraint::Length(2), |
| 238 | Constraint::Fill(1), |
| 239 | Constraint::Length(5), |
| 240 | ], |
| 241 | songs.len(), |
| 242 | self.focus == Column::Songs, |
| 243 | theme, |
| 244 | |i| { |
| 245 | let song = &songs[i]; |
| 246 | let row = Row::new([ |
| 247 | Cell::from(Span::styled( |
| 248 | song.track().map(|t| format!("{t:0>2}")).unwrap_or_default(), |
| 249 | Style::new().fg(theme.track), |
| 250 | )), |
| 251 | Cell::from(ui::clean(song.display_title())), |
| 252 | Cell::from( |
| 253 | Line::from(song.duration.map(format_duration).unwrap_or_default()) |
| 254 | .right_aligned(), |
| 255 | ), |
| 256 | ]); |
| 257 | if ctx.mpd.is_queued(&song.file) { |
| 258 | row.style(Style::new().add_modifier(Modifier::BOLD)) |
| 259 | } else { |
| 260 | row |
| 261 | } |
| 262 | }, |
| 263 | ); |
| 264 | } |
| 265 | |
| 266 | fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool { |
| 267 | let (list, len) = self.focused_list(); |
| 268 | if list.navigate(action, len) { |
| 269 | self.sync(ctx); |
| 270 | return true; |
| 271 | } |
| 272 | match action { |
| 273 | Action::Left => { |
| 274 | self.focus = match self.focus { |
| 275 | Column::Songs => Column::Albums, |
| 276 | _ => Column::Artists, |
| 277 | } |
| 278 | } |
| 279 | Action::Right => match self.focus { |
| 280 | Column::Artists if !self.albums.is_empty() => self.focus = Column::Albums, |
| 281 | Column::Albums if !self.songs.is_empty() => self.focus = Column::Songs, |
| 282 | _ => {} |
| 283 | }, |
| 284 | Action::Activate => self.activate(ctx), |
| 285 | Action::AddToQueue => { |
| 286 | if let Some(selection) = self.selection() { |
| 287 | ctx.client.exec_all(selection.add_to_queue()); |
| 288 | ctx.notify("Added to the queue"); |
| 289 | if self.focus == Column::Songs { |
| 290 | self.song_list.navigate(Action::Down, self.songs.len()); |
| 291 | } |
| 292 | } |
| 293 | } |
| 294 | Action::AddToPlaylist => { |
| 295 | if let Some(selection) = self.selection() { |
| 296 | let popup = AddToPlaylistPopup::new(selection, ctx); |
| 297 | ctx.popup(popup); |
| 298 | } |
| 299 | } |
| 300 | Action::ToggleArtistTag => { |
| 301 | self.use_artist = !self.use_artist; |
| 302 | self.focus = Column::Artists; |
| 303 | self.artists.clear(); |
| 304 | self.artist_list.reset(); |
| 305 | self.reload(ctx); |
| 306 | } |
| 307 | _ => return false, |
| 308 | } |
| 309 | true |
| 310 | } |
| 311 | |
| 312 | fn on_mpd(&mut self, event: &MpdEvent, ctx: &mut Ctx) { |
| 313 | match event { |
| 314 | MpdEvent::Reply(Reply::TagValues { tag, values }) if tag == self.tag() => { |
| 315 | self.artists.clone_from(values); |
| 316 | self.artists_loaded = true; |
| 317 | self.sync(ctx); |
| 318 | } |
| 319 | MpdEvent::Reply(Reply::Albums { tag, value, albums }) |
| 320 | if tag == self.tag() && Some(value) == self.albums_of.as_ref() => |
| 321 | { |
| 322 | self.albums.clone_from(albums); |
| 323 | self.sync(ctx); |
| 324 | } |
| 325 | MpdEvent::Reply(Reply::Find { filter, songs }) |
| 326 | if Some(filter) == self.songs_filter.as_ref() => |
| 327 | { |
| 328 | self.songs.clone_from(songs); |
| 329 | } |
| 330 | MpdEvent::Changed(subsystems) |
| 331 | if subsystems.contains(&Subsystem::Database) && self.artists_loaded => |
| 332 | { |
| 333 | self.reload(ctx); |
| 334 | } |
| 335 | _ => {} |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | fn on_enter(&mut self, ctx: &mut Ctx) { |
| 340 | if !self.artists_loaded { |
| 341 | self.reload(ctx); |
| 342 | } |
| 343 | } |
| 344 | |
| 345 | fn on_mouse(&mut self, event: MouseEvent, ctx: &mut Ctx) { |
| 346 | let position = Position::new(event.column, event.row); |
| 347 | let column = [Column::Artists, Column::Albums, Column::Songs] |
| 348 | .into_iter() |
| 349 | .find(|&c| { |
| 350 | let list = match c { |
| 351 | Column::Artists => &self.artist_list, |
| 352 | Column::Albums => &self.album_list, |
| 353 | Column::Songs => &self.song_list, |
| 354 | }; |
| 355 | list.contains(position) |
| 356 | }); |
| 357 | let Some(column) = column else { |
| 358 | return; |
| 359 | }; |
| 360 | self.focus = column; |
| 361 | let (list, len) = self.focused_list(); |
| 362 | let click = list.mouse(event, len); |
| 363 | self.sync(ctx); |
| 364 | if let Some(Click::Activate(_)) = click { |
| 365 | self.activate(ctx); |
| 366 | } |
| 367 | } |
| 368 | |
| 369 | fn search(&mut self, query: &str, ctx: &Ctx) -> bool { |
| 370 | match self.focus { |
| 371 | Column::Artists => { |
| 372 | self.artist_list.search( |
| 373 | query, |
| 374 | self.artists.iter().map(String::as_str), |
| 375 | self.artists.len(), |
| 376 | ); |
| 377 | } |
| 378 | Column::Albums => { |
| 379 | self.album_list.search( |
| 380 | query, |
| 381 | self.albums.iter().map(|a| a.name.as_str()), |
| 382 | self.albums.len(), |
| 383 | ); |
| 384 | } |
| 385 | Column::Songs => { |
| 386 | self.song_list.search( |
| 387 | query, |
| 388 | self.songs.iter().map(Song::display_title), |
| 389 | self.songs.len(), |
| 390 | ); |
| 391 | } |
| 392 | } |
| 393 | self.sync(ctx); |
| 394 | true |
| 395 | } |
| 396 | } |