1 /*
2  * Copyright (C) 2018 Emeric Poupon
3  *
4  * This file is part of LMS.
5  *
6  * LMS is free software: you can redistribute it and/or modify
7  * it under the terms of the GNU General Public License as published by
8  * the Free Software Foundation, either version 3 of the License, or
9  * (at your option) any later version.
10  *
11  * LMS is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14  * GNU General Public License for more details.
15  *
16  * You should have received a copy of the GNU General Public License
17  * along with LMS.  If not, see <http://www.gnu.org/licenses/>.
18  */
19 
20 #include "PlayQueue.hpp"
21 
22 #include <Wt/WPopupMenu.h>
23 #include <Wt/WText.h>
24 
25 #include "database/Cluster.hpp"
26 #include "database/Session.hpp"
27 #include "database/Track.hpp"
28 #include "database/TrackList.hpp"
29 #include "database/User.hpp"
30 #include "recommendation/IEngine.hpp"
31 #include "utils/Logger.hpp"
32 #include "utils/Random.hpp"
33 #include "utils/Service.hpp"
34 #include "utils/String.hpp"
35 
36 #include "common/LoadingIndicator.hpp"
37 #include "resource/CoverResource.hpp"
38 #include "resource/DownloadResource.hpp"
39 #include "LmsApplication.hpp"
40 #include "MediaPlayer.hpp"
41 #include "TrackStringUtils.hpp"
42 
43 namespace UserInterface {
44 
PlayQueue()45 PlayQueue::PlayQueue()
46 : Wt::WTemplate(Wt::WString::tr("Lms.PlayQueue.template"))
47 {
48 	addFunction("tr", &Wt::WTemplate::Functions::tr);
49 
50 	{
51 		auto transaction {LmsApp->getDbSession().createSharedTransaction()};
52 		_repeatAll = LmsApp->getUser()->isRepeatAllSet();
53 		_radioMode = LmsApp->getUser()->isRadioSet();
54 	}
55 
56 	auto setToolTip = [](Wt::WWidget& widget, Wt::WString title)
57 	{
58 		widget.setAttributeValue("data-toggle", "tooltip");
59 		widget.setAttributeValue("data-placement", "bottom");
60 		widget.setAttributeValue("title", std::move(title));
61 	};
62 
63 	Wt::WText* clearBtn = bindNew<Wt::WText>("clear-btn", Wt::WString::tr("Lms.PlayQueue.template.clear-btn"), Wt::TextFormat::XHTML);
64 	setToolTip(*clearBtn, Wt::WString::tr("Lms.PlayQueue.clear"));
65 	clearBtn->clicked().connect([=]
66 	{
67 		clearTracks();
68 	});
69 
70 	_entriesContainer = bindNew<Wt::WContainerWidget>("entries");
71 	hideLoadingIndicator();
72 
73 	Wt::WText* shuffleBtn = bindNew<Wt::WText>("shuffle-btn", Wt::WString::tr("Lms.PlayQueue.template.shuffle-btn"), Wt::TextFormat::XHTML);
74 	setToolTip(*shuffleBtn, Wt::WString::tr("Lms.PlayQueue.shuffle"));
75 	shuffleBtn->clicked().connect([=]
76 	{
77 		{
78 			auto transaction {LmsApp->getDbSession().createUniqueTransaction()};
79 
80 			Database::TrackList::pointer trackList {getTrackList()};
81 			auto entries {trackList->getEntries()};
82 			Random::shuffleContainer(entries);
83 
84 			getTrackList().modify()->clear();
85 			for (const auto& entry : entries)
86 				Database::TrackListEntry::create(LmsApp->getDbSession(), entry->getTrack(), trackList);
87 		}
88 		_entriesContainer->clear();
89 		addSome();
90 	});
91 
92 	_repeatBtn = bindNew<Wt::WText>("repeat-btn", Wt::WString::tr("Lms.PlayQueue.template.repeat-btn"), Wt::TextFormat::XHTML);
93 	setToolTip(*_repeatBtn, Wt::WString::tr("Lms.PlayQueue.repeat"));
94 	_repeatBtn->clicked().connect([=]
95 	{
96 		_repeatAll = !_repeatAll;
97 		updateRepeatBtn();
98 
99 		auto transaction {LmsApp->getDbSession().createUniqueTransaction()};
100 
101 		if (!LmsApp->getUser()->isDemo())
102 			LmsApp->getUser().modify()->setRepeatAll(_repeatAll);
103 	});
104 	updateRepeatBtn();
105 
106 	_radioBtn = bindNew<Wt::WText>("radio-btn", Wt::WString::tr("Lms.PlayQueue.template.radio-btn"));
107 	setToolTip(*_radioBtn, Wt::WString::tr("Lms.PlayQueue.radio-mode"));
108 	_radioBtn->clicked().connect([=]
109 	{
110 		_radioMode = !_radioMode;
111 		updateRadioBtn();
112 
113 		auto transaction {LmsApp->getDbSession().createUniqueTransaction()};
114 
115 		if (!LmsApp->getUser()->isDemo())
116 			LmsApp->getUser().modify()->setRadio(_radioMode);
117 	});
118 	updateRadioBtn();
119 
120 	_nbTracks = bindNew<Wt::WText>("nb-tracks");
121 
122 	LmsApp->preQuit().connect([=]
123 	{
124 		auto transaction {LmsApp->getDbSession().createUniqueTransaction()};
125 
126 		if (LmsApp->getUser()->isDemo())
127 		{
128 			LMS_LOG(UI, DEBUG) << "Removing tracklist id " << _tracklistId;
129 			auto tracklist = Database::TrackList::getById(LmsApp->getDbSession(), _tracklistId);
130 			if (tracklist)
131 				tracklist.remove();
132 		}
133 	});
134 
135 	{
136 		auto transaction {LmsApp->getDbSession().createUniqueTransaction()};
137 
138 		Database::TrackList::pointer trackList;
139 
140 		if (!LmsApp->getUser()->isDemo())
141 		{
142 			LmsApp->getMediaPlayer().settingsLoaded.connect([=]
143 			{
144 				if (_mediaPlayerSettingsLoaded)
145 					return;
146 
147 				_mediaPlayerSettingsLoaded = true;
148 
149 				std::size_t trackPos {};
150 
151 				{
152 					auto transaction {LmsApp->getDbSession().createSharedTransaction()};
153 					trackPos = LmsApp->getUser()->getCurPlayingTrackPos();
154 				}
155 
156 				loadTrack(trackPos, false);
157 			});
158 			trackList = LmsApp->getUser()->getQueuedTrackList(LmsApp->getDbSession());
159 		}
160 		else
161 		{
162 			static const std::string currentPlayQueueName {"__current__playqueue__"};
163 			trackList = Database::TrackList::create(LmsApp->getDbSession(), currentPlayQueueName, Database::TrackList::Type::Internal, false, LmsApp->getUser());
164 		}
165 
166 		_tracklistId = trackList.id();
167 	}
168 
169 	updateInfo();
170 	addSome();
171 }
172 
173 void
updateRepeatBtn()174 PlayQueue::updateRepeatBtn()
175 {
176 	_repeatBtn->toggleStyleClass("text-success", _repeatAll);
177 	_repeatBtn->toggleStyleClass("text-muted", !_repeatAll);
178 }
179 
180 void
updateRadioBtn()181 PlayQueue::updateRadioBtn()
182 {
183 	_radioBtn->toggleStyleClass("text-success",_radioMode);
184 	_radioBtn->toggleStyleClass("text-muted", !_radioMode);
185 }
186 
187 void
displayLoadingIndicator()188 PlayQueue::displayLoadingIndicator()
189 {
190 	_loadingIndicator = bindWidget<Wt::WTemplate>("loading-indicator", createLoadingIndicator());
191 	_loadingIndicator->scrollVisibilityChanged().connect([this](bool visible)
192 	{
193 		if (!visible)
194 			return;
195 
196 		addSome();
197 		updateCurrentTrack(true);
198 	});
199 }
200 
201 void
hideLoadingIndicator()202 PlayQueue::hideLoadingIndicator()
203 {
204 	_loadingIndicator = nullptr;
205 	bindEmpty("loading-indicator");
206 }
207 
208 Database::TrackList::pointer
getTrackList() const209 PlayQueue::getTrackList() const
210 {
211 	return Database::TrackList::getById(LmsApp->getDbSession(), _tracklistId);
212 }
213 
214 bool
isFull() const215 PlayQueue::isFull() const
216 {
217 	auto transaction {LmsApp->getDbSession().createSharedTransaction()};
218 	return getTrackList()->getCount() == _nbMaxEntries;
219 }
220 
221 void
clearTracks()222 PlayQueue::clearTracks()
223 {
224 	{
225 		auto transaction {LmsApp->getDbSession().createUniqueTransaction()};
226 		getTrackList().modify()->clear();
227 	}
228 
229 	hideLoadingIndicator();
230 	_entriesContainer->clear();
231 	updateInfo();
232 }
233 
234 void
stop()235 PlayQueue::stop()
236 {
237 	updateCurrentTrack(false);
238 	_trackPos.reset();
239 	trackUnselected.emit();
240 }
241 
242 void
loadTrack(std::size_t pos,bool play)243 PlayQueue::loadTrack(std::size_t pos, bool play)
244 {
245 	updateCurrentTrack(false);
246 
247 	Database::IdType trackId {};
248 	bool addRadioTrack {};
249 	std::optional<float> replayGain {};
250 	{
251 		auto transaction {LmsApp->getDbSession().createSharedTransaction()};
252 
253 		Database::TrackList::pointer tracklist {getTrackList()};
254 
255 		// If out of range, stop playing
256 		if (pos >= tracklist->getCount())
257 		{
258 			if (!_repeatAll || tracklist->getCount() == 0)
259 			{
260 				stop();
261 				return;
262 			}
263 
264 			pos = 0;
265 		}
266 
267 		// If last and radio mode, fill the next song
268 		if (_radioMode && pos == tracklist->getCount() - 1)
269 			addRadioTrack = true;
270 
271 		_trackPos = pos;
272 		auto track = tracklist->getEntry(*_trackPos)->getTrack();
273 
274 		trackId = track.id();
275 
276 		replayGain = getReplayGain(pos, track);
277 
278 		if (!LmsApp->getUser()->isDemo())
279 			LmsApp->getUser().modify()->setCurPlayingTrackPos(pos);
280 	}
281 
282 	if (addRadioTrack)
283 		enqueueRadioTracks();
284 
285 	updateCurrentTrack(true);
286 
287 	trackSelected.emit(trackId, play, replayGain ? *replayGain : 0);
288 }
289 
290 void
playPrevious()291 PlayQueue::playPrevious()
292 {
293 	if (!_trackPos)
294 		return;
295 
296 	if (*_trackPos == 0)
297 		stop();
298 	else
299 		loadTrack(*_trackPos - 1, true);
300 }
301 
302 void
playNext()303 PlayQueue::playNext()
304 {
305 	if (!_trackPos)
306 	{
307 		loadTrack(0, true);
308 		return;
309 	}
310 
311 	loadTrack(*_trackPos + 1, true);
312 }
313 
314 void
updateInfo()315 PlayQueue::updateInfo()
316 {
317 	auto transaction {LmsApp->getDbSession().createSharedTransaction()};
318 
319 	_nbTracks->setText(Wt::WString::tr("Lms.PlayQueue.nb-tracks").arg(static_cast<unsigned>(getTrackList()->getCount())));
320 }
321 
322 void
updateCurrentTrack(bool selected)323 PlayQueue::updateCurrentTrack(bool selected)
324 {
325 	if (!_trackPos || *_trackPos >= static_cast<std::size_t>(_entriesContainer->count()))
326 		return;
327 
328 	Wt::WTemplate* entry {static_cast<Wt::WTemplate*>(_entriesContainer->widget(*_trackPos))};
329 	if (entry)
330 		entry->bindString("is-selected", selected ? "Lms-playqueue-selected" : "");
331 }
332 
333 std::size_t
enqueueTracks(const std::vector<Database::IdType> & trackIds)334 PlayQueue::enqueueTracks(const std::vector<Database::IdType>& trackIds)
335 {
336 	std::size_t nbTracksQueued {};
337 
338 	{
339 		auto transaction {LmsApp->getDbSession().createUniqueTransaction()};
340 
341 		auto tracklist {getTrackList()};
342 
343 		std::size_t nbTracksToEnqueue {tracklist->getCount() + trackIds.size() > _nbMaxEntries ? _nbMaxEntries - tracklist->getCount() : trackIds.size()};
344 		for (Database::IdType trackId : trackIds)
345 		{
346 			Database::Track::pointer track {Database::Track::getById(LmsApp->getDbSession(), trackId)};
347 			if (!track)
348 				continue;
349 
350 			if (nbTracksQueued == nbTracksToEnqueue)
351 				break;
352 
353 			Database::TrackListEntry::create(LmsApp->getDbSession(), track, tracklist);
354 			nbTracksQueued++;
355 		}
356 	}
357 
358 	updateInfo();
359 	addSome();
360 
361 	return nbTracksQueued;
362 }
363 
364 void
processTracks(PlayQueueAction action,const std::vector<Database::IdType> & trackIds)365 PlayQueue::processTracks(PlayQueueAction action, const std::vector<Database::IdType>& trackIds)
366 {
367 	std::size_t nbAddedTracks {};
368 
369 	switch (action)
370 	{
371 		case PlayQueueAction::PlayLast:
372 			nbAddedTracks = enqueueTracks(trackIds);
373 			if (!_trackPos)
374 				loadTrack(0, true);
375 			break;
376 
377 		case PlayQueueAction::Play:
378 			clearTracks();
379 			nbAddedTracks = enqueueTracks(trackIds);
380 			loadTrack(0, true);
381 
382 			break;
383 
384 		case PlayQueueAction::PlayShuffled:
385 		{
386 			clearTracks();
387 			{
388 				std::vector<Database::IdType> shuffledTrackIds {trackIds};
389 				Random::shuffleContainer(shuffledTrackIds);
390 				nbAddedTracks = enqueueTracks(shuffledTrackIds);
391 			}
392 			loadTrack(0, true);
393 			break;
394 		}
395 	}
396 
397 	if (nbAddedTracks > 0)
398 		LmsApp->notifyMsg(LmsApplication::MsgType::Info, Wt::WString::trn("Lms.PlayQueue.nb-tracks-added", nbAddedTracks).arg(nbAddedTracks), std::chrono::milliseconds(2000));
399 
400 	if (isFull())
401 		LmsApp->notifyMsg(LmsApplication::MsgType::Warning, Wt::WString::tr("Lms.PlayQueue.playqueue-full"), std::chrono::milliseconds(2000));
402 
403 
404 }
405 
406 void
addSome()407 PlayQueue::addSome()
408 {
409 	auto transaction {LmsApp->getDbSession().createSharedTransaction()};
410 
411 	auto tracklist = getTrackList();
412 
413 	auto tracklistEntries = tracklist->getEntries(_entriesContainer->count(), 50);
414 	for (const Database::TrackListEntry::pointer& tracklistEntry : tracklistEntries)
415 		addEntry(tracklistEntry);
416 
417 	if (static_cast<std::size_t>(_entriesContainer->count()) < tracklist->getCount())
418 		displayLoadingIndicator();
419 	else
420 		hideLoadingIndicator();
421 }
422 
423 void
addEntry(const Database::TrackListEntry::pointer & tracklistEntry)424 PlayQueue::addEntry(const Database::TrackListEntry::pointer& tracklistEntry)
425 {
426 	const auto tracklistEntryId {tracklistEntry.id()};
427 	const auto track {tracklistEntry->getTrack()};
428 	const Database::IdType trackId {track->id()};
429 
430 	Wt::WTemplate* entry = _entriesContainer->addNew<Wt::WTemplate>(Wt::WString::tr("Lms.PlayQueue.template.entry"));
431 
432 	entry->bindString("name", Wt::WString::fromUTF8(track->getName()), Wt::TextFormat::Plain);
433 
434 	const auto artists {track->getArtists({Database::TrackArtistLinkType::Artist})};
435 	const auto release {track->getRelease()};
436 
437 	if (!artists.empty() || release)
438 		entry->setCondition("if-has-artists-or-release", true);
439 
440 	if (!artists.empty())
441 	{
442 		entry->setCondition("if-has-artists", true);
443 
444 		Wt::WContainerWidget* artistContainer {entry->bindNew<Wt::WContainerWidget>("artists")};
445 		for (const auto& artist : artists)
446 		{
447 			Wt::WTemplate* a {artistContainer->addNew<Wt::WTemplate>(Wt::WString::tr("Lms.PlayQueue.template.entry-artist"))};
448 			a->bindWidget("artist", LmsApplication::createArtistAnchor(artist));
449 		}
450 	}
451 	if (release)
452 	{
453 		entry->setCondition("if-has-release", true);
454 		entry->bindWidget("release", LmsApplication::createReleaseAnchor(release));
455 		{
456 			Wt::WAnchor* anchor = entry->bindWidget("cover", LmsApplication::createReleaseAnchor(release, false));
457 			auto cover = std::make_unique<Wt::WImage>();
458 			cover->setImageLink(LmsApp->getCoverResource()->getReleaseUrl(release.id(), CoverResource::Size::Large));
459 			cover->setStyleClass("Lms-cover");
460 			cover->setAttributeValue("onload", LmsApp->javaScriptClass() + ".onLoadCover(this)");
461 			anchor->setImage(std::move(cover));
462 		}
463 	}
464 	else
465 	{
466 		auto cover = entry->bindNew<Wt::WImage>("cover");
467 		cover->setImageLink(LmsApp->getCoverResource()->getTrackUrl(track.id(), CoverResource::Size::Large));
468 		cover->setStyleClass("Lms-cover");
469 		cover->setAttributeValue("onload", LmsApp->javaScriptClass() + ".onLoadCover(this)");
470 	}
471 
472 	entry->bindString("duration", trackDurationToString(track->getDuration()), Wt::TextFormat::Plain);
473 
474 	Wt::WText* playBtn {entry->bindNew<Wt::WText>("play-btn", Wt::WString::tr("Lms.PlayQueue.template.play-btn"), Wt::TextFormat::XHTML)};
475 	playBtn->clicked().connect([=]
476 	{
477 		auto pos = _entriesContainer->indexOf(entry);
478 		if (pos >= 0)
479 			loadTrack(pos, true);
480 	});
481 
482 	Wt::WText* delBtn {entry->bindNew<Wt::WText>("del-btn", Wt::WString::tr("Lms.PlayQueue.template.delete-btn"), Wt::TextFormat::XHTML)};
483 	delBtn->clicked().connect([=]
484 	{
485 		// Remove the entry n both the widget tree and the playqueue
486 		{
487 			auto transaction {LmsApp->getDbSession().createUniqueTransaction()};
488 
489 			Database::TrackListEntry::pointer entryToRemove {Database::TrackListEntry::getById(LmsApp->getDbSession(), tracklistEntryId)};
490 			entryToRemove.remove();
491 		}
492 
493 		if (_trackPos)
494 		{
495 			auto pos {_entriesContainer->indexOf(entry)};
496 			if (pos > 0 && *_trackPos >= static_cast<std::size_t>(pos))
497 			(*_trackPos)--;
498 		}
499 
500 		_entriesContainer->removeWidget(entry);
501 
502 		updateInfo();
503 	});
504 
505 	Wt::WText* moreBtn {entry->bindNew<Wt::WText>("more-btn", Wt::WString::tr("Lms.PlayQueue.template.more-btn"), Wt::TextFormat::XHTML)};
506 	moreBtn->clicked().connect([=]
507 	{
508 		Wt::WPopupMenu* popup {LmsApp->createPopupMenu()};
509 
510 		bool isStarred {};
511 		{
512 			auto transaction {LmsApp->getDbSession().createSharedTransaction()};
513 
514 			if (auto track {Database::Track::getById(LmsApp->getDbSession(), trackId)})
515 			isStarred = LmsApp->getUser()->hasStarredTrack(track);
516 		}
517 
518 		popup->addItem(Wt::WString::tr(isStarred ? "Lms.Explore.unstar" : "Lms.Explore.star"))
519 			->triggered().connect(moreBtn, [=]
520 			{
521 				auto transaction {LmsApp->getDbSession().createUniqueTransaction()};
522 
523 				auto track {Database::Track::getById(LmsApp->getDbSession(), trackId)};
524 				if (!track)
525 					return;
526 
527 				if (isStarred)
528 					LmsApp->getUser().modify()->unstarTrack(track);
529 				else
530 					LmsApp->getUser().modify()->starTrack(track);
531 			});
532 		popup->addItem(Wt::WString::tr("Lms.Explore.download"))
533 			->setLink(Wt::WLink {std::make_unique<DownloadTrackResource>(trackId)});
534 
535 		popup->popup(moreBtn);
536 	});
537 }
538 
539 void
enqueueRadioTracks()540 PlayQueue::enqueueRadioTracks()
541 {
542 	const auto similarTrackIds {Service<Recommendation::IEngine>::get()->getSimilarTracksFromTrackList(LmsApp->getDbSession(), _tracklistId, 3)};
543 
544 	std::vector<Database::IdType> trackToAddIds(std::cbegin(similarTrackIds), std::cend(similarTrackIds));
545 	Random::shuffleContainer(trackToAddIds);
546 	enqueueTracks(trackToAddIds);
547 }
548 
549 std::optional<float>
getReplayGain(std::size_t pos,const Database::Track::pointer & track) const550 PlayQueue::getReplayGain(std::size_t pos, const Database::Track::pointer& track) const
551 {
552 	const auto& settings {LmsApp->getMediaPlayer().getSettings()};
553 	if (!settings)
554 		return std::nullopt;
555 
556 	std::optional<MediaPlayer::Gain> gain;
557 
558 	switch (settings->replayGain.mode)
559 	{
560 		case MediaPlayer::Settings::ReplayGain::Mode::None:
561 			return std::nullopt;
562 
563 		case MediaPlayer::Settings::ReplayGain::Mode::Track:
564 			gain = track->getTrackReplayGain();
565 			break;
566 
567 		case MediaPlayer::Settings::ReplayGain::Mode::Release:
568 			gain = track->getReleaseReplayGain();
569 			if (!gain)
570 				gain = track->getTrackReplayGain();
571 			break;
572 
573 		case MediaPlayer::Settings::ReplayGain::Mode::Auto:
574 		{
575 			const auto trackList {getTrackList()};
576 			const auto prevEntry {pos > 0 ? trackList->getEntry(pos - 1) : Database::TrackListEntry::pointer {}};
577 			const auto nextEntry {trackList->getEntry(pos + 1)};
578 			const Database::Track::pointer prevTrack {prevEntry ? prevEntry->getTrack() : Database::Track::pointer {}};
579 			const Database::Track::pointer nextTrack {nextEntry ? nextEntry->getTrack() : Database::Track::pointer {}};
580 
581 			if ((prevTrack && prevTrack->getRelease() && prevTrack->getRelease() == track->getRelease())
582 				||
583 				(nextTrack && nextTrack->getRelease() && nextTrack->getRelease() == track->getRelease()))
584 			{
585 				gain = track->getReleaseReplayGain();
586 				if (!gain)
587 					gain = track->getTrackReplayGain();
588 			}
589 			else
590 			{
591 				gain = track->getTrackReplayGain();
592 			}
593 			break;
594 		}
595 	}
596 
597 	if (gain)
598 		return *gain + settings->replayGain.preAmpGain;
599 
600 	return settings->replayGain.preAmpGainIfNoInfo;
601 }
602 
603 } // namespace UserInterface
604 
605