1 /* This file is part of Clementine.
2    Copyright 2012, David Sansome <me@davidsansome.com>
3 
4    Clementine is free software: you can redistribute it and/or modify
5    it under the terms of the GNU General Public License as published by
6    the Free Software Foundation, either version 3 of the License, or
7    (at your option) any later version.
8 
9    Clementine is distributed in the hope that it will be useful,
10    but WITHOUT ANY WARRANTY; without even the implied warranty of
11    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12    GNU General Public License for more details.
13 
14    You should have received a copy of the GNU General Public License
15    along with Clementine.  If not, see <http://www.gnu.org/licenses/>.
16 */
17 
18 #include "globalsearchview.h"
19 
20 #include <QMenu>
21 #include <QSortFilterProxyModel>
22 #include <QStandardItem>
23 #include <QTimer>
24 
25 #include <algorithm>
26 #include <functional>
27 
28 #include "globalsearch.h"
29 #include "globalsearchitemdelegate.h"
30 #include "globalsearchmodel.h"
31 #include "globalsearchsortmodel.h"
32 #include "searchprovider.h"
33 #include "searchproviderstatuswidget.h"
34 #include "suggestionwidget.h"
35 #include "ui_globalsearchview.h"
36 #include "core/application.h"
37 #include "core/logging.h"
38 #include "core/mimedata.h"
39 #include "core/timeconstants.h"
40 #include "internet/core/internetsongmimedata.h"
41 #include "library/libraryfilterwidget.h"
42 #include "library/librarymodel.h"
43 #include "library/groupbydialog.h"
44 #include "playlist/songmimedata.h"
45 
46 using std::placeholders::_1;
47 using std::placeholders::_2;
48 
49 const int GlobalSearchView::kSwapModelsTimeoutMsec = 250;
50 const int GlobalSearchView::kMaxSuggestions = 10;
51 const int GlobalSearchView::kUpdateSuggestionsTimeoutMsec = 60 * kMsecPerSec;
52 
GlobalSearchView(Application * app,QWidget * parent)53 GlobalSearchView::GlobalSearchView(Application* app, QWidget* parent)
54     : QWidget(parent),
55       app_(app),
56       engine_(app_->global_search()),
57       ui_(new Ui_GlobalSearchView),
58       context_menu_(nullptr),
59       last_search_id_(0),
60       front_model_(new GlobalSearchModel(engine_, this)),
61       back_model_(new GlobalSearchModel(engine_, this)),
62       current_model_(front_model_),
63       front_proxy_(new GlobalSearchSortModel(this)),
64       back_proxy_(new GlobalSearchSortModel(this)),
65       current_proxy_(front_proxy_),
66       swap_models_timer_(new QTimer(this)),
67       update_suggestions_timer_(new QTimer(this)),
68       search_icon_(IconLoader::Load("search", IconLoader::Base)),
69       warning_icon_(IconLoader::Load("dialog-warning", IconLoader::Base)),
70       show_providers_(true),
71       show_suggestions_(true) {
72   ui_->setupUi(this);
73 
74   front_model_->set_proxy(front_proxy_);
75   back_model_->set_proxy(back_proxy_);
76 
77   ui_->search->installEventFilter(this);
78   ui_->results_stack->installEventFilter(this);
79 
80   ui_->settings->setIcon(IconLoader::Load("configure", IconLoader::Base));
81 
82   // Must be a queued connection to ensure the GlobalSearch handles it first.
83   connect(app_, SIGNAL(SettingsChanged()), SLOT(ReloadSettings()),
84           Qt::QueuedConnection);
85 
86   connect(ui_->search, SIGNAL(textChanged(QString)), SLOT(TextEdited(QString)));
87   connect(ui_->results, SIGNAL(AddToPlaylistSignal(QMimeData*)),
88           SIGNAL(AddToPlaylist(QMimeData*)));
89   connect(ui_->results, SIGNAL(FocusOnFilterSignal(QKeyEvent*)),
90           SLOT(FocusOnFilter(QKeyEvent*)));
91 
92   // Set the appearance of the results list
93   ui_->results->setItemDelegate(new GlobalSearchItemDelegate(this));
94   ui_->results->setAttribute(Qt::WA_MacShowFocusRect, false);
95   ui_->results->setStyleSheet("QTreeView::item{padding-top:1px;}");
96 
97   // Show the help page initially
98   ui_->results_stack->setCurrentWidget(ui_->help_page);
99   ui_->help_frame->setBackgroundRole(QPalette::Base);
100   QVBoxLayout* enabled_layout = new QVBoxLayout(ui_->enabled_list);
101   QVBoxLayout* disabled_layout = new QVBoxLayout(ui_->disabled_list);
102   QVBoxLayout* suggestions_layout = new QVBoxLayout(ui_->suggestions_list);
103   enabled_layout->setContentsMargins(16, 0, 16, 6);
104   disabled_layout->setContentsMargins(16, 0, 16, 32);
105   suggestions_layout->setContentsMargins(16, 0, 16, 6);
106 
107   // Set the colour of the help text to the disabled window text colour
108   QPalette help_palette = ui_->help_text->palette();
109   const QColor help_color =
110       help_palette.color(QPalette::Disabled, QPalette::WindowText);
111   help_palette.setColor(QPalette::Normal, QPalette::WindowText, help_color);
112   help_palette.setColor(QPalette::Inactive, QPalette::WindowText, help_color);
113   ui_->help_text->setPalette(help_palette);
114 
115   // Create suggestion widgets
116   for (int i = 0; i < kMaxSuggestions; ++i) {
117     SuggestionWidget* widget = new SuggestionWidget(search_icon_);
118     connect(widget, SIGNAL(SuggestionClicked(QString)),
119             SLOT(StartSearch(QString)));
120     suggestions_layout->addWidget(widget);
121     suggestion_widgets_ << widget;
122   }
123 
124   // Make it bold
125   QFont help_font = ui_->help_text->font();
126   help_font.setBold(true);
127   ui_->help_text->setFont(help_font);
128 
129   // Set up the sorting proxy model
130   front_proxy_->setSourceModel(front_model_);
131   front_proxy_->setDynamicSortFilter(true);
132   front_proxy_->sort(0);
133 
134   back_proxy_->setSourceModel(back_model_);
135   back_proxy_->setDynamicSortFilter(true);
136   back_proxy_->sort(0);
137 
138   swap_models_timer_->setSingleShot(true);
139   swap_models_timer_->setInterval(kSwapModelsTimeoutMsec);
140   connect(swap_models_timer_, SIGNAL(timeout()), SLOT(SwapModels()));
141 
142   update_suggestions_timer_->setInterval(kUpdateSuggestionsTimeoutMsec);
143   connect(update_suggestions_timer_, SIGNAL(timeout()),
144           SLOT(UpdateSuggestions()));
145 
146   // Add actions to the settings menu
147   group_by_actions_ = LibraryFilterWidget::CreateGroupByActions(this);
148   QMenu* settings_menu = new QMenu(this);
149   settings_menu->addActions(group_by_actions_->actions());
150   settings_menu->addSeparator();
151   settings_menu->addAction(IconLoader::Load("configure", IconLoader::Base),
152                            tr("Configure global search..."), this,
153                            SLOT(OpenSettingsDialog()));
154   ui_->settings->setMenu(settings_menu);
155 
156   connect(group_by_actions_, SIGNAL(triggered(QAction*)),
157           SLOT(GroupByClicked(QAction*)));
158 
159   // These have to be queued connections because they may get emitted before
160   // our call to Search() (or whatever) returns and we add the ID to the map.
161   connect(engine_, SIGNAL(ResultsAvailable(int, SearchProvider::ResultList)),
162           SLOT(AddResults(int, SearchProvider::ResultList)),
163           Qt::QueuedConnection);
164   connect(engine_, SIGNAL(ArtLoaded(int, QPixmap)),
165           SLOT(ArtLoaded(int, QPixmap)), Qt::QueuedConnection);
166 }
167 
~GlobalSearchView()168 GlobalSearchView::~GlobalSearchView() { delete ui_; }
169 
170 namespace {
CompareProvider(const QStringList & provider_order,SearchProvider * left,SearchProvider * right)171 bool CompareProvider(const QStringList& provider_order, SearchProvider* left,
172                      SearchProvider* right) {
173   const int left_index = provider_order.indexOf(left->id());
174   const int right_index = provider_order.indexOf(right->id());
175   if (left_index == -1 && right_index == -1) {
176     // None are in our provider list: compare name instead
177     return left->name() < right->name();
178   } else if (left_index == -1) {
179     // Left provider not in provider list
180     return false;
181   } else if (right_index == -1) {
182     // Right provider not in provider list
183     return true;
184   }
185   return left_index < right_index;
186 }
187 }
188 
ReloadSettings()189 void GlobalSearchView::ReloadSettings() {
190   const bool old_show_suggestions = show_suggestions_;
191 
192   QSettings s;
193 
194   // Library settings
195   s.beginGroup(LibraryView::kSettingsGroup);
196   const bool pretty = s.value("pretty_covers", true).toBool();
197   front_model_->set_use_pretty_covers(pretty);
198   back_model_->set_use_pretty_covers(pretty);
199   s.endGroup();
200 
201   // Global search settings
202   s.beginGroup(GlobalSearch::kSettingsGroup);
203   const QStringList provider_order =
204       s.value("provider_order", QStringList() << "library").toStringList();
205   front_model_->set_provider_order(provider_order);
206   back_model_->set_provider_order(provider_order);
207   show_providers_ = s.value("show_providers", true).toBool();
208   show_suggestions_ = s.value("show_suggestions", true).toBool();
209   SetGroupBy(LibraryModel::Grouping(
210       LibraryModel::GroupBy(
211           s.value("group_by1", int(LibraryModel::GroupBy_Artist)).toInt()),
212       LibraryModel::GroupBy(
213           s.value("group_by2", int(LibraryModel::GroupBy_Album)).toInt()),
214       LibraryModel::GroupBy(
215           s.value("group_by3", int(LibraryModel::GroupBy_None)).toInt())));
216   s.endGroup();
217 
218   // Delete any old status widgets
219   qDeleteAll(provider_status_widgets_);
220   provider_status_widgets_.clear();
221 
222   // Toggle visibility of the providers group
223   ui_->providers_group->setVisible(show_providers_);
224 
225   if (show_providers_) {
226     // Sort the list of providers
227     QList<SearchProvider*> providers = engine_->providers();
228     std::sort(providers.begin(), providers.end(),
229               std::bind(&CompareProvider, std::cref(provider_order), _1, _2));
230 
231     bool any_disabled = false;
232 
233     for (SearchProvider* provider : providers) {
234       QWidget* parent = ui_->enabled_list;
235       if (!engine_->is_provider_usable(provider)) {
236         parent = ui_->disabled_list;
237         any_disabled = true;
238       }
239 
240       SearchProviderStatusWidget* widget =
241           new SearchProviderStatusWidget(warning_icon_, engine_, provider);
242 
243       parent->layout()->addWidget(widget);
244       provider_status_widgets_ << widget;
245     }
246 
247     ui_->disabled_label->setVisible(any_disabled);
248   }
249 
250   ui_->suggestions_group->setVisible(show_suggestions_);
251   if (!show_suggestions_) {
252     update_suggestions_timer_->stop();
253   }
254 
255   if (!old_show_suggestions && show_suggestions_) {
256     UpdateSuggestions();
257   }
258 }
259 
UpdateSuggestions()260 void GlobalSearchView::UpdateSuggestions() {
261   const QStringList suggestions = engine_->GetSuggestions(kMaxSuggestions);
262 
263   for (int i = 0; i < suggestions.count(); ++i) {
264     suggestion_widgets_[i]->SetText(suggestions[i]);
265     suggestion_widgets_[i]->show();
266   }
267 
268   for (int i = suggestions.count(); i < kMaxSuggestions; ++i) {
269     suggestion_widgets_[i]->hide();
270   }
271 }
272 
StartSearch(const QString & query)273 void GlobalSearchView::StartSearch(const QString& query) {
274   ui_->search->setText(query);
275   TextEdited(query);
276 
277   // Swap models immediately
278   swap_models_timer_->stop();
279   SwapModels();
280 }
281 
TextEdited(const QString & text)282 void GlobalSearchView::TextEdited(const QString& text) {
283   const QString trimmed(text.trimmed());
284 
285   // Add results to the back model, switch models after some delay.
286   back_model_->Clear();
287   current_model_ = back_model_;
288   current_proxy_ = back_proxy_;
289   swap_models_timer_->start();
290 
291   // Cancel the last search (if any) and start the new one.
292   engine_->CancelSearch(last_search_id_);
293   // If text query is empty, don't start a new search
294   if (trimmed.isEmpty()) {
295     last_search_id_ = -1;
296   } else {
297     last_search_id_ = engine_->SearchAsync(trimmed);
298   }
299 }
300 
AddResults(int id,const SearchProvider::ResultList & results)301 void GlobalSearchView::AddResults(int id,
302                                   const SearchProvider::ResultList& results) {
303   if (id != last_search_id_ || results.isEmpty()) return;
304 
305   current_model_->AddResults(results);
306 }
307 
SwapModels()308 void GlobalSearchView::SwapModels() {
309   art_requests_.clear();
310 
311   std::swap(front_model_, back_model_);
312   std::swap(front_proxy_, back_proxy_);
313 
314   ui_->results->setModel(front_proxy_);
315 
316   if (ui_->search->text().trimmed().isEmpty()) {
317     ui_->results_stack->setCurrentWidget(ui_->help_page);
318   } else {
319     ui_->results_stack->setCurrentWidget(ui_->results_page);
320   }
321 }
322 
LazyLoadArt(const QModelIndex & proxy_index)323 void GlobalSearchView::LazyLoadArt(const QModelIndex& proxy_index) {
324   if (!proxy_index.isValid() || proxy_index.model() != front_proxy_) {
325     return;
326   }
327 
328   // Already loading art for this item?
329   if (proxy_index.data(GlobalSearchModel::Role_LazyLoadingArt).isValid()) {
330     return;
331   }
332 
333   // Should we even load art at all?
334   if (!app_->library_model()->use_pretty_covers()) {
335     return;
336   }
337 
338   // Is this an album?
339   const LibraryModel::GroupBy container_type = LibraryModel::GroupBy(
340       proxy_index.data(LibraryModel::Role_ContainerType).toInt());
341   if (container_type != LibraryModel::GroupBy_Album &&
342       container_type != LibraryModel::GroupBy_AlbumArtist &&
343       container_type != LibraryModel::GroupBy_YearAlbum &&
344       container_type != LibraryModel::GroupBy_OriginalYearAlbum) {
345     return;
346   }
347 
348   // Mark the item as loading art
349   const QModelIndex source_index = front_proxy_->mapToSource(proxy_index);
350   QStandardItem* item = front_model_->itemFromIndex(source_index);
351   item->setData(true, GlobalSearchModel::Role_LazyLoadingArt);
352 
353   // Walk down the item's children until we find a track
354   while (item->rowCount()) {
355     item = item->child(0);
356   }
357 
358   // Get the track's Result
359   const SearchProvider::Result result =
360       item->data(GlobalSearchModel::Role_Result)
361           .value<SearchProvider::Result>();
362 
363   // Load the art.
364   int id = engine_->LoadArtAsync(result);
365   art_requests_[id] = source_index;
366 }
367 
ArtLoaded(int id,const QPixmap & pixmap)368 void GlobalSearchView::ArtLoaded(int id, const QPixmap& pixmap) {
369   if (!art_requests_.contains(id)) return;
370   QModelIndex index = art_requests_.take(id);
371 
372   if (!pixmap.isNull()) {
373     front_model_->itemFromIndex(index)->setData(pixmap, Qt::DecorationRole);
374   }
375 }
376 
SelectedMimeData()377 MimeData* GlobalSearchView::SelectedMimeData() {
378   if (!ui_->results->selectionModel()) return nullptr;
379 
380   // Get all selected model indexes
381   QModelIndexList indexes = ui_->results->selectionModel()->selectedRows();
382   if (indexes.isEmpty()) {
383     // There's nothing selected - take the first thing in the model that isn't
384     // a divider.
385     for (int i = 0; i < front_proxy_->rowCount(); ++i) {
386       QModelIndex index = front_proxy_->index(i, 0);
387       if (!index.data(LibraryModel::Role_IsDivider).toBool()) {
388         indexes << index;
389         ui_->results->setCurrentIndex(index);
390         break;
391       }
392     }
393   }
394 
395   // Still got nothing?  Give up.
396   if (indexes.isEmpty()) {
397     return nullptr;
398   }
399 
400   // Get items for these indexes
401   QList<QStandardItem*> items;
402   for (const QModelIndex& index : indexes) {
403     items << (front_model_->itemFromIndex(front_proxy_->mapToSource(index)));
404   }
405 
406   // Get a MimeData for these items
407   return engine_->LoadTracks(front_model_->GetChildResults(items));
408 }
409 
eventFilter(QObject * object,QEvent * event)410 bool GlobalSearchView::eventFilter(QObject* object, QEvent* event) {
411   if (object == ui_->search && event->type() == QEvent::KeyRelease) {
412     if (SearchKeyEvent(static_cast<QKeyEvent*>(event))) {
413       return true;
414     }
415   } else if (object == ui_->results_stack &&
416              event->type() == QEvent::ContextMenu) {
417     if (ResultsContextMenuEvent(static_cast<QContextMenuEvent*>(event))) {
418       return true;
419     }
420   }
421 
422   return QWidget::eventFilter(object, event);
423 }
424 
SearchKeyEvent(QKeyEvent * event)425 bool GlobalSearchView::SearchKeyEvent(QKeyEvent* event) {
426   switch (event->key()) {
427     case Qt::Key_Up:
428       ui_->results->UpAndFocus();
429       break;
430 
431     case Qt::Key_Down:
432       ui_->results->DownAndFocus();
433       break;
434 
435     case Qt::Key_Escape:
436       ui_->search->clear();
437       break;
438 
439     case Qt::Key_Return:
440       AddSelectedToPlaylist();
441       break;
442 
443     default:
444       return false;
445   }
446 
447   event->accept();
448   return true;
449 }
450 
ResultsContextMenuEvent(QContextMenuEvent * event)451 bool GlobalSearchView::ResultsContextMenuEvent(QContextMenuEvent* event) {
452   context_menu_ = new QMenu(this);
453   context_actions_ << context_menu_->addAction(
454       IconLoader::Load("media-playback-start", IconLoader::Base),
455       tr("Append to current playlist"), this, SLOT(AddSelectedToPlaylist()));
456   context_actions_ << context_menu_->addAction(
457       IconLoader::Load("media-playback-start", IconLoader::Base),
458       tr("Replace current playlist"), this, SLOT(LoadSelected()));
459   context_actions_ << context_menu_->addAction(
460       IconLoader::Load("document-new", IconLoader::Base),
461       tr("Open in new playlist"), this, SLOT(OpenSelectedInNewPlaylist()));
462 
463   context_menu_->addSeparator();
464   context_actions_ << context_menu_->addAction(
465       IconLoader::Load("go-next", IconLoader::Base), tr("Queue track"), this,
466       SLOT(AddSelectedToPlaylistEnqueue()));
467 
468   context_menu_->addSeparator();
469 
470   if (ui_->results->selectionModel() &&
471       ui_->results->selectionModel()->selectedRows().length() == 1) {
472     context_actions_ << context_menu_->addAction(
473         IconLoader::Load("system-search", IconLoader::Base),
474         tr("Search for this"), this, SLOT(SearchForThis()));
475   }
476 
477   context_menu_->addSeparator();
478   context_menu_->addMenu(tr("Group by"))
479       ->addActions(group_by_actions_->actions());
480   context_menu_->addAction(IconLoader::Load("configure", IconLoader::Base),
481                            tr("Configure global search..."), this,
482                            SLOT(OpenSettingsDialog()));
483 
484   const bool enable_context_actions =
485       ui_->results->selectionModel() &&
486       ui_->results->selectionModel()->hasSelection();
487 
488   for (QAction* action : context_actions_) {
489     action->setEnabled(enable_context_actions);
490   }
491 
492   context_menu_->popup(event->globalPos());
493 
494   return true;
495 }
496 
AddSelectedToPlaylist()497 void GlobalSearchView::AddSelectedToPlaylist() {
498   emit AddToPlaylist(SelectedMimeData());
499 }
500 
LoadSelected()501 void GlobalSearchView::LoadSelected() {
502   MimeData* data = SelectedMimeData();
503   if (!data) return;
504 
505   data->clear_first_ = true;
506   emit AddToPlaylist(data);
507 }
508 
AddSelectedToPlaylistEnqueue()509 void GlobalSearchView::AddSelectedToPlaylistEnqueue() {
510   MimeData* data = SelectedMimeData();
511   if (!data) return;
512 
513   data->enqueue_now_ = true;
514   emit AddToPlaylist(data);
515 }
516 
OpenSelectedInNewPlaylist()517 void GlobalSearchView::OpenSelectedInNewPlaylist() {
518   MimeData* data = SelectedMimeData();
519   if (!data) return;
520 
521   data->open_in_new_playlist_ = true;
522   emit AddToPlaylist(data);
523 }
524 
SearchForThis()525 void GlobalSearchView::SearchForThis() {
526   StartSearch(
527       ui_->results->selectionModel()->selectedRows().first().data().toString());
528 }
529 
showEvent(QShowEvent * e)530 void GlobalSearchView::showEvent(QShowEvent* e) {
531   if (show_suggestions_) {
532     UpdateSuggestions();
533     update_suggestions_timer_->start();
534   }
535   QWidget::showEvent(e);
536 
537   FocusSearchField();
538 }
539 
FocusSearchField()540 void GlobalSearchView::FocusSearchField() {
541   ui_->search->setFocus();
542   ui_->search->selectAll();
543 }
544 
hideEvent(QHideEvent * e)545 void GlobalSearchView::hideEvent(QHideEvent* e) {
546   update_suggestions_timer_->stop();
547   QWidget::hideEvent(e);
548 }
549 
FocusOnFilter(QKeyEvent * event)550 void GlobalSearchView::FocusOnFilter(QKeyEvent* event) {
551   ui_->search->setFocus();
552   QApplication::sendEvent(ui_->search, event);
553 }
554 
OpenSettingsDialog()555 void GlobalSearchView::OpenSettingsDialog() {
556   app_->OpenSettingsDialogAtPage(SettingsDialog::Page_GlobalSearch);
557 }
558 
GroupByClicked(QAction * action)559 void GlobalSearchView::GroupByClicked(QAction* action) {
560   if (action->property("group_by").isNull()) {
561     if (!group_by_dialog_) {
562       group_by_dialog_.reset(new GroupByDialog);
563       connect(group_by_dialog_.data(), SIGNAL(Accepted(LibraryModel::Grouping)),
564               SLOT(SetGroupBy(LibraryModel::Grouping)));
565     }
566 
567     group_by_dialog_->show();
568     return;
569   }
570 
571   SetGroupBy(action->property("group_by").value<LibraryModel::Grouping>());
572 }
573 
SetGroupBy(const LibraryModel::Grouping & g)574 void GlobalSearchView::SetGroupBy(const LibraryModel::Grouping& g) {
575   // Clear requests: changing "group by" on the models will cause all the items
576   // to be removed/added
577   // again, so all the QModelIndex here will become invalid. New requests will
578   // be created for those
579   // songs when they will be displayed again anyway (when
580   // GlobalSearchItemDelegate::paint will call
581   // LazyLoadArt)
582   art_requests_.clear();
583   // Update the models
584   front_model_->SetGroupBy(g, true);
585   back_model_->SetGroupBy(g, false);
586 
587   // Save the setting
588   QSettings s;
589   s.beginGroup(GlobalSearch::kSettingsGroup);
590   s.setValue("group_by1", int(g.first));
591   s.setValue("group_by2", int(g.second));
592   s.setValue("group_by3", int(g.third));
593 
594   // Make sure the correct action is checked.
595   for (QAction* action : group_by_actions_->actions()) {
596     if (action->property("group_by").isNull()) continue;
597 
598     if (g == action->property("group_by").value<LibraryModel::Grouping>()) {
599       action->setChecked(true);
600       return;
601     }
602   }
603 
604   // Check the advanced action
605   group_by_actions_->actions().last()->setChecked(true);
606 }
607