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