Krolyxon
Commit 815a27a

rmptuisrcscreensplaylists.rs

296 lines · 10.3 KB · Rust

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