Krolyxon
Commit cf9c69a

rmptuisrcscreenslibrary.rs

413 lines · 13.1 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, Layout, Position, Rect},
7 style::Modifier,
8 text::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, 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 draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) {
176 let theme = ctx.theme();
177 let [artists_area, albums_area, songs_area] = Layout::horizontal([
178 Constraint::Percentage(28),
179 Constraint::Percentage(32),
180 Constraint::Percentage(40),
181 ])
182 .areas(area);
183
184 let artists = &self.artists;
185 let title = if self.use_artist {
186 " Artists "
187 } else {
188 " Album Artists "
189 };
190 let block = ui::pane(title, self.focus == Column::Artists, theme);
191 if artists.is_empty() {
192 let inner = block.inner(artists_area);
193 frame.render_widget(block, artists_area);
194 let text = if self.artists_loaded {
195 "No artists"
196 } else {
197 "Loading…"
198 };
199 ui::draw_placeholder(frame, inner, &[text], theme);
200 } else {
201 self.artist_list.render(
202 frame,
203 artists_area,
204 block,
205 None,
206 &[Constraint::Fill(1)],
207 artists.len(),
208 self.focus == Column::Artists,
209 theme,
210 |i| Row::new([Cell::from(unknown(&artists[i]).to_owned())]),
211 );
212 }
213
214 let albums = &self.albums;
215 let block = ui::pane(" Albums ", self.focus == Column::Albums, theme);
216 if albums.is_empty() {
217 frame.render_widget(block, albums_area);
218 } else {
219 self.album_list.render(
220 frame,
221 albums_area,
222 block,
223 None,
224 &[Constraint::Length(6), Constraint::Fill(1)],
225 albums.len(),
226 self.focus == Column::Albums,
227 theme,
228 |i| {
229 let album = &albums[i];
230 Row::new([
231 Cell::from(Span::styled(
232 album.date.chars().take(4).collect::<String>(),
233 theme.dim(),
234 )),
235 Cell::from(unknown(&album.name).to_owned()),
236 ])
237 },
238 );
239 }
240
241 let songs = &self.songs;
242 let block = ui::pane(" Songs ", self.focus == Column::Songs, theme);
243 if songs.is_empty() {
244 frame.render_widget(block, songs_area);
245 } else {
246 self.song_list.render(
247 frame,
248 songs_area,
249 block,
250 None,
251 &[
252 Constraint::Length(3),
253 Constraint::Fill(1),
254 Constraint::Length(7),
255 ],
256 songs.len(),
257 self.focus == Column::Songs,
258 theme,
259 |i| {
260 let song = &songs[i];
261 let style = if ctx.mpd.is_queued(&song.file) {
262 theme.text().add_modifier(Modifier::BOLD)
263 } else {
264 theme.text()
265 };
266 Row::new([
267 Cell::from(Span::styled(
268 song.track().unwrap_or_default().to_owned(),
269 theme.dim(),
270 )),
271 Cell::from(song.display_title().to_owned()),
272 Cell::from(Span::styled(
273 song.duration.map(format_duration).unwrap_or_default(),
274 theme.dim(),
275 )),
276 ])
277 .style(style)
278 },
279 );
280 }
281 }
282
283 fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool {
284 let (list, len) = self.focused_list();
285 if list.navigate(action, len) {
286 self.sync(ctx);
287 return true;
288 }
289 match action {
290 Action::Left => {
291 self.focus = match self.focus {
292 Column::Songs => Column::Albums,
293 _ => Column::Artists,
294 }
295 }
296 Action::Right => match self.focus {
297 Column::Artists if !self.albums.is_empty() => self.focus = Column::Albums,
298 Column::Albums if !self.songs.is_empty() => self.focus = Column::Songs,
299 _ => {}
300 },
301 Action::Activate => self.activate(ctx),
302 Action::AddToQueue => {
303 if let Some(selection) = self.selection() {
304 ctx.client.exec_all(selection.add_to_queue());
305 ctx.notify("Added to the queue");
306 if self.focus == Column::Songs {
307 self.song_list.navigate(Action::Down, self.songs.len());
308 }
309 }
310 }
311 Action::AddToPlaylist => {
312 if let Some(selection) = self.selection() {
313 let popup = AddToPlaylistPopup::new(selection, ctx);
314 ctx.popup(popup);
315 }
316 }
317 Action::ToggleArtistTag => {
318 self.use_artist = !self.use_artist;
319 self.focus = Column::Artists;
320 self.artists.clear();
321 self.artist_list.reset();
322 self.reload(ctx);
323 }
324 _ => return false,
325 }
326 true
327 }
328
329 fn on_mpd(&mut self, event: &MpdEvent, ctx: &mut Ctx) {
330 match event {
331 MpdEvent::Reply(Reply::TagValues { tag, values }) if tag == self.tag() => {
332 self.artists.clone_from(values);
333 self.artists_loaded = true;
334 self.sync(ctx);
335 }
336 MpdEvent::Reply(Reply::Albums { tag, value, albums })
337 if tag == self.tag() && Some(value) == self.albums_of.as_ref() =>
338 {
339 self.albums.clone_from(albums);
340 self.sync(ctx);
341 }
342 MpdEvent::Reply(Reply::Find { filter, songs })
343 if Some(filter) == self.songs_filter.as_ref() =>
344 {
345 self.songs.clone_from(songs);
346 }
347 MpdEvent::Changed(subsystems)
348 if subsystems.contains(&Subsystem::Database) && self.artists_loaded =>
349 {
350 self.reload(ctx);
351 }
352 _ => {}
353 }
354 }
355
356 fn on_enter(&mut self, ctx: &mut Ctx) {
357 if !self.artists_loaded {
358 self.reload(ctx);
359 }
360 }
361
362 fn on_mouse(&mut self, event: MouseEvent, ctx: &mut Ctx) {
363 let position = Position::new(event.column, event.row);
364 let column = [Column::Artists, Column::Albums, Column::Songs]
365 .into_iter()
366 .find(|&c| {
367 let list = match c {
368 Column::Artists => &self.artist_list,
369 Column::Albums => &self.album_list,
370 Column::Songs => &self.song_list,
371 };
372 list.contains(position)
373 });
374 let Some(column) = column else {
375 return;
376 };
377 self.focus = column;
378 let (list, len) = self.focused_list();
379 let click = list.mouse(event, len);
380 self.sync(ctx);
381 if let Some(Click::Activate(_)) = click {
382 self.activate(ctx);
383 }
384 }
385
386 fn search(&mut self, query: &str, ctx: &Ctx) -> bool {
387 match self.focus {
388 Column::Artists => {
389 self.artist_list.search(
390 query,
391 self.artists.iter().map(String::as_str),
392 self.artists.len(),
393 );
394 }
395 Column::Albums => {
396 self.album_list.search(
397 query,
398 self.albums.iter().map(|a| a.name.as_str()),
399 self.albums.len(),
400 );
401 }
402 Column::Songs => {
403 self.song_list.search(
404 query,
405 self.songs.iter().map(Song::display_title),
406 self.songs.len(),
407 );
408 }
409 }
410 self.sync(ctx);
411 true
412 }
413}