Krolyxon
Commit b139f20

rmptuisrcscreenslibrary.rs

396 lines · 12.4 KB · Rust

1//! Screen 4: the media library — artists, their albums, and album songs.
2
3use crossterm::event::MouseEvent;
4use ratatui::{
5 Frame,
6 layout::{Constraint, Position, Rect},
7 style::{Modifier, Style},
8 text::{Line, Span},
9 widgets::{Cell, Row},
10};
11
12use super::{Ctx, Screen};
13use 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)]
28enum Column {
29 #[default]
30 Artists,
31 Albums,
32 Songs,
33}
34
35#[derive(Debug, Default)]
36pub 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
51impl 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
170fn unknown(value: &str) -> &str {
171 if value.is_empty() { "<unknown>" } else { value }
172}
173
174impl 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}