1 /***************************************************************************
2  *   Copyright (C) 2008-2021 by Andrzej Rybczak                            *
3  *   andrzej@rybczak.net                                                   *
4  *                                                                         *
5  *   This program is free software; you can redistribute it and/or modify  *
6  *   it under the terms of the GNU General Public License as published by  *
7  *   the Free Software Foundation; either version 2 of the License, or     *
8  *   (at your option) any later version.                                   *
9  *                                                                         *
10  *   This program is distributed in the hope that it will be useful,       *
11  *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
12  *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
13  *   GNU General Public License for more details.                          *
14  *                                                                         *
15  *   You should have received a copy of the GNU General Public License     *
16  *   along with this program; if not, write to the                         *
17  *   Free Software Foundation, Inc.,                                       *
18  *   51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA.              *
19  ***************************************************************************/
20 
21 #include <algorithm>
22 #include <boost/optional.hpp>
23 #include <boost/date_time/posix_time/posix_time.hpp>
24 #include <cassert>
25 
26 #include "curses/menu_impl.h"
27 #include "charset.h"
28 #include "display.h"
29 #include "global.h"
30 #include "helpers.h"
31 #include "screens/playlist.h"
32 #include "screens/playlist_editor.h"
33 #include "mpdpp.h"
34 #include "status.h"
35 #include "statusbar.h"
36 #include "screens/tag_editor.h"
37 #include "format_impl.h"
38 #include "helpers/song_iterator_maker.h"
39 #include "utility/functional.h"
40 #include "utility/comparators.h"
41 #include "title.h"
42 #include "screens/screen_switcher.h"
43 
44 using Global::MainHeight;
45 using Global::MainStartY;
46 
47 namespace ph = std::placeholders;
48 
49 PlaylistEditor *myPlaylistEditor;
50 
51 namespace {
52 
53 size_t LeftColumnStartX;
54 size_t LeftColumnWidth;
55 size_t RightColumnStartX;
56 size_t RightColumnWidth;
57 
58 std::string SongToString(const MPD::Song &s);
59 bool PlaylistEntryMatcher(const Regex::Regex &rx, const MPD::Playlist &playlist);
60 bool SongEntryMatcher(const Regex::Regex &rx, const MPD::Song &s);
61 boost::optional<size_t> GetSongIndexInPlaylist(MPD::Playlist playlist, const MPD::Song &song);
62 }
63 
PlaylistEditor()64 PlaylistEditor::PlaylistEditor()
65 : m_timer(boost::posix_time::from_time_t(0))
66 , m_window_timeout(Config.data_fetching_delay ? 250 : BaseScreen::defaultWindowTimeout)
67 , m_fetching_delay(boost::posix_time::milliseconds(Config.data_fetching_delay ? 250 : -1))
68 {
69 	LeftColumnWidth = COLS/3-1;
70 	RightColumnStartX = LeftColumnWidth+1;
71 	RightColumnWidth = COLS-LeftColumnWidth-1;
72 
73 	Playlists = NC::Menu<MPD::Playlist>(0, MainStartY, LeftColumnWidth, MainHeight, Config.titles_visibility ? "Playlists" : "", Config.main_color, NC::Border());
74 	setHighlightFixes(Playlists);
75 	Playlists.cyclicScrolling(Config.use_cyclic_scrolling);
76 	Playlists.centeredCursor(Config.centered_cursor);
77 	Playlists.setSelectedPrefix(Config.selected_item_prefix);
78 	Playlists.setSelectedSuffix(Config.selected_item_suffix);
79 	Playlists.setItemDisplayer([](NC::Menu<MPD::Playlist> &menu) {
80 		menu << Charset::utf8ToLocale(menu.drawn()->value().path());
81 	});
82 
83 	Content = NC::Menu<MPD::Song>(RightColumnStartX, MainStartY, RightColumnWidth, MainHeight, Config.titles_visibility ? "Content" : "", Config.main_color, NC::Border());
84 	setHighlightInactiveColumnFixes(Content);
85 	Content.cyclicScrolling(Config.use_cyclic_scrolling);
86 	Content.centeredCursor(Config.centered_cursor);
87 	Content.setSelectedPrefix(Config.selected_item_prefix);
88 	Content.setSelectedSuffix(Config.selected_item_suffix);
89 	switch (Config.playlist_editor_display_mode)
90 	{
91 		case DisplayMode::Classic:
92 			Content.setItemDisplayer(std::bind(
93 				Display::Songs, ph::_1, std::cref(Content), std::cref(Config.song_list_format)
94 			));
95 			break;
96 		case DisplayMode::Columns:
97 			Content.setItemDisplayer(std::bind(
98 				Display::SongsInColumns, ph::_1, std::cref(Content)
99 			));
100 			break;
101 	}
102 
103 	w = &Playlists;
104 }
105 
resize()106 void PlaylistEditor::resize()
107 {
108 	size_t x_offset, width;
109 	getWindowResizeParams(x_offset, width);
110 
111 	LeftColumnStartX = x_offset;
112 	LeftColumnWidth = width/3-1;
113 	RightColumnStartX = LeftColumnStartX+LeftColumnWidth+1;
114 	RightColumnWidth = width-LeftColumnWidth-1;
115 
116 	Playlists.resize(LeftColumnWidth, MainHeight);
117 	Content.resize(RightColumnWidth, MainHeight);
118 
119 	Playlists.moveTo(LeftColumnStartX, MainStartY);
120 	Content.moveTo(RightColumnStartX, MainStartY);
121 
122 	hasToBeResized = 0;
123 }
124 
title()125 std::wstring PlaylistEditor::title()
126 {
127 	return L"Playlist editor";
128 }
129 
refresh()130 void PlaylistEditor::refresh()
131 {
132 	Playlists.display();
133 	drawSeparator(RightColumnStartX-1);
134 	Content.display();
135 }
136 
switchTo()137 void PlaylistEditor::switchTo()
138 {
139 	SwitchTo::execute(this);
140 	drawHeader();
141 	refresh();
142 }
143 
update()144 void PlaylistEditor::update()
145 {
146 	{
147 		ScopedUnfilteredMenu<MPD::Playlist> sunfilter_playlists(ReapplyFilter::No, Playlists);
148 		if (Playlists.empty() || m_playlists_update_requested)
149 		{
150 			m_playlists_update_requested = false;
151 			sunfilter_playlists.set(ReapplyFilter::Yes, true);
152 			size_t idx = 0;
153 			try
154 			{
155 				for (MPD::PlaylistIterator it = Mpd.GetPlaylists(), end; it != end; ++it, ++idx)
156 				{
157 					if (idx < Playlists.size())
158 						Playlists[idx].value() = std::move(*it);
159 					else
160 						Playlists.addItem(std::move(*it));
161 				};
162 			}
163 			catch (MPD::ServerError &e)
164 			{
165 				if (e.code() == MPD_SERVER_ERROR_SYSTEM) // no playlists directory
166 					Statusbar::print(e.what());
167 				else
168 					throw;
169 			}
170 			if (idx < Playlists.size())
171 				Playlists.resizeList(idx);
172 			std::sort(Playlists.beginV(), Playlists.endV(),
173 			          LocaleBasedSorting(std::locale(), Config.ignore_leading_the));
174 		}
175 	}
176 
177 	{
178 		ScopedUnfilteredMenu<MPD::Song> sunfilter_content(ReapplyFilter::No, Content);
179 		if (!Playlists.empty()
180 		    && ((Content.empty() && Global::Timer - m_timer > m_fetching_delay)
181 		        || m_content_update_requested))
182 		{
183 			m_content_update_requested = false;
184 			sunfilter_content.set(ReapplyFilter::Yes, true);
185 			size_t idx = 0;
186 			MPD::SongIterator s = Mpd.GetPlaylistContent(Playlists.current()->value().path()), end;
187 			for (; s != end; ++s, ++idx)
188 			{
189 				if (idx < Content.size())
190 					Content[idx].value() = std::move(*s);
191 				else
192 					Content.addItem(std::move(*s));
193 			}
194 			if (idx < Content.size())
195 				Content.resizeList(idx);
196 			std::string wtitle;
197 			if (Config.titles_visibility)
198 			{
199 				wtitle = (boost::format("Content (%1% %2%)")
200 				          % boost::lexical_cast<std::string>(Content.size())
201 				          % (Content.size() == 1 ? "item" : "items")).str();
202 				wtitle.resize(Content.getWidth());
203 			}
204 			Content.setTitle(wtitle);
205 			Content.refreshBorder();
206 		}
207 	}
208 }
209 
windowTimeout()210 int PlaylistEditor::windowTimeout()
211 {
212 	ScopedUnfilteredMenu<MPD::Song> sunfilter_content(ReapplyFilter::No, Content);
213 	if (Content.empty())
214 		return m_window_timeout;
215 	else
216 		return Screen<WindowType>::windowTimeout();
217 }
218 
mouseButtonPressed(MEVENT me)219 void PlaylistEditor::mouseButtonPressed(MEVENT me)
220 {
221 	if (Playlists.hasCoords(me.x, me.y))
222 	{
223 		if (!isActiveWindow(Playlists))
224 		{
225 			if (previousColumnAvailable())
226 				previousColumn();
227 			else
228 				return;
229 		}
230 		if (size_t(me.y) < Playlists.size() && (me.bstate & (BUTTON1_PRESSED | BUTTON3_PRESSED)))
231 		{
232 			Playlists.Goto(me.y);
233 			if (me.bstate & BUTTON3_PRESSED)
234 				addItemToPlaylist(false);
235 		}
236 		else
237 			Screen<WindowType>::mouseButtonPressed(me);
238 		Content.clear();
239 	}
240 	else if (Content.hasCoords(me.x, me.y))
241 	{
242 		if (!isActiveWindow(Content))
243 		{
244 			if (nextColumnAvailable())
245 				nextColumn();
246 			else
247 				return;
248 		}
249 		if (size_t(me.y) < Content.size() && (me.bstate & (BUTTON1_PRESSED | BUTTON3_PRESSED)))
250 		{
251 			Content.Goto(me.y);
252 			bool play = me.bstate & BUTTON3_PRESSED;
253 			addItemToPlaylist(play);
254 		}
255 		else
256 			Screen<WindowType>::mouseButtonPressed(me);
257 	}
258 }
259 
260 /***********************************************************************/
261 
allowsSearching()262 bool PlaylistEditor::allowsSearching()
263 {
264 	return true;
265 }
266 
searchConstraint()267 const std::string &PlaylistEditor::searchConstraint()
268 {
269 	if (isActiveWindow(Playlists))
270 		return m_playlists_search_predicate.constraint();
271 	else if (isActiveWindow(Content))
272 		return m_content_search_predicate.constraint();
273 	throw std::runtime_error("no active window");
274 }
275 
setSearchConstraint(const std::string & constraint)276 void PlaylistEditor::setSearchConstraint(const std::string &constraint)
277 {
278 	if (isActiveWindow(Playlists))
279 	{
280 		m_playlists_search_predicate = Regex::Filter<MPD::Playlist>(
281 			constraint,
282 			Config.regex_type,
283 			PlaylistEntryMatcher);
284 	}
285 	else if (isActiveWindow(Content))
286 	{
287 		m_content_search_predicate = Regex::Filter<MPD::Song>(
288 			constraint,
289 			Config.regex_type,
290 			SongEntryMatcher);
291 	}
292 }
293 
clearSearchConstraint()294 void PlaylistEditor::clearSearchConstraint()
295 {
296 	if (isActiveWindow(Playlists))
297 		m_playlists_search_predicate.clear();
298 	else if (isActiveWindow(Content))
299 		m_content_search_predicate.clear();
300 }
301 
search(SearchDirection direction,bool wrap,bool skip_current)302 bool PlaylistEditor::search(SearchDirection direction, bool wrap, bool skip_current)
303 {
304 	bool result = false;
305 	if (isActiveWindow(Playlists))
306 		result = ::search(Playlists, m_playlists_search_predicate, direction, wrap, skip_current);
307 	else if (isActiveWindow(Content))
308 		result = ::search(Content, m_content_search_predicate, direction, wrap, skip_current);
309 	return result;
310 }
311 
312 /***********************************************************************/
313 
allowsFiltering()314 bool PlaylistEditor::allowsFiltering()
315 {
316 	return allowsSearching();
317 }
318 
currentFilter()319 std::string PlaylistEditor::currentFilter()
320 {
321 	std::string result;
322 	if (isActiveWindow(Playlists))
323 	{
324 		if (auto pred = Playlists.filterPredicate<Regex::Filter<MPD::Playlist>>())
325 			result = pred->constraint();
326 	}
327 	else if (isActiveWindow(Content))
328 	{
329 		if (auto pred = Content.filterPredicate<Regex::Filter<MPD::Song>>())
330 			result = pred->constraint();
331 	}
332 	return result;
333 }
334 
applyFilter(const std::string & constraint)335 void PlaylistEditor::applyFilter(const std::string &constraint)
336 {
337 	if (isActiveWindow(Playlists))
338 	{
339 		if (!constraint.empty())
340 		{
341 			Playlists.applyFilter(Regex::Filter<MPD::Playlist>(
342 				                      constraint,
343 				                      Config.regex_type,
344 				                      PlaylistEntryMatcher));
345 		}
346 		else
347 			Playlists.clearFilter();
348 	}
349 	else if (isActiveWindow(Content))
350 	{
351 		if (!constraint.empty())
352 		{
353 			Content.applyFilter(Regex::Filter<MPD::Song>(
354 				                    constraint,
355 				                    Config.regex_type,
356 				                    SongEntryMatcher));
357 		}
358 		else
359 			Content.clearFilter();
360 	}
361 }
362 
363 
364 /***********************************************************************/
365 
itemAvailable()366 bool PlaylistEditor::itemAvailable()
367 {
368 	if (isActiveWindow(Playlists))
369 		return !Playlists.empty();
370 	if (isActiveWindow(Content))
371 		return !Content.empty();
372 	return false;
373 }
374 
addItemToPlaylist(bool play)375 bool PlaylistEditor::addItemToPlaylist(bool play)
376 {
377 	bool success = false;
378 	if (isActiveWindow(Playlists))
379 	{
380 		const auto &playlist = Playlists.current()->value();
381 		success = Mpd.LoadPlaylist(playlist.path());
382 		if (play)
383 		{
384 			// Cheap trick that might fail in presence of multiple clients modifying the
385 			// playlist at the same time, but oh well, this approach correctly loads cue
386 			// playlists and is much faster in general as it doesn't require fetching
387 			// song data.
388 			try
389 			{
390 				Mpd.Play(Status::State::playlistLength());
391 			}
392 			catch (MPD::ServerError &e)
393 			{
394 				// If not bad index, rethrow.
395 				if (e.code() != MPD_SERVER_ERROR_ARG)
396 					throw;
397 			}
398 		}
399 		if (success)
400 			Statusbar::printf("Playlist \"%1%\" loaded", playlist.path());
401 	}
402 	else if (isActiveWindow(Content))
403 		success = addSongToPlaylist(Content.current()->value(), play);
404 	return success;
405 }
406 
getSelectedSongs()407 std::vector<MPD::Song> PlaylistEditor::getSelectedSongs()
408 {
409 	std::vector<MPD::Song> result;
410 	if (isActiveWindow(Playlists))
411 	{
412 		bool any_selected = false;
413 		for (auto &e : Playlists)
414 		{
415 			if (e.isSelected())
416 			{
417 				any_selected = true;
418 				std::copy(
419 					std::make_move_iterator(Mpd.GetPlaylistContent(e.value().path())),
420 					std::make_move_iterator(MPD::SongIterator()),
421 					std::back_inserter(result));
422 			}
423 		}
424 		// if no item is selected, add songs from right column
425 		ScopedUnfilteredMenu<MPD::Song> sunfilter_content(ReapplyFilter::No, Content);
426 		if (!any_selected && !Playlists.empty())
427 			std::copy(Content.beginV(), Content.endV(), std::back_inserter(result));
428 	}
429 	else if (isActiveWindow(Content))
430 		result = Content.getSelectedSongs();
431 	return result;
432 }
433 
434 /***********************************************************************/
435 
previousColumnAvailable()436 bool PlaylistEditor::previousColumnAvailable()
437 {
438 	if (isActiveWindow(Content))
439 	{
440 		ScopedUnfilteredMenu<MPD::Playlist> sunfilter_playlists(ReapplyFilter::No, Playlists);
441 		if (!Playlists.empty())
442 			return true;
443 	}
444 	return false;
445 }
446 
previousColumn()447 void PlaylistEditor::previousColumn()
448 {
449 	if (isActiveWindow(Content))
450 	{
451 		setHighlightInactiveColumnFixes(Content);
452 		w->refresh();
453 		w = &Playlists;
454 		setHighlightFixes(Playlists);
455 	}
456 }
457 
nextColumnAvailable()458 bool PlaylistEditor::nextColumnAvailable()
459 {
460 	if (isActiveWindow(Playlists))
461 	{
462 		ScopedUnfilteredMenu<MPD::Song> sunfilter_content(ReapplyFilter::No, Content);
463 		if (!Content.empty())
464 			return true;
465 	}
466 	return false;
467 }
468 
nextColumn()469 void PlaylistEditor::nextColumn()
470 {
471 	if (isActiveWindow(Playlists))
472 	{
473 		setHighlightInactiveColumnFixes(Playlists);
474 		w->refresh();
475 		w = &Content;
476 		setHighlightFixes(Content);
477 	}
478 }
479 
480 /***********************************************************************/
481 
updateTimer()482 void PlaylistEditor::updateTimer()
483 {
484 	m_timer = Global::Timer;
485 }
486 
locatePlaylist(const MPD::Playlist & playlist)487 void PlaylistEditor::locatePlaylist(const MPD::Playlist &playlist)
488 {
489 	update();
490 	Playlists.clearFilter();
491 	auto first = Playlists.beginV(), last = Playlists.endV();
492 	auto it = std::find(first, last, playlist);
493 	if (it != last)
494 	{
495 		Playlists.highlight(it - first);
496 		Content.clear();
497 		Content.clearFilter();
498 		switchTo();
499 	}
500 }
501 
locateSong(const MPD::Song & s)502 void PlaylistEditor::locateSong(const MPD::Song &s)
503 {
504 	if (Playlists.empty())
505 		return;
506 
507 	Content.clearFilter();
508 	Playlists.clearFilter();
509 
510 	auto locate_song_in_current_playlist = [this, &s](auto front, auto back) {
511 		if (!Content.empty())
512 		{
513 			auto it = std::find(front, back, s);
514 			if (it != back)
515 			{
516 				Content.highlight(it - Content.beginV());
517 				nextColumn();
518 				return true;
519 			}
520 		}
521 		return false;
522 	};
523 	auto locate_song_in_playlists = [this, &s](auto front, auto back) {
524 		for (auto it = front; it != back; ++it)
525 		{
526 			if (auto song_index = GetSongIndexInPlaylist(*it, s))
527 			{
528 				Playlists.highlight(it - Playlists.beginV());
529 				Playlists.refresh();
530 
531 				requestContentUpdate();
532 				update();
533 				Content.highlight(*song_index);
534 				nextColumn();
535 
536 				return true;
537 			}
538 		}
539 		return false;
540 	};
541 
542 
543 	if (locate_song_in_current_playlist(Content.currentV() + 1, Content.endV()))
544 		return;
545 	Statusbar::print("Jumping to song...");
546 	if (locate_song_in_playlists(Playlists.currentV() + 1, Playlists.endV()))
547 		return;
548 	if (locate_song_in_playlists(Playlists.beginV(), Playlists.currentV()))
549 		return;
550 	if (locate_song_in_current_playlist(Content.beginV(), Content.currentV()))
551 		return;
552 
553 	// Highlighted song was skipped, so if that's the one we're looking for, we're
554 	// good.
555 	if (Content.empty() || *Content.currentV() != s)
556 		Statusbar::print("Song was not found in playlists");
557 }
558 
559 namespace {
560 
SongToString(const MPD::Song & s)561 std::string SongToString(const MPD::Song &s)
562 {
563 	std::string result;
564 	switch (Config.playlist_display_mode)
565 	{
566 		case DisplayMode::Classic:
567 			result = Format::stringify<char>(Config.song_list_format, &s);
568 			break;
569 		case DisplayMode::Columns:
570 			result = Format::stringify<char>(Config.song_columns_mode_format, &s);
571 			break;
572 	}
573 	return result;
574 }
575 
PlaylistEntryMatcher(const Regex::Regex & rx,const MPD::Playlist & playlist)576 bool PlaylistEntryMatcher(const Regex::Regex &rx, const MPD::Playlist &playlist)
577 {
578 	return Regex::search(playlist.path(), rx, Config.ignore_diacritics);
579 }
580 
SongEntryMatcher(const Regex::Regex & rx,const MPD::Song & s)581 bool SongEntryMatcher(const Regex::Regex &rx, const MPD::Song &s)
582 {
583 	return Regex::search(SongToString(s), rx, Config.ignore_diacritics);
584 }
585 
GetSongIndexInPlaylist(MPD::Playlist playlist,const MPD::Song & song)586 boost::optional<size_t> GetSongIndexInPlaylist(MPD::Playlist playlist, const MPD::Song &song)
587 {
588 	size_t index = 0;
589 	MPD::SongIterator it = Mpd.GetPlaylistContentNoInfo(playlist.path()), end;
590 
591 	for (;;)
592 	{
593 		if (it == end)
594 			return boost::none;
595 		if (*it == song)
596 			return index;
597 
598 		++it, ++index;
599 	}
600 }
601 
602 }
603