From 820124f9e1c1d26931a33bc3c584626394ac1334 Mon Sep 17 00:00:00 2001 From: Jonas Kvinge Date: Thu, 9 Aug 2018 18:10:03 +0200 Subject: [PATCH] Add tidal support --- data/data.qrc | 6 + data/icons/128x128/tidal.png | Bin 0 -> 5892 bytes data/icons/22x22/tidal.png | Bin 0 -> 933 bytes data/icons/32x32/tidal.png | Bin 0 -> 1433 bytes data/icons/48x48/tidal.png | Bin 0 -> 2136 bytes data/icons/64x64/tidal.png | Bin 0 -> 2905 bytes data/icons/full/tidal.png | Bin 0 -> 7322 bytes data/schema/schema-1.sql | 3 + data/schema/schema.sql | 39 +- src/CMakeLists.txt | 34 +- src/collection/collectionbackend.cpp | 2 +- src/core/application.cpp | 20 +- src/core/application.h | 7 +- src/core/database.cpp | 2 +- src/core/mainwindow.cpp | 54 +- src/core/mainwindow.h | 11 + src/core/metatypes.cpp | 5 + src/core/mimedata.h | 5 +- src/core/mpris2.cpp | 4 +- src/core/network.cpp | 2 +- src/core/song.cpp | 79 +- src/core/song.h | 31 +- src/core/songloader.cpp | 43 +- src/core/songloader.h | 4 +- src/device/cddasongloader.cpp | 6 +- src/device/gpoddevice.cpp | 6 +- src/device/mtpdevice.cpp | 12 +- src/engine/gstengine.cpp | 1 - src/engine/vlcengine.cpp | 2 +- src/internet/internetmimedata.h | 43 ++ src/internet/internetmodel.cpp | 83 ++ src/internet/internetmodel.h | 132 ++++ src/internet/internetplaylistitem.cpp | 106 +++ src/internet/internetplaylistitem.h | 58 ++ src/internet/internetservice.cpp | 32 + src/internet/internetservice.h | 64 ++ src/internet/internetsongmimedata.h | 39 + src/playlist/playlist.cpp | 81 +- src/playlist/playlist.h | 19 +- src/playlist/playlistbackend.cpp | 6 +- src/playlist/playlistdelegates.cpp | 9 +- src/playlist/playlistfilterparser.cpp | 5 +- src/playlist/playlistitem.cpp | 12 +- src/playlist/playlistitem.h | 5 +- src/playlist/playlistview.cpp | 13 +- src/playlist/playlistview.h | 4 +- src/settings/playlistsettingspage.cpp | 2 +- src/settings/settingsdialog.cpp | 5 +- src/settings/settingsdialog.h | 3 +- src/settings/tidalsettingspage.cpp | 118 +++ src/settings/tidalsettingspage.h | 62 ++ src/settings/tidalsettingspage.ui | 157 ++++ src/tidal/tidalsearch.cpp | 316 ++++++++ src/tidal/tidalsearch.h | 157 ++++ src/tidal/tidalsearchitemdelegate.cpp | 35 + src/tidal/tidalsearchitemdelegate.h | 41 + src/tidal/tidalsearchmodel.cpp | 314 ++++++++ src/tidal/tidalsearchmodel.h | 109 +++ src/tidal/tidalsearchsortmodel.cpp | 79 ++ src/tidal/tidalsearchsortmodel.h | 35 + src/tidal/tidalsearchview.cpp | 544 ++++++++++++++ src/tidal/tidalsearchview.h | 139 ++++ src/tidal/tidalsearchview.ui | 259 +++++++ src/tidal/tidalservice.cpp | 832 +++++++++++++++++++++ src/tidal/tidalservice.cpp.bak | 745 ++++++++++++++++++ src/tidal/tidalservice.h | 134 ++++ src/transcoder/transcoder.cpp | 26 +- src/transcoder/transcoderoptionsdialog.cpp | 10 +- src/widgets/fileviewlist.cpp | 12 +- src/widgets/loginstatewidget.cpp | 148 ++++ src/widgets/loginstatewidget.h | 80 ++ src/widgets/loginstatewidget.ui | 182 +++++ src/widgets/statusview.cpp | 56 +- src/widgets/statusview.h | 4 +- 74 files changed, 5420 insertions(+), 273 deletions(-) create mode 100644 data/icons/128x128/tidal.png create mode 100644 data/icons/22x22/tidal.png create mode 100644 data/icons/32x32/tidal.png create mode 100644 data/icons/48x48/tidal.png create mode 100644 data/icons/64x64/tidal.png create mode 100644 data/icons/full/tidal.png create mode 100644 data/schema/schema-1.sql create mode 100644 src/internet/internetmimedata.h create mode 100644 src/internet/internetmodel.cpp create mode 100644 src/internet/internetmodel.h create mode 100644 src/internet/internetplaylistitem.cpp create mode 100644 src/internet/internetplaylistitem.h create mode 100644 src/internet/internetservice.cpp create mode 100644 src/internet/internetservice.h create mode 100644 src/internet/internetsongmimedata.h create mode 100644 src/settings/tidalsettingspage.cpp create mode 100644 src/settings/tidalsettingspage.h create mode 100644 src/settings/tidalsettingspage.ui create mode 100644 src/tidal/tidalsearch.cpp create mode 100644 src/tidal/tidalsearch.h create mode 100644 src/tidal/tidalsearchitemdelegate.cpp create mode 100644 src/tidal/tidalsearchitemdelegate.h create mode 100644 src/tidal/tidalsearchmodel.cpp create mode 100644 src/tidal/tidalsearchmodel.h create mode 100644 src/tidal/tidalsearchsortmodel.cpp create mode 100644 src/tidal/tidalsearchsortmodel.h create mode 100644 src/tidal/tidalsearchview.cpp create mode 100644 src/tidal/tidalsearchview.h create mode 100644 src/tidal/tidalsearchview.ui create mode 100644 src/tidal/tidalservice.cpp create mode 100644 src/tidal/tidalservice.cpp.bak create mode 100644 src/tidal/tidalservice.h create mode 100644 src/widgets/loginstatewidget.cpp create mode 100644 src/widgets/loginstatewidget.h create mode 100644 src/widgets/loginstatewidget.ui diff --git a/data/data.qrc b/data/data.qrc index 386868118..e5780faa4 100644 --- a/data/data.qrc +++ b/data/data.qrc @@ -1,6 +1,7 @@ schema/schema.sql + schema/schema-1.sql schema/device-schema.sql style/mainwindow.css style/statusview.css @@ -113,6 +114,7 @@ icons/128x128/xine.png icons/128x128/zoom-in.png icons/128x128/zoom-out.png + icons/128x128/tidal.png icons/64x64/albums.png icons/64x64/alsa.png icons/64x64/application-exit.png @@ -201,6 +203,7 @@ icons/64x64/xine.png icons/64x64/zoom-in.png icons/64x64/zoom-out.png + icons/64x64/tidal.png icons/48x48/albums.png icons/48x48/alsa.png icons/48x48/application-exit.png @@ -292,6 +295,7 @@ icons/48x48/xine.png icons/48x48/zoom-in.png icons/48x48/zoom-out.png + icons/48x48/tidal.png icons/32x32/albums.png icons/32x32/alsa.png icons/32x32/application-exit.png @@ -384,6 +388,7 @@ icons/32x32/xine.png icons/32x32/zoom-in.png icons/32x32/zoom-out.png + icons/32x32/tidal.png icons/22x22/albums.png icons/22x22/alsa.png icons/22x22/application-exit.png @@ -476,5 +481,6 @@ icons/22x22/xine.png icons/22x22/zoom-in.png icons/22x22/zoom-out.png + icons/22x22/tidal.png diff --git a/data/icons/128x128/tidal.png b/data/icons/128x128/tidal.png new file mode 100644 index 0000000000000000000000000000000000000000..5b7e2f11e143be11e9bdada4a687b1b9adfadd5d GIT binary patch literal 5892 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 WHuG>8GXHNi4Uh*b%2r7m1^f@tF>E0K literal 0 HcmV?d00001 diff --git a/data/icons/22x22/tidal.png b/data/icons/22x22/tidal.png new file mode 100644 index 0000000000000000000000000000000000000000..49610554112233a53feca55142bb787d8d0e7e9c GIT binary patch literal 933 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 1; +CREATE VIEW IF NOT EXISTS duplicated_songs as select artist dup_artist, album dup_album, title dup_title from songs as inner_songs where artist != '' and album != '' and title != '' and unavailable = 0 group by artist, album , title having count(*) > 1; -CREATE VIRTUAL TABLE songs_fts USING fts3( +CREATE VIRTUAL TABLE IF NOT EXISTS songs_fts USING fts3( ftstitle, ftsalbum, @@ -180,7 +181,7 @@ CREATE VIRTUAL TABLE songs_fts USING fts3( ); -CREATE VIRTUAL TABLE playlist_items_fts_ USING fts3( +CREATE VIRTUAL TABLE IF NOT EXISTS playlist_items_fts_ USING fts3( ftstitle, ftsalbum, @@ -195,7 +196,7 @@ CREATE VIRTUAL TABLE playlist_items_fts_ USING fts3( ); -CREATE VIRTUAL TABLE %allsongstables_fts USING fts3( +CREATE VIRTUAL TABLE IF NOT EXISTS %allsongstables_fts USING fts3( ftstitle, ftsalbum, @@ -211,7 +212,7 @@ CREATE VIRTUAL TABLE %allsongstables_fts USING fts3( ); -INSERT INTO songs_fts (ROWID, ftstitle, ftsalbum, ftsartist, ftsalbumartist, ftscomposer, ftsperformer, ftsgrouping, ftsgenre, ftscomment) +INSERT INTO songs_fts (ROWID, ftstitle, ftsalbum, ftsartist, ftsalbumartist, ftscomposer, ftsperformer, ftsgrouping, ftsgenre, ftscomment) SELECT ROWID, title, album, artist, albumartist, composer, performer, grouping, genre, comment FROM songs; INSERT INTO %allsongstables_fts (ROWID, ftstitle, ftsalbum, ftsartist, ftsalbumartist, ftscomposer, ftsperformer, ftsgrouping, ftsgenre, ftscomment) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c1f01a79a..e5efec282 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -207,6 +207,7 @@ set(SOURCES settings/shortcutssettingspage.cpp settings/appearancesettingspage.cpp settings/notificationssettingspage.cpp + settings/tidalsettingspage.cpp dialogs/about.cpp dialogs/console.cpp @@ -246,6 +247,7 @@ set(SOURCES widgets/tracksliderpopup.cpp widgets/tracksliderslider.cpp widgets/widgetfadehelper.cpp + widgets/loginstatewidget.cpp musicbrainz/acoustidclient.cpp musicbrainz/musicbrainzclient.cpp @@ -266,6 +268,16 @@ set(SOURCES device/deviceviewcontainer.cpp device/filesystemdevice.cpp + internet/internetmodel.cpp + internet/internetservice.cpp + internet/internetplaylistitem.cpp + tidal/tidalservice.cpp + tidal/tidalsearch.cpp + tidal/tidalsearchview.cpp + tidal/tidalsearchmodel.cpp + tidal/tidalsearchsortmodel.cpp + tidal/tidalsearchitemdelegate.cpp + ) set(HEADERS @@ -356,7 +368,7 @@ set(HEADERS covermanager/amazoncoverprovider.h covermanager/musicbrainzcoverprovider.h covermanager/discogscoverprovider.h - + settings/settingsdialog.h settings/settingspage.h settings/behavioursettingspage.h @@ -368,7 +380,8 @@ set(HEADERS settings/shortcutssettingspage.h settings/appearancesettingspage.h settings/notificationssettingspage.h - + settings/tidalsettingspage.h + dialogs/about.h dialogs/errordialog.h dialogs/console.h @@ -405,6 +418,7 @@ set(HEADERS widgets/tracksliderpopup.h widgets/tracksliderslider.h widgets/widgetfadehelper.h + widgets/loginstatewidget.h musicbrainz/acoustidclient.h musicbrainz/musicbrainzclient.h @@ -424,6 +438,16 @@ set(HEADERS device/deviceview.h device/filesystemdevice.h + internet/internetmodel.h + internet/internetservice.h + internet/internetmimedata.h + internet/internetsongmimedata.h + + tidal/tidalservice.h + tidal/tidalsearch.h + tidal/tidalsearchview.h + tidal/tidalsearchmodel.h + ) set(UI @@ -457,6 +481,7 @@ set(UI settings/shortcutssettingspage.ui settings/appearancesettingspage.ui settings/notificationssettingspage.ui + settings/tidalsettingspage.ui equalizer/equalizer.ui equalizer/equalizerslider.ui @@ -470,12 +495,15 @@ set(UI widgets/trackslider.ui widgets/osdpretty.ui widgets/fileview.ui - + widgets/loginstatewidget.ui + device/deviceproperties.ui device/deviceviewcontainer.ui globalshortcuts/globalshortcutgrabber.ui + tidal/tidalsearchview.ui + ) set(RESOURCES ../data/data.qrc) diff --git a/src/collection/collectionbackend.cpp b/src/collection/collectionbackend.cpp index fffa49f0a..f22afd3d8 100644 --- a/src/collection/collectionbackend.cpp +++ b/src/collection/collectionbackend.cpp @@ -789,7 +789,7 @@ void CollectionBackend::UpdateCompilations() { info.artists.insert(artist); info.directories.insert(filename.left(last_separator)); if (compilation_detected) info.has_compilation_detected = true; - else info.has_not_compilation_detected = true; + else info.has_not_compilation_detected = true; } // Now mark the songs that we think are in compilations diff --git a/src/core/application.cpp b/src/core/application.cpp index 529283a6c..801bf8774 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #include "config.h" @@ -52,6 +52,9 @@ #include "covermanager/discogscoverprovider.h" #include "covermanager/musicbrainzcoverprovider.h" +#include "internet/internetmodel.h" +#include "tidal/tidalsearch.h" + bool Application::kIsPortable = false; class ApplicationImpl { @@ -97,7 +100,9 @@ class ApplicationImpl { app->MoveToNewThread(loader); return loader; }), - current_art_loader_([=]() { return new CurrentArtLoader(app, app); }) + current_art_loader_([=]() { return new CurrentArtLoader(app, app); }), + internet_model_([=]() { return new InternetModel(app, app); }), + tidal_search_([=]() { return new TidalSearch(app, app); }) { } Lazy tag_reader_client_; @@ -113,6 +118,8 @@ class ApplicationImpl { Lazy cover_providers_; Lazy album_cover_loader_; Lazy current_art_loader_; + Lazy internet_model_; + Lazy tidal_search_; }; @@ -210,6 +217,13 @@ TaskManager *Application::task_manager() const { } EngineDevice *Application::enginedevice() const { - //qLog(Debug) << __PRETTY_FUNCTION__; return p_->enginedevice_.get(); } + +InternetModel* Application::internet_model() const { + return p_->internet_model_.get(); +} + +TidalSearch* Application::tidal_search() const { + return p_->tidal_search_.get(); +} diff --git a/src/core/application.h b/src/core/application.h index 322b8dac2..7ad8cc684 100644 --- a/src/core/application.h +++ b/src/core/application.h @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #ifndef APPLICATION_H_ @@ -49,6 +49,8 @@ class DeviceManager; class CoverProviders; class AlbumCoverLoader; class CurrentArtLoader; +class InternetModel; +class TidalSearch; class Application : public QObject { Q_OBJECT @@ -79,6 +81,9 @@ class Application : public QObject { CollectionBackend *collection_backend() const; CollectionModel *collection_model() const; + InternetModel *internet_model() const; + TidalSearch *tidal_search() const; + void MoveToNewThread(QObject *object); void MoveToThread(QObject *object, QThread *thread); diff --git a/src/core/database.cpp b/src/core/database.cpp index d50ba1921..ae7c1c640 100644 --- a/src/core/database.cpp +++ b/src/core/database.cpp @@ -52,7 +52,7 @@ #include "scopedtransaction.h" const char *Database::kDatabaseFilename = "strawberry.db"; -const int Database::kSchemaVersion = 0; +const int Database::kSchemaVersion = 1; const char *Database::kMagicAllSongsTables = "%allsongstables"; int Database::sNextConnectionId = 1; diff --git a/src/core/mainwindow.cpp b/src/core/mainwindow.cpp index 22a345d4f..c43e69e6b 100644 --- a/src/core/mainwindow.cpp +++ b/src/core/mainwindow.cpp @@ -126,6 +126,8 @@ #include "settings/playlistsettingspage.h" #include "settings/settingsdialog.h" +#include "tidal/tidalsearchview.h" + #if defined(HAVE_GSTREAMER) && defined(HAVE_CHROMAPRINT) # include "musicbrainz/tagfetcher.h" #endif @@ -186,6 +188,7 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co manager->SetPlaylistManager(app->playlist_manager()); return manager; }), + tidal_search_view_(new TidalSearchView(app_, this)), playlist_menu_(new QMenu(this)), playlist_add_to_another_(nullptr), playlistitem_actions_separator_(nullptr), @@ -218,7 +221,7 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co ui_->volume->setValue(volume); VolumeChanged(volume); - // Initialise the global search widget + // Initialise the tidal search widget StyleHelper::setBaseColor(palette().color(QPalette::Highlight).darker()); // Add tabs to the fancy tab widget @@ -227,6 +230,7 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co ui_->tabs->addTab(file_view_, IconLoader::Load("document-open"), tr("Files")); ui_->tabs->addTab(playlist_list_, IconLoader::Load("view-media-playlist"), tr("Playlists")); ui_->tabs->addTab(device_view_, IconLoader::Load("device"), tr("Devices")); + ui_->tabs->addTab(tidal_search_view_, IconLoader::Load("tidal"), tr("Tidal", "Tidal")); //ui_->tabs->AddSpacer(); // Add the now playing widget to the fancy tab widget @@ -475,6 +479,9 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co collection_view_->filter()->AddMenuAction(separator); collection_view_->filter()->AddMenuAction(collection_config_action); + // Tidal + connect(tidal_search_view_, SIGNAL(AddToPlaylist(QMimeData*)), SLOT(AddToPlaylist(QMimeData*))); + // Playlist menu playlist_play_pause_ = playlist_menu_->addAction(tr("Play"), this, SLOT(PlaylistPlay())); playlist_menu_->addAction(ui_->action_stop); @@ -657,6 +664,12 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co ReloadSettings(); + // Tidal search shortcut + QAction *tidal_search_action = new QAction(this); + tidal_search_action->setShortcuts(QList() << QKeySequence("Ctrl+F") << QKeySequence("Ctrl+L")); + addAction(tidal_search_action); + connect(tidal_search_action, SIGNAL(triggered()), SLOT(FocusTidalSearchField())); + // Reload pretty OSD to avoid issues with fonts osd_->ReloadPrettyOSDSettings(); @@ -745,6 +758,7 @@ void MainWindow::ReloadAllSettings() { osd_->ReloadSettings(); collection_view_->ReloadSettings(); ui_->playlist->view()->ReloadSettings(); + tidal_search_view_->ReloadSettings(); } @@ -787,7 +801,7 @@ void MainWindow::MediaPaused() { } void MainWindow::MediaPlaying() { - + ui_->action_stop->setEnabled(true); ui_->action_stop_after_this_track->setEnabled(true); ui_->action_play_pause->setIcon(IconLoader::Load("media-pause")); @@ -1789,7 +1803,7 @@ void MainWindow::EditFileTags(const QList &urls) { Song song; song.set_url(url); song.set_valid(true); - song.set_filetype(Song::Type_Mpeg); + song.set_filetype(Song::Type_MPEG); songs << song; } @@ -2261,3 +2275,37 @@ void MainWindow::keyPressEvent(QKeyEvent *event) { } } +void MainWindow::FocusTidalSearchField() { + ui_->tabs->setCurrentWidget(tidal_search_view_); + tidal_search_view_->FocusSearchField(); +} + +void MainWindow::DoTidalSearch(const QString& query) { + FocusTidalSearchField(); + tidal_search_view_->StartSearch(query); +} + +void MainWindow::SearchForArtist() { + + PlaylistItemPtr item(app_->playlist_manager()->current()->item_at(playlist_menu_index_.row())); + Song song = item->Metadata(); + if (!song.albumartist().isEmpty()) { + DoTidalSearch(song.albumartist().simplified()); + } + else if (!song.artist().isEmpty()) { + DoTidalSearch(song.artist().simplified()); + } + +} + +void MainWindow::SearchForAlbum() { + + PlaylistItemPtr item(app_->playlist_manager()->current()->item_at(playlist_menu_index_.row())); + Song song = item->Metadata(); + if (!song.album().isEmpty()) { + DoTidalSearch(song.album().simplified()); + } + +} + + diff --git a/src/core/mainwindow.h b/src/core/mainwindow.h index 2c6841129..6516a70a4 100644 --- a/src/core/mainwindow.h +++ b/src/core/mainwindow.h @@ -84,6 +84,7 @@ class TranscodeDialog; #endif class Ui_MainWindow; class Windows7ThumbBar; +class TidalSearchView; class MainWindow : public QMainWindow, public PlatformInterface { Q_OBJECT @@ -263,6 +264,11 @@ signals: void ShowConsole(); + void FocusTidalSearchField(); + void DoTidalSearch(const QString& query); + void SearchForArtist(); + void SearchForAlbum(); + private: void ConnectStatusView(StatusView *statusview); @@ -313,6 +319,8 @@ signals: PlaylistItemList autocomplete_tag_items_; #endif + TidalSearchView *tidal_search_view_; + QAction *collection_show_all_; QAction *collection_show_duplicates_; QAction *collection_show_untagged_; @@ -335,6 +343,9 @@ signals: QAction *playlist_add_to_another_; QList playlistitem_actions_; QAction *playlistitem_actions_separator_; + QAction *search_for_artist_; + QAction *search_for_album_; + QModelIndex playlist_menu_index_; QSortFilterProxyModel *collection_sort_model_; diff --git a/src/core/metatypes.cpp b/src/core/metatypes.cpp index 204864755..079a7aa99 100644 --- a/src/core/metatypes.cpp +++ b/src/core/metatypes.cpp @@ -60,6 +60,8 @@ # include "dbus/metatypes.h" #endif +#include "tidal/tidalsearch.h" + void RegisterMetaTypes() { qRegisterMetaType("const char*"); @@ -113,4 +115,7 @@ void RegisterMetaTypes() { #endif #endif + qRegisterMetaType("TidalSearch::ResultList"); + qRegisterMetaType("TidalSearch::Result"); + } diff --git a/src/core/mimedata.h b/src/core/mimedata.h index 0d22d77c5..bf1b75aa3 100644 --- a/src/core/mimedata.h +++ b/src/core/mimedata.h @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #ifndef MIMEDATA_H @@ -52,6 +52,9 @@ class MimeData : public QMimeData { // If this is set then the items are added to the queue after being inserted. bool enqueue_now_; + + // If this is set then the items are added to the beginning of the queue after being inserted. + bool enqueue_next_now_; // If this is set then the items are inserted into a newly created playlist. bool open_in_new_playlist_; diff --git a/src/core/mpris2.cpp b/src/core/mpris2.cpp index f26ef69b0..700f2b9ab 100644 --- a/src/core/mpris2.cpp +++ b/src/core/mpris2.cpp @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #include "config.h" @@ -393,7 +393,7 @@ bool Mpris2::CanPause() const { bool Mpris2::CanSeek() const { return CanSeek(app_->player()->GetState()); } bool Mpris2::CanSeek(Engine::State state) const { - return app_->player()->GetCurrentItem() && state != Engine::Empty; + return app_->player()->GetCurrentItem() && state != Engine::Empty && !app_->player()->GetCurrentItem()->Metadata().is_stream(); } bool Mpris2::CanControl() const { return true; } diff --git a/src/core/network.cpp b/src/core/network.cpp index 369bbbd87..650359a62 100644 --- a/src/core/network.cpp +++ b/src/core/network.cpp @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #include "config.h" diff --git a/src/core/song.cpp b/src/core/song.cpp index 2924e8705..3f6a89966 100644 --- a/src/core/song.cpp +++ b/src/core/song.cpp @@ -287,9 +287,10 @@ uint Song::mtime() const { return d->mtime_; } uint Song::ctime() const { return d->ctime_; } int Song::filesize() const { return d->filesize_; } Song::FileType Song::filetype() const { return d->filetype_; } -bool Song::is_cdda() const { return d->filetype_ == Type_Cdda; } +bool Song::is_stream() const { return d->filetype_ == Type_Stream; } +bool Song::is_cdda() const { return d->filetype_ == Type_CDDA; } bool Song::is_collection_song() const { - return !is_cdda() && id() != -1; + return !is_cdda() && !is_stream() && id() != -1; } const QString &Song::art_automatic() const { return d->art_automatic_; } const QString &Song::art_manual() const { return d->art_manual_; } @@ -329,10 +330,10 @@ void Song::set_bitdepth(int v) { d->bitdepth_ = v; } void Song::set_directory_id(int v) { d->directory_id_ = v; } void Song::set_url(const QUrl &v) { if (Application::kIsPortable) { - QUrl base = - QUrl::fromLocalFile(QCoreApplication::applicationDirPath() + "/"); + QUrl base = QUrl::fromLocalFile(QCoreApplication::applicationDirPath() + "/"); d->url_ = base.resolved(v); - } else { + } + else { d->url_ = v; } } @@ -364,36 +365,35 @@ QString Song::JoinSpec(const QString &table) { QString Song::TextForFiletype(FileType type) { switch (type) { - case Song::Type_Wav: return QObject::tr("Wav"); - case Song::Type_Flac: return QObject::tr("FLAC"); + case Song::Type_WAV: return QObject::tr("Wav"); + case Song::Type_FLAC: return QObject::tr("FLAC"); case Song::Type_WavPack: return QObject::tr("WavPack"); case Song::Type_OggFlac: return QObject::tr("Ogg FLAC"); case Song::Type_OggVorbis: return QObject::tr("Ogg Vorbis"); case Song::Type_OggOpus: return QObject::tr("Ogg Opus"); case Song::Type_OggSpeex: return QObject::tr("Ogg Speex"); - case Song::Type_Mpeg: return QObject::tr("MP3"); - case Song::Type_Mp4: return QObject::tr("MP4 AAC"); - case Song::Type_Asf: return QObject::tr("Windows Media audio"); - case Song::Type_Aiff: return QObject::tr("AIFF"); - case Song::Type_Mpc: return QObject::tr("MPC"); + case Song::Type_MPEG: return QObject::tr("MP3"); + case Song::Type_MP4: return QObject::tr("MP4 AAC"); + case Song::Type_ASF: return QObject::tr("Windows Media audio"); + case Song::Type_AIFF: return QObject::tr("AIFF"); + case Song::Type_MPC: return QObject::tr("MPC"); case Song::Type_TrueAudio: return QObject::tr("TrueAudio"); - case Song::Type_Cdda: return QObject::tr("CDDA"); - + case Song::Type_CDDA: return QObject::tr("CDDA"); case Song::Type_Unknown: default: return QObject::tr("Unknown"); - + } } bool Song::IsFileLossless() const { switch (filetype()) { - case Song::Type_Wav: - case Song::Type_Flac: + case Song::Type_WAV: + case Song::Type_FLAC: case Song::Type_OggFlac: case Song::Type_WavPack: - case Song::Type_Aiff: + case Song::Type_AIFF: return true; default: return false; @@ -628,7 +628,7 @@ void Song::InitFromQuery(const SqlRow &q, bool reliable_metadata, int col) { else if (Song::kColumns.value(i) == "unavailable") { d->unavailable_ = q.value(x).toBool(); } - + else if (Song::kColumns.value(i) == "playcount") { d->playcount_ = q.value(x).isNull() ? 0 : q.value(x).toInt(); } @@ -650,7 +650,7 @@ void Song::InitFromQuery(const SqlRow &q, bool reliable_metadata, int col) { } else if (Song::kColumns.value(i) == "compilation_effective") { } - + else if (Song::kColumns.value(i) == "art_automatic") { d->art_automatic_ = q.value(x).toString(); } @@ -662,11 +662,11 @@ void Song::InitFromQuery(const SqlRow &q, bool reliable_metadata, int col) { } else if (Song::kColumns.value(i) == "effective_originalyear") { } - + else if (Song::kColumns.value(i) == "cue_path") { d->cue_path_ = tostr(x); } - + else { qLog(Error) << "Forgot to handle" << Song::kColumns.value(i); } @@ -752,7 +752,7 @@ void Song::InitFromItdb(const Itdb_Track *track, const QString &prefix) { } d->basefilename_ = QFileInfo(filename).fileName(); - d->filetype_ = track->type2 ? Type_Mpeg : Type_Mp4; + d->filetype_ = track->type2 ? Type_MPEG : Type_MP4; d->filesize_ = track->size; d->mtime_ = track->time_modified; d->ctime_ = track->time_added; @@ -785,7 +785,7 @@ void Song::ToItdb(Itdb_Track *track) const { //track->bithdepth = d->bithdepth_; track->type1 = 0; - track->type2 = d->filetype_ == Type_Mp4 ? 0 : 1; + track->type2 = d->filetype_ == Type_MP4 ? 0 : 1; track->mediatype = 1; // Audio track->size = d->filesize_; track->time_modified = d->mtime_; @@ -825,15 +825,15 @@ void Song::InitFromMTP(const LIBMTP_track_t *track, const QString &host) { d->playcount_ = track->usecount; switch (track->filetype) { - case LIBMTP_FILETYPE_WAV: d->filetype_ = Type_Wav; break; - case LIBMTP_FILETYPE_MP3: d->filetype_ = Type_Mpeg; break; - case LIBMTP_FILETYPE_WMA: d->filetype_ = Type_Asf; break; + case LIBMTP_FILETYPE_WAV: d->filetype_ = Type_WAV; break; + case LIBMTP_FILETYPE_MP3: d->filetype_ = Type_MPEG; break; + case LIBMTP_FILETYPE_WMA: d->filetype_ = Type_ASF; break; case LIBMTP_FILETYPE_OGG: d->filetype_ = Type_OggVorbis; break; - case LIBMTP_FILETYPE_MP4: d->filetype_ = Type_Mp4; break; - case LIBMTP_FILETYPE_AAC: d->filetype_ = Type_Mp4; break; + case LIBMTP_FILETYPE_MP4: d->filetype_ = Type_MP4; break; + case LIBMTP_FILETYPE_AAC: d->filetype_ = Type_MP4; break; case LIBMTP_FILETYPE_FLAC: d->filetype_ = Type_OggFlac; break; - case LIBMTP_FILETYPE_MP2: d->filetype_ = Type_Mpeg; break; - case LIBMTP_FILETYPE_M4A: d->filetype_ = Type_Mp4; break; + case LIBMTP_FILETYPE_MP2: d->filetype_ = Type_MPEG; break; + case LIBMTP_FILETYPE_M4A: d->filetype_ = Type_MP4; break; default: d->filetype_ = Type_Unknown; break; } @@ -868,14 +868,14 @@ void Song::ToMTP(LIBMTP_track_t *track) const { track->usecount = d->playcount_; switch (d->filetype_) { - case Type_Asf: track->filetype = LIBMTP_FILETYPE_ASF; break; - case Type_Mp4: track->filetype = LIBMTP_FILETYPE_MP4; break; - case Type_Mpeg: track->filetype = LIBMTP_FILETYPE_MP3; break; - case Type_Flac: + case Type_ASF: track->filetype = LIBMTP_FILETYPE_ASF; break; + case Type_MP4: track->filetype = LIBMTP_FILETYPE_MP4; break; + case Type_MPEG: track->filetype = LIBMTP_FILETYPE_MP3; break; + case Type_FLAC: case Type_OggFlac: track->filetype = LIBMTP_FILETYPE_FLAC; break; case Type_OggSpeex: case Type_OggVorbis: track->filetype = LIBMTP_FILETYPE_OGG; break; - case Type_Wav: track->filetype = LIBMTP_FILETYPE_WAV; break; + case Type_WAV: track->filetype = LIBMTP_FILETYPE_WAV; break; default: track->filetype = LIBMTP_FILETYPE_UNDEF_AUDIO; break; } @@ -927,7 +927,7 @@ void Song::BindToQuery(QSqlQuery *query) const { query->bindValue(":performer", strval(d->performer_)); query->bindValue(":grouping", strval(d->grouping_)); query->bindValue(":comment", strval(d->comment_)); - + query->bindValue(":beginning", d->beginning_); query->bindValue(":length", intval(length_nanosec())); @@ -1037,7 +1037,8 @@ QString Song::TitleWithCompilationArtist() const { } QString Song::SampleRateBitDepthToText() const { - + + if (d->samplerate_ == -1) return QString(""); if (d->bitdepth_ == -1) return QString("%1 hz").arg(d->samplerate_); return QString("%1 hz / %2 bit").arg(d->samplerate_).arg(d->bitdepth_); @@ -1071,7 +1072,7 @@ bool Song::IsMetadataEqual(const Song &other) const { } bool Song::IsEditable() const { - return d->valid_ && !d->url_.isEmpty() && d->filetype_ != Type_Unknown && !has_cue(); + return d->valid_ && !d->url_.isEmpty() && !is_stream() && d->filetype_ != Type_Unknown && !has_cue(); } bool Song::operator==(const Song &other) const { diff --git a/src/core/song.h b/src/core/song.h index 74f836d39..6062aed80 100644 --- a/src/core/song.h +++ b/src/core/song.h @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #ifndef SONG_H @@ -58,12 +58,6 @@ struct _Itdb_Track; struct LIBMTP_track_struct; #endif -#ifdef HAVE_LIBLASTFM -namespace lastfm { -class Track; -} -#endif - class SqlRow; class Song { @@ -95,20 +89,20 @@ class Song { // If a new lossless file is added, also add it to IsFileLossless(). enum FileType { Type_Unknown = 0, - Type_Wav = 1, - Type_Flac = 2, + Type_WAV = 1, + Type_FLAC = 2, Type_WavPack = 3, Type_OggFlac = 4, Type_OggVorbis = 5, Type_OggOpus = 6, Type_OggSpeex = 7, - Type_Mpeg = 8, - Type_Mp4 = 9, - Type_Asf = 10, - Type_Aiff = 11, - Type_Mpc = 12, + Type_MPEG = 8, + Type_MP4 = 9, + Type_ASF = 10, + Type_AIFF = 11, + Type_MPC = 12, Type_TrueAudio = 13, - Type_Cdda = 90, + Type_CDDA = 90, Type_Stream = 91, }; @@ -127,9 +121,6 @@ class Song { void InitFromQuery(const SqlRow &query, bool reliable_metadata, int col = 0); void InitFromFilePartial(const QString &filename); // Just store the filename: incomplete but fast void InitArtManual(); // Check if there is already a art in the cache and store the filename in art_manual -#ifdef HAVE_LIBLASTFM - void InitFromLastFM(const lastfm::Track &track); -#endif void MergeFromSimpleMetaBundle(const Engine::SimpleMetaBundle &bundle); @@ -152,9 +143,6 @@ class Song { // Save void BindToQuery(QSqlQuery *query) const; void BindToFtsQuery(QSqlQuery *query) const; -#ifdef HAVE_LIBLASTFM - void ToLastFM(lastfm::Track *track, bool prefer_album_artist) const; -#endif void ToXesam(QVariantMap *map) const; void ToProtobuf(pb::tagreader::SongMetadata *pb) const; @@ -210,6 +198,7 @@ class Song { const QString &effective_albumartist() const; bool is_collection_song() const; + bool is_stream() const; bool is_cdda() const; // Playlist views are special because you don't want to fill in album artists automatically for compilations, but you do for normal albums: diff --git a/src/core/songloader.cpp b/src/core/songloader.cpp index e4e4d7e49..ad4b03efb 100644 --- a/src/core/songloader.cpp +++ b/src/core/songloader.cpp @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #include "config.h" @@ -51,6 +51,8 @@ #include "song.h" #include "songloader.h" #include "tagreaderclient.h" +#include "engine/enginetype.h" +#include "engine/enginebase.h" #include "collection/collectionbackend.h" #include "collection/collectionquery.h" #include "collection/sqlrow.h" @@ -78,6 +80,7 @@ SongLoader::SongLoader(CollectionBackendInterface *collection, const Player *pla parser_(nullptr), collection_(collection), player_(player) { + if (sRawUriSchemes.isEmpty()) { sRawUriSchemes << "udp" << "mms" @@ -97,7 +100,7 @@ SongLoader::SongLoader(CollectionBackendInterface *collection, const Player *pla } SongLoader::~SongLoader() { - + #ifdef HAVE_GSTREAMER if (pipeline_) { state_ = Finished; @@ -121,24 +124,29 @@ SongLoader::Result SongLoader::Load(const QUrl &url) { return Success; } + if (player_->engine()->type() == Engine::GStreamer) { #ifdef HAVE_GSTREAMER - preload_func_ = std::bind(&SongLoader::LoadRemote, this); + preload_func_ = std::bind(&SongLoader::LoadRemote, this); + return BlockingLoadRequired; +#else + return Error; #endif + } - return BlockingLoadRequired; + return Success; } void SongLoader::LoadFilenamesBlocking() { - + if (preload_func_) { preload_func_(); } - + } SongLoader::Result SongLoader::LoadLocalPartial(const QString &filename) { - + qLog(Debug) << "Fast Loading local file" << filename; // First check to see if it's a directory - if so we can load all the songs inside right away. if (QFileInfo(filename).isDir()) { @@ -149,7 +157,7 @@ SongLoader::Result SongLoader::LoadLocalPartial(const QString &filename) { song.InitFromFilePartial(filename); if (song.is_valid()) songs_ << song; return Success; - + } SongLoader::Result SongLoader::LoadAudioCD() { @@ -208,6 +216,7 @@ SongLoader::Result SongLoader::LoadLocal(const QString &filename) { // It's not in the database, load it asynchronously. preload_func_ = std::bind(&SongLoader::LoadLocalAsync, this, filename); return BlockingLoadRequired; + } void SongLoader::LoadLocalAsync(const QString &filename) { @@ -253,6 +262,7 @@ void SongLoader::LoadLocalAsync(const QString &filename) { Song song; song.InitFromFilePartial(filename); if (song.is_valid()) songs_ << song; + } void SongLoader::LoadMetadataBlocking() { @@ -274,7 +284,8 @@ void SongLoader::EffectiveSongLoad(Song *song) { Song collection_song = collection_->GetSongByUrl(song->url()); if (collection_song.is_valid()) { *song = collection_song; - } else { + } + else { // it's a normal media file QString filename = song->url().toLocalFile(); TagReaderClient::Instance()->ReadFileBlocking(filename, song); @@ -318,7 +329,15 @@ void SongLoader::LoadLocalDirectory(const QString &filename) { // so if the user has the "Start playing when adding to playlist" preference behaviour set, // it can enjoy the first song being played (seek it, have moodbar, etc.) if (!songs_.isEmpty()) EffectiveSongLoad(&(*songs_.begin())); +} +void SongLoader::AddAsRawStream() { + Song song; + song.set_valid(true); + song.set_filetype(Song::Type_Stream); + song.set_url(url_); + song.set_title(url_.toString()); + songs_ << song; } void SongLoader::Timeout() { @@ -348,10 +367,10 @@ void SongLoader::StopTypefind() { } else if (success_) { - //qLog(Debug) << "Loading" << url_ << "as raw stream"; + qLog(Debug) << "Loading" << url_ << "as raw stream"; // It wasn't a playlist - just put the URL in as a stream - //AddAsRawStream(); + AddAsRawStream(); } emit LoadRemoteFinished(); @@ -413,7 +432,7 @@ void SongLoader::LoadRemote() { #ifdef HAVE_GSTREAMER void SongLoader::TypeFound(GstElement *, uint, GstCaps *caps, void *self) { - + SongLoader *instance = static_cast(self); if (instance->state_ != WaitingForType) return; diff --git a/src/core/songloader.h b/src/core/songloader.h index f7d900ce7..b099c8acc 100644 --- a/src/core/songloader.h +++ b/src/core/songloader.h @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #ifndef SONGLOADER_H @@ -106,6 +106,8 @@ signals: void LoadLocalDirectory(const QString &filename); void LoadPlaylist(ParserBase *parser, const QString &filename); + void AddAsRawStream(); + #ifdef HAVE_GSTREAMER void LoadRemote(); diff --git a/src/device/cddasongloader.cpp b/src/device/cddasongloader.cpp index 9f3a581b5..8bcbf3fab 100644 --- a/src/device/cddasongloader.cpp +++ b/src/device/cddasongloader.cpp @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #include "config.h" @@ -110,7 +110,7 @@ void CddaSongLoader::LoadSongs() { Song song; song.set_id(track_number); song.set_valid(true); - song.set_filetype(Song::Type_Cdda); + song.set_filetype(Song::Type_CDDA); song.set_url(GetUrlFromTrack(track_number)); song.set_title(QString("Track %1").arg(track_number)); song.set_track(track_number); @@ -207,7 +207,7 @@ void CddaSongLoader::AudioCDTagsLoaded(const QString &artist, const QString &alb song.set_track(track_number); song.set_year(ret.year_); song.set_id(track_number); - song.set_filetype(Song::Type_Cdda); + song.set_filetype(Song::Type_CDDA); song.set_valid(true); // We need to set url: that's how playlist will find the correct item to update song.set_url(GetUrlFromTrack(track_number++)); diff --git a/src/device/gpoddevice.cpp b/src/device/gpoddevice.cpp index 00707661b..efb6bd039 100644 --- a/src/device/gpoddevice.cpp +++ b/src/device/gpoddevice.cpp @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #include "config.h" @@ -248,8 +248,8 @@ void GPodDevice::FinishDelete(bool success) { } bool GPodDevice::GetSupportedFiletypes(QList *ret) { - *ret << Song::Type_Mp4; - *ret << Song::Type_Mpeg; + *ret << Song::Type_MP4; + *ret << Song::Type_MPEG; return true; } diff --git a/src/device/mtpdevice.cpp b/src/device/mtpdevice.cpp index cb7c2af6f..74db3d8bf 100644 --- a/src/device/mtpdevice.cpp +++ b/src/device/mtpdevice.cpp @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #include "config.h" @@ -209,15 +209,15 @@ bool MtpDevice::GetSupportedFiletypes(QList *ret, LIBMTP_mtpdevi for (int i = 0; i < length; ++i) { switch (LIBMTP_filetype_t(list[i])) { - case LIBMTP_FILETYPE_WAV: *ret << Song::Type_Wav; break; + case LIBMTP_FILETYPE_WAV: *ret << Song::Type_WAV; break; case LIBMTP_FILETYPE_MP2: - case LIBMTP_FILETYPE_MP3: *ret << Song::Type_Mpeg; break; - case LIBMTP_FILETYPE_WMA: *ret << Song::Type_Asf; break; + case LIBMTP_FILETYPE_MP3: *ret << Song::Type_MPEG; break; + case LIBMTP_FILETYPE_WMA: *ret << Song::Type_ASF; break; case LIBMTP_FILETYPE_MP4: case LIBMTP_FILETYPE_M4A: - case LIBMTP_FILETYPE_AAC: *ret << Song::Type_Mp4; break; + case LIBMTP_FILETYPE_AAC: *ret << Song::Type_MP4; break; case LIBMTP_FILETYPE_FLAC: - *ret << Song::Type_Flac; + *ret << Song::Type_FLAC; *ret << Song::Type_OggFlac; break; case LIBMTP_FILETYPE_OGG: diff --git a/src/engine/gstengine.cpp b/src/engine/gstengine.cpp index b3909b4f5..27e053b79 100644 --- a/src/engine/gstengine.cpp +++ b/src/engine/gstengine.cpp @@ -786,7 +786,6 @@ void GstEngine::StartFadeoutPause() { void GstEngine::StartTimers() { StopTimers(); - timer_id_ = startTimer(kTimerIntervalNanosec / kNsecPerMsec); } diff --git a/src/engine/vlcengine.cpp b/src/engine/vlcengine.cpp index 8c2f2963a..3a32f216c 100644 --- a/src/engine/vlcengine.cpp +++ b/src/engine/vlcengine.cpp @@ -336,7 +336,7 @@ EngineBase::PluginDetailsList VLCEngine::GetPluginList() const { ret << details; //GetDevicesList(audio_output->psz_name); } - + libvlc_audio_output_list_release(audio_output_list); return ret; diff --git a/src/internet/internetmimedata.h b/src/internet/internetmimedata.h new file mode 100644 index 000000000..eaa532ff0 --- /dev/null +++ b/src/internet/internetmimedata.h @@ -0,0 +1,43 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef INTERNETMIMEDATA_H +#define INTERNETMIMEDATA_H + +#include "config.h" + +#include +#include + +#include "core/mimedata.h" + +class InternetModel; + +class InternetMimeData : public MimeData { + Q_OBJECT + + public: + explicit InternetMimeData(const InternetModel *_model) : model(_model) {} + + const InternetModel *model; + QModelIndexList indexes; +}; + +#endif diff --git a/src/internet/internetmodel.cpp b/src/internet/internetmodel.cpp new file mode 100644 index 000000000..81d8433a0 --- /dev/null +++ b/src/internet/internetmodel.cpp @@ -0,0 +1,83 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * Copyright 2018, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include "config.h" + +#include +#include +#include +#include +#include + +#include "core/logging.h" +#include "internetmodel.h" +#include "internetservice.h" +#include "tidal/tidalservice.h" + +QMap* InternetModel::sServices = nullptr; + +InternetModel::InternetModel(Application *app, QObject *parent) + : QStandardItemModel(parent), + app_(app) { + + if (!sServices) sServices = new QMap; + Q_ASSERT(sServices->isEmpty()); + AddService(new TidalService(app, this)); + +} + +void InternetModel::AddService(InternetService *service) { + + qLog(Debug) << "Adding internet service:" << service->name(); + sServices->insert(service->name(), service); + connect(service, SIGNAL(destroyed()), SLOT(ServiceDeleted())); + if (service->has_initial_load_settings()) service->InitialLoadSettings(); + else service->ReloadSettings(); + +} + +void InternetModel::RemoveService(InternetService *service) { + + if (!sServices->contains(service->name())) return; + sServices->remove(service->name()); + disconnect(service, 0, this, 0); + +} + +void InternetModel::ServiceDeleted() { + + InternetService *service = qobject_cast(sender()); + if (service) RemoveService(service); + +} + +InternetService *InternetModel::ServiceByName(const QString &name) { + + if (sServices->contains(name)) return sServices->value(name); + return nullptr; + +} + +void InternetModel::ReloadSettings() { + for (InternetService *service : sServices->values()) { + service->ReloadSettings(); + } +} diff --git a/src/internet/internetmodel.h b/src/internet/internetmodel.h new file mode 100644 index 000000000..af3cf83da --- /dev/null +++ b/src/internet/internetmodel.h @@ -0,0 +1,132 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * Copyright 2018, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef INTERNETMODEL_H +#define INTERNETMODEL_H + +#include "config.h" + +#include +#include +#include +#include +#include + +#include "core/song.h" +#include "collection/collectionmodel.h" +#include "playlist/playlistitem.h" +#include "settings/settingsdialog.h" +#include "widgets/multiloadingindicator.h" + +class Application; +class InternetService; + +class InternetModel : public QStandardItemModel { + Q_OBJECT + + public: + explicit InternetModel(Application* app, QObject *parent = nullptr); + + enum Role { + // Services can use this role to distinguish between different types of items that they add. + // The root item's type is automatically set to Type_Service, + // but apart from that Services are free to define their own values for this field (starting from TypeCount). + Role_Type = Qt::UserRole + 1000, + + // If this is not set the item is not playable (ie. it can't be dragged to the playlist). + // Otherwise it describes how this item is converted to playlist items. + // See the PlayBehaviour enum for more details. + Role_PlayBehaviour, + + // The URL of the media for this item. This is required if the PlayBehaviour is set to PlayBehaviour_UseSongLoader. + Role_Url, + + // The metadata used in the item that is added to the playlist if the PlayBehaviour is set to PlayBehaviour_SingleItem. Ignored otherwise. + Role_SongMetadata, + + // If this is set to true then the model will call the service's LazyPopulate method when this item is expanded. + // Use this if your item's children have to be downloaded or fetched remotely. + Role_CanLazyLoad, + + // This is automatically set on the root item for a service. It contains a pointer to an InternetService. + // Services should not set this field themselves. + Role_Service, + + // Setting this to true means that the item can be changed by user action (e.g. changing remote playlists) + Role_CanBeModified, + RoleCount, + Role_IsDivider = CollectionModel::Role_IsDivider, + }; + + enum Type { + Type_Service = 1, + Type_Track, + Type_UserPlaylist, + TypeCount + }; + + enum PlayBehaviour { + // The item can't be played. This is the default. + PlayBehaviour_None = 0, + + // This item's URL is passed through the normal song loader. + // This supports loading remote playlists, remote files and local files. + // This is probably the most sensible behaviour to use if you're just returning normal radio stations. + PlayBehaviour_UseSongLoader, + + // This item's URL, Title and Artist are used in the playlist. No special behaviour occurs + // The URL is just passed straight to gstreamer when the user starts playing. + PlayBehaviour_SingleItem, + + // This item's children have PlayBehaviour_SingleItem set. + // This is used when dragging a playlist item for instance, to have all the playlit's items info loaded in the mime data. + PlayBehaviour_MultipleItems, + + // This item might not represent a song - the service's ItemDoubleClicked() slot will get called instead to do some custom action. + PlayBehaviour_DoubleClickAction, + }; + + // Needs to be static for InternetPlaylistItem::restore + static InternetService *ServiceByName(const QString &name); + + template + static T *Service() { + return static_cast(ServiceByName(T::kServiceName)); + } + + // Add and remove services. Ownership is not transferred and the service is not reparented. + // If the service is deleted it will be automatically removed from the model. + void AddService(InternetService *service); + void RemoveService(InternetService *service); + void ReloadSettings(); + + Application *app() const { return app_; } + + private slots: + void ServiceDeleted(); + + private: + static QMap *sServices; + Application *app_; + +}; + +#endif diff --git a/src/internet/internetplaylistitem.cpp b/src/internet/internetplaylistitem.cpp new file mode 100644 index 000000000..6ce03e3ce --- /dev/null +++ b/src/internet/internetplaylistitem.cpp @@ -0,0 +1,106 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include "config.h" + +#include +#include +#include +#include +#include + +#include "internetplaylistitem.h" +#include "internetservice.h" +#include "internetmodel.h" +#include "core/settingsprovider.h" +#include "collection/sqlrow.h" +#include "playlist/playlistbackend.h" + +InternetPlaylistItem::InternetPlaylistItem(const QString &type) + : PlaylistItem(type), set_service_icon_(false) {} + +InternetPlaylistItem::InternetPlaylistItem(InternetService *service, const Song &metadata) + : PlaylistItem("Internet"), + service_name_(service->name()), + set_service_icon_(false), + metadata_(metadata) { + InitMetadata(); +} + +bool InternetPlaylistItem::InitFromQuery(const SqlRow &query) { + + // The song tables gets joined first, plus one each for the song ROWIDs + const int row = (Song::kColumns.count() + 1) * PlaylistBackend::kSongTableJoins; + + service_name_ = query.value(row + 1).toString(); + + metadata_.InitFromQuery(query, false, (Song::kColumns.count() + 1) * 1); + InitMetadata(); + + return true; + +} + +InternetService *InternetPlaylistItem::service() const { + + InternetService *ret = InternetModel::ServiceByName(service_name_); + + if (ret && !set_service_icon_) { + const_cast(this)->set_service_icon_ = true; + + QString icon = ret->Icon(); + if (!icon.isEmpty()) { + const_cast(this)->metadata_.set_art_manual(icon); + } + } + + return ret; + +} + +QVariant InternetPlaylistItem::DatabaseValue(DatabaseColumn column) const { + switch (column) { + case Column_InternetService: + return service_name_; + default: + return PlaylistItem::DatabaseValue(column); + } +} + +void InternetPlaylistItem::InitMetadata() { + + if (metadata_.title().isEmpty()) + metadata_.set_title(metadata_.url().toString()); + metadata_.set_filetype(Song::Type_Stream); + metadata_.set_valid(true); + +} + +Song InternetPlaylistItem::Metadata() const { + if (!set_service_icon_) { + // Get the icon if we don't have it already + service(); + } + + if (HasTemporaryMetadata()) return temp_metadata_; + return metadata_; +} + +QUrl InternetPlaylistItem::Url() const { return metadata_.url(); } diff --git a/src/internet/internetplaylistitem.h b/src/internet/internetplaylistitem.h new file mode 100644 index 000000000..2a6d8f98f --- /dev/null +++ b/src/internet/internetplaylistitem.h @@ -0,0 +1,58 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef INTERNETPLAYLISTITEM_H +#define INTERNETPLAYLISTITEM_H + +#include "config.h" + +#include +#include +#include + +#include "core/song.h" +#include "playlist/playlistitem.h" + +class InternetService; + +class InternetPlaylistItem : public PlaylistItem { + + public: + explicit InternetPlaylistItem(const QString &type); + InternetPlaylistItem(InternetService *service, const Song &metadata); + bool InitFromQuery(const SqlRow &query); + Song Metadata() const; + QUrl Url() const; + + protected: + QVariant DatabaseValue(DatabaseColumn) const; + Song DatabaseSongMetadata() const { return metadata_; } + + private: + void InitMetadata(); + InternetService *service() const; + + private: + QString service_name_; + bool set_service_icon_; + Song metadata_; +}; + +#endif diff --git a/src/internet/internetservice.cpp b/src/internet/internetservice.cpp new file mode 100644 index 000000000..25139ba6f --- /dev/null +++ b/src/internet/internetservice.cpp @@ -0,0 +1,32 @@ +/* + * Strawberry Music Player + * Copyright 2018, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include +#include +#include +#include + +#include "core/logging.h" +#include "core/mimedata.h" +#include "internetmodel.h" +#include "internetservice.h" + +InternetService::InternetService(const QString &name, Application *app, InternetModel *model, QObject *parent) + : QObject(parent), app_(app), model_(model), name_(name) { +} diff --git a/src/internet/internetservice.h b/src/internet/internetservice.h new file mode 100644 index 000000000..808648f49 --- /dev/null +++ b/src/internet/internetservice.h @@ -0,0 +1,64 @@ +/* + * Strawberry Music Player + * Copyright 2018, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef INTERNETSERVICE_H +#define INTERNETSERVICE_H + +#include +#include +#include +#include +#include +#include +#include + +#include "core/song.h" +#include "playlist/playlistitem.h" +#include "settings/settingsdialog.h" + +class Application; +class InternetModel; +class CollectionFilterWidget; + +class InternetService : public QObject { + Q_OBJECT + + public: + InternetService(const QString &name, Application *app, InternetModel *model, QObject *parent = nullptr); + virtual ~InternetService() {} + QString name() const { return name_; } + InternetModel *model() const { return model_; } + virtual bool has_initial_load_settings() const { return false; } + virtual void InitialLoadSettings() {} + virtual void ReloadSettings() {} + virtual QString Icon() { return QString(); } + + public slots: + virtual void ShowConfig() {} + + protected: + Application *app_; + private: + InternetModel *model_; + QString name_; + +}; +Q_DECLARE_METATYPE(InternetService*); + +#endif diff --git a/src/internet/internetsongmimedata.h b/src/internet/internetsongmimedata.h new file mode 100644 index 000000000..68541f49f --- /dev/null +++ b/src/internet/internetsongmimedata.h @@ -0,0 +1,39 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2011, David Sansome + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef INTERNETSONGMIMEDATA_H +#define INTERNETSONGMIMEDATA_H + +#include "core/mimedata.h" +#include "core/song.h" + +class InternetService; + +class InternetSongMimeData : public MimeData { + Q_OBJECT + + public: + explicit InternetSongMimeData(InternetService *_service) : service(_service) {} + + InternetService *service; + SongList songs; +}; + +#endif diff --git a/src/playlist/playlist.cpp b/src/playlist/playlist.cpp index 084919cec..35489c136 100644 --- a/src/playlist/playlist.cpp +++ b/src/playlist/playlist.cpp @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #include "config.h" @@ -84,6 +84,11 @@ #include "songplaylistitem.h" #include "tagreadermessages.pb.h" +#include "internet/internetmodel.h" +#include "internet/internetplaylistitem.h" +#include "internet/internetmimedata.h" +#include "internet/internetsongmimedata.h" + using std::placeholders::_1; using std::placeholders::_2; using std::shared_ptr; @@ -153,7 +158,7 @@ Playlist::~Playlist() { } template -void Playlist::InsertSongItems(const SongList &songs, int pos, bool play_now, bool enqueue) { +void Playlist::InsertSongItems(const SongList &songs, int pos, bool play_now, bool enqueue, bool enqueue_next) { PlaylistItemList items; @@ -161,7 +166,7 @@ void Playlist::InsertSongItems(const SongList &songs, int pos, bool play_now, bo items << PlaylistItemPtr(new T(song)); } - InsertItems(items, pos, play_now, enqueue); + InsertItems(items, pos, play_now, enqueue, enqueue_next); } @@ -282,16 +287,15 @@ QVariant Playlist::data(const QModelIndex &index, int role) const { case Column_AlbumArtist: return song.playlist_albumartist(); case Column_Composer: return song.composer(); case Column_Performer: return song.performer(); - case Column_Grouping: return song.grouping(); + case Column_Grouping: return song.grouping(); case Column_PlayCount: return song.playcount(); case Column_SkipCount: return song.skipcount(); case Column_LastPlayed: return song.lastplayed(); case Column_Samplerate: return song.samplerate(); - case Column_Bitdepth: return song.bitdepth(); - case Column_Bitrate: return song.bitrate(); - case Column_SamplerateBitdepth: return song.SampleRateBitDepthToText(); + case Column_Bitdepth: return song.bitdepth(); + case Column_Bitrate: return song.bitrate(); case Column_Filename: return song.url(); case Column_BaseFilename: return song.basefilename(); @@ -304,7 +308,7 @@ QVariant Playlist::data(const QModelIndex &index, int role) const { if (role == Qt::DisplayRole) return song.comment().simplified(); return song.comment(); - //case Column_Source: return item->Url(); + case Column_Source: return item->Url(); } @@ -323,9 +327,7 @@ QVariant Playlist::data(const QModelIndex &index, int role) const { if (items_[index.row()]->HasCurrentForegroundColor()) { return QBrush(items_[index.row()]->GetCurrentForegroundColor()); } - //if (index.row() < dynamic_history_length()) { - //return QBrush(kDynamicHistoryColor); - //} + return QVariant(); case Qt::BackgroundRole: @@ -562,7 +564,7 @@ int Playlist::previous_row(bool ignore_repeat_track) const { void Playlist::set_current_row(int i, bool is_stopping) { QModelIndex old_current_item_index = current_item_index_; - //ClearStreamMetadata(); + ClearStreamMetadata(); current_item_index_ = QPersistentModelIndex(index(i, 0, QModelIndex())); @@ -636,6 +638,7 @@ bool Playlist::dropMimeData(const QMimeData *data, Qt::DropAction action, int ro bool play_now = false; bool enqueue_now = false; + bool enqueue_next_now = false; if (const MimeData *mime_data = qobject_cast(data)) { if (mime_data->clear_first_) { @@ -643,6 +646,7 @@ bool Playlist::dropMimeData(const QMimeData *data, Qt::DropAction action, int ro } play_now = mime_data->play_now_; enqueue_now = mime_data->enqueue_now_; + enqueue_next_now = mime_data->enqueue_next_now_; } if (const SongMimeData *song_data = qobject_cast(data)) { @@ -651,11 +655,13 @@ bool Playlist::dropMimeData(const QMimeData *data, Qt::DropAction action, int ro if (song_data->backend && song_data->backend->songs_table() == SCollection::kSongsTable) InsertSongItems(song_data->songs, row, play_now, enqueue_now); else - InsertSongItems(song_data->songs, row, play_now, enqueue_now); - + InsertSongItems(song_data->songs, row, play_now, enqueue_now, enqueue_next_now); } else if (const PlaylistItemMimeData *item_data = qobject_cast(data)) { - InsertItems(item_data->items_, row, play_now, enqueue_now); + InsertItems(item_data->items_, row, play_now, enqueue_now, enqueue_next_now); + } + else if (const InternetSongMimeData* internet_song_data = qobject_cast(data)) { + InsertInternetItems(internet_song_data->service, internet_song_data->songs, row, play_now, enqueue_now, enqueue_next_now); } else if (data->hasFormat(kRowsMimetype)) { // Dragged from the playlist @@ -719,7 +725,7 @@ bool Playlist::dropMimeData(const QMimeData *data, Qt::DropAction action, int ro } -void Playlist::InsertUrls(const QList &urls, int pos, bool play_now, bool enqueue) { +void Playlist::InsertUrls(const QList &urls, int pos, bool play_now, bool enqueue, bool enqueue_next) { SongLoaderInserter *inserter = new SongLoaderInserter(task_manager_, collection_, backend_->app()->player()); connect(inserter, SIGNAL(Error(QString)), SIGNAL(Error(QString))); @@ -832,7 +838,7 @@ void Playlist::MoveItemsWithoutUndo(int start, const QList &dest_rows) { } -void Playlist::InsertItems(const PlaylistItemList &itemsIn, int pos, bool play_now, bool enqueue) { +void Playlist::InsertItems(const PlaylistItemList &itemsIn, int pos, bool play_now, bool enqueue, bool enqueue_next) { if (itemsIn.isEmpty()) return; @@ -932,25 +938,37 @@ void Playlist::InsertItemsWithoutUndo(const PlaylistItemList &items, int pos, bo } -void Playlist::InsertCollectionItems(const SongList &songs, int pos, bool play_now, bool enqueue) { - InsertSongItems(songs, pos, play_now, enqueue); +void Playlist::InsertCollectionItems(const SongList &songs, int pos, bool play_now, bool enqueue, bool enqueue_next) { + InsertSongItems(songs, pos, play_now, enqueue, enqueue_next); } -void Playlist::InsertSongs(const SongList &songs, int pos, bool play_now, bool enqueue) { - InsertSongItems(songs, pos, play_now, enqueue); +void Playlist::InsertSongs(const SongList &songs, int pos, bool play_now, bool enqueue, bool enqueue_next) { + InsertSongItems(songs, pos, play_now, enqueue, enqueue_next); } -void Playlist::InsertSongsOrCollectionItems(const SongList &songs, int pos, bool play_now, bool enqueue) { +void Playlist::InsertSongsOrCollectionItems(const SongList &songs, int pos, bool play_now, bool enqueue, bool enqueue_next) { PlaylistItemList items; for (const Song &song : songs) { if (song.is_collection_song()) { items << PlaylistItemPtr(new CollectionPlaylistItem(song)); - } else { + } + else { items << PlaylistItemPtr(new SongPlaylistItem(song)); } } - InsertItems(items, pos, play_now, enqueue); + InsertItems(items, pos, play_now, enqueue, enqueue_next); + +} + +void Playlist::InsertInternetItems(InternetService *service, const SongList &songs, int pos, bool play_now, bool enqueue, bool enqueue_next) { + + PlaylistItemList playlist_items; + for (const Song &song : songs) { + playlist_items << shared_ptr(new InternetPlaylistItem(service, song)); + } + + InsertItems(playlist_items, pos, play_now, enqueue, enqueue_next); } @@ -973,8 +991,10 @@ void Playlist::UpdateItems(const SongList &songs) { PlaylistItemPtr &item = items_[i]; if (item->Metadata().url() == song.url() && (item->Metadata().filetype() == Song::Type_Unknown || + // Stream may change and may need to be updated too + item->Metadata().filetype() == Song::Type_Stream || // And CD tracks as well (tags are loaded in a second step) - item->Metadata().filetype() == Song::Type_Cdda)) { + item->Metadata().filetype() == Song::Type_CDDA)) { PlaylistItemPtr new_item; if (song.is_collection_song()) { new_item = PlaylistItemPtr(new CollectionPlaylistItem(song)); @@ -1069,9 +1089,7 @@ bool Playlist::CompareItems(int column, Qt::SortOrder order, shared_ptrMetadata().SampleRateBitDepthToText().toLower(), b->Metadata().SampleRateBitDepthToText().toLower()) < 0; + case Column_Bitdepth: cmp(bitdepth); case Column_Filename: return (QString::localeAwareCompare(a->Url().path().toLower(), b->Url().path().toLower()) < 0); case Column_BaseFilename: cmp(basefilename); @@ -1081,7 +1099,7 @@ bool Playlist::CompareItems(int column, Qt::SortOrder order, shared_ptrMetadata(); + if (!song.is_stream()) { bool exists = QFile::exists(song.url().toLocalFile()); if (!exists && !item->HasForegroundColor(kInvalidSongPriority)) { @@ -1768,6 +1786,7 @@ void Playlist::InvalidateDeletedSongs() { item->RemoveForegroundColor(kInvalidSongPriority); invalidated_rows.append(row); } + } } ReloadItems(invalidated_rows); diff --git a/src/playlist/playlist.h b/src/playlist/playlist.h index deb7e0a02..4099fa1d6 100644 --- a/src/playlist/playlist.h +++ b/src/playlist/playlist.h @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #ifndef PLAYLIST_H @@ -54,6 +54,8 @@ class PlaylistBackend; class PlaylistFilter; class Queue; class TaskManager; +class InternetModel; +class InternetService; namespace PlaylistUndoCommands { class InsertItems; @@ -110,7 +112,6 @@ class Playlist : public QAbstractListModel { Column_Genre, Column_Samplerate, Column_Bitdepth, - Column_SamplerateBitdepth, Column_Bitrate, Column_Filename, Column_BaseFilename, @@ -123,6 +124,7 @@ class Playlist : public QAbstractListModel { Column_LastPlayed, Column_Comment, Column_Grouping, + Column_Source, ColumnCount }; @@ -212,10 +214,11 @@ class Playlist : public QAbstractListModel { QUndoStack *undo_stack() const { return undo_stack_; } // Changing the playlist - void InsertItems (const PlaylistItemList &items, int pos = -1, bool play_now = false, bool enqueue = false); - void InsertCollectionItems (const SongList &items, int pos = -1, bool play_now = false, bool enqueue = false); - void InsertSongs (const SongList &items, int pos = -1, bool play_now = false, bool enqueue = false); - void InsertSongsOrCollectionItems (const SongList &items, int pos = -1, bool play_now = false, bool enqueue = false); + void InsertItems (const PlaylistItemList &items, int pos = -1, bool play_now = false, bool enqueue = false, bool enqueue_next = false); + void InsertCollectionItems (const SongList &items, int pos = -1, bool play_now = false, bool enqueue = false, bool enqueue_next = false); + void InsertSongs (const SongList &items, int pos = -1, bool play_now = false, bool enqueue = false, bool enqueue_next = false); + void InsertSongsOrCollectionItems (const SongList &items, int pos = -1, bool play_now = false, bool enqueue = false, bool enqueue_next = false); + void InsertInternetItems(InternetService* service, const SongList& songs, int pos = -1, bool play_now = false, bool enqueue = false, bool enqueue_next = false); void ReshuffleIndices(); @@ -276,7 +279,7 @@ class Playlist : public QAbstractListModel { void SetColumnAlignment(const ColumnAlignmentMap &alignment); - void InsertUrls(const QList &urls, int pos = -1, bool play_now = false, bool enqueue = false); + void InsertUrls(const QList &urls, int pos = -1, bool play_now = false, bool enqueue = false, bool enqueue_next = false); // Removes items with given indices from the playlist. This operation is not undoable. void RemoveItemsWithoutUndo(const QList &indices); @@ -302,7 +305,7 @@ private: bool FilterContainsVirtualIndex(int i) const; template - void InsertSongItems(const SongList &songs, int pos, bool play_now, bool enqueue); + void InsertSongItems(const SongList &songs, int pos, bool play_now, bool enqueue, bool enqueue_next = false); // Modify the playlist without changing the undo stack. These are used by our friends in PlaylistUndoCommands void InsertItemsWithoutUndo(const PlaylistItemList &items, int pos, bool enqueue = false); diff --git a/src/playlist/playlistbackend.cpp b/src/playlist/playlistbackend.cpp index 20870c40e..e38e41ac7 100644 --- a/src/playlist/playlistbackend.cpp +++ b/src/playlist/playlistbackend.cpp @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #include @@ -145,7 +145,7 @@ QSqlQuery PlaylistBackend::GetPlaylistRows(int playlist) { " p.ROWID, " + Song::JoinSpec("p") + "," - " p.type" + " p.type, p.internet_service" " FROM playlist_items AS p" " LEFT JOIN songs" " ON p.collection_id = songs.ROWID" @@ -279,7 +279,7 @@ void PlaylistBackend::SavePlaylist(int playlist, const PlaylistItemList &items, QSqlQuery clear(db); clear.prepare("DELETE FROM playlist_items WHERE playlist = :playlist"); QSqlQuery insert(db); - insert.prepare("INSERT INTO playlist_items (playlist, type, collection_id, " + Song::kColumnSpec + ") VALUES (:playlist, :type, :collection_id, " + Song::kBindSpec + ")"); + insert.prepare("INSERT INTO playlist_items (playlist, type, collection_id, internet_service, " + Song::kColumnSpec + ") VALUES (:playlist, :type, :collection_id, :internet_service, " + Song::kBindSpec + ")"); QSqlQuery update(db); update.prepare("UPDATE playlists SET last_played=:last_played WHERE ROWID=:playlist"); diff --git a/src/playlist/playlistdelegates.cpp b/src/playlist/playlistdelegates.cpp index ada0a2b51..6ae34349e 100644 --- a/src/playlist/playlistdelegates.cpp +++ b/src/playlist/playlistdelegates.cpp @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #include "config.h" @@ -39,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -460,6 +461,12 @@ QPixmap SongSourceDelegate::LookupPixmap(const QUrl &url, const QSize &size) con else if (url.scheme() == "cdda") { icon = IconLoader::Load("cd"); } + else if (url.scheme() == "http" || url.scheme() == "https") { + if (url.host().contains(QRegExp(".*.tidal.com"))) + icon = IconLoader::Load("tidal"); + else + icon = IconLoader::Load("download"); + } else { icon = IconLoader::Load("folder-sound"); } diff --git a/src/playlist/playlistfilterparser.cpp b/src/playlist/playlistfilterparser.cpp index 11f4a00c4..01aa0806a 100644 --- a/src/playlist/playlistfilterparser.cpp +++ b/src/playlist/playlistfilterparser.cpp @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #include "config.h" @@ -445,9 +445,6 @@ FilterTree *FilterParser::createSearchTermTreeNode( if (columns_[col] == Playlist::Column_Length) { search_value = parseTime(search); } - //else if (columns_[col] == Playlist::Column_Rating) { - //search_value = static_cast(search.toDouble() * 2.0 + 0.5); - //} else { search_value = search.toInt(); } diff --git a/src/playlist/playlistitem.cpp b/src/playlist/playlistitem.cpp index 26bcb0aa6..13eb7a1b6 100644 --- a/src/playlist/playlistitem.cpp +++ b/src/playlist/playlistitem.cpp @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #include "config.h" @@ -37,6 +37,8 @@ #include "playlistitem.h" #include "songplaylistitem.h" +#include "internet/internetplaylistitem.h" + PlaylistItem::~PlaylistItem() { } @@ -44,11 +46,13 @@ PlaylistItem* PlaylistItem::NewFromType(const QString &type) { if (type == "Collection") return new CollectionPlaylistItem(type); else if (type == "File") return new SongPlaylistItem(type); - + else if (type == "Internet") return new InternetPlaylistItem("Internet"); + else if (type == "Tidal") return new InternetPlaylistItem("Tidal"); + qLog(Warning) << "Invalid PlaylistItem type:" << type; return nullptr; - + } PlaylistItem* PlaylistItem::NewFromSongsTable(const QString &table, const Song &song) { @@ -65,6 +69,7 @@ void PlaylistItem::BindToQuery(QSqlQuery *query) const { query->bindValue(":type", type()); query->bindValue(":collection_id", DatabaseValue(Column_CollectionId)); + query->bindValue(":internet_service", DatabaseValue(Column_InternetService)); DatabaseSongMetadata().BindToQuery(query); @@ -119,3 +124,4 @@ bool PlaylistItem::HasCurrentForegroundColor() const { } void PlaylistItem::SetShouldSkip(bool val) { should_skip_ = val; } bool PlaylistItem::GetShouldSkip() const { return should_skip_; } + diff --git a/src/playlist/playlistitem.h b/src/playlist/playlistitem.h index f71d73d04..a7358bb1c 100644 --- a/src/playlist/playlistitem.h +++ b/src/playlist/playlistitem.h @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #ifndef PLAYLISTITEM_H @@ -104,7 +104,7 @@ class PlaylistItem : public std::enable_shared_from_this { protected: bool should_skip_; - enum DatabaseColumn { Column_CollectionId, Column_InternetService, }; + enum DatabaseColumn { Column_CollectionId, Column_InternetService }; virtual QVariant DatabaseValue(DatabaseColumn) const { return QVariant(QVariant::String); @@ -126,3 +126,4 @@ Q_DECLARE_METATYPE(QList) Q_DECLARE_OPERATORS_FOR_FLAGS(PlaylistItem::Options) #endif // PLAYLISTITEM_H + diff --git a/src/playlist/playlistview.cpp b/src/playlist/playlistview.cpp index dd0b17183..5624a2900 100644 --- a/src/playlist/playlistview.cpp +++ b/src/playlist/playlistview.cpp @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #include "config.h" @@ -228,20 +228,16 @@ void PlaylistView::SetItemDelegates(CollectionBackend *backend) { setItemDelegateForColumn(Playlist::Column_Samplerate, new PlaylistDelegateBase(this, ("Hz"))); setItemDelegateForColumn(Playlist::Column_Bitdepth, new PlaylistDelegateBase(this, ("Bit"))); setItemDelegateForColumn(Playlist::Column_Bitrate, new PlaylistDelegateBase(this, tr("kbps"))); - - setItemDelegateForColumn(Playlist::Column_SamplerateBitdepth, new SamplerateBitdepthItemDelegate(this)); setItemDelegateForColumn(Playlist::Column_Filename, new NativeSeparatorsDelegate(this)); setItemDelegateForColumn(Playlist::Column_LastPlayed, new LastPlayedItemDelegate(this)); -#if 0 if (app_ && app_->player()) { setItemDelegateForColumn(Playlist::Column_Source, new SongSourceDelegate(this, app_->player())); } else { header_->HideSection(Playlist::Column_Source); } -#endif } @@ -946,7 +942,8 @@ void PlaylistView::ReloadSettings() { header_->SetColumnWidth(Playlist::Column_Album, 0.10); header_->SetColumnWidth(Playlist::Column_Length, 0.03); header_->SetColumnWidth(Playlist::Column_Bitrate, 0.07); - header_->SetColumnWidth(Playlist::Column_SamplerateBitdepth, 0.07); + header_->SetColumnWidth(Playlist::Column_Samplerate, 0.07); + header_->SetColumnWidth(Playlist::Column_Bitdepth, 0.07); header_->SetColumnWidth(Playlist::Column_Filetype, 0.06); setting_initial_header_layout_ = false; @@ -1089,7 +1086,6 @@ ColumnAlignmentMap PlaylistView::DefaultColumnAlignment() { ret[Playlist::Column_Bitrate] = ret[Playlist::Column_Samplerate] = ret[Playlist::Column_Bitdepth] = - ret[Playlist::Column_SamplerateBitdepth] = ret[Playlist::Column_Filesize] = ret[Playlist::Column_PlayCount] = ret[Playlist::Column_SkipCount] = @@ -1216,8 +1212,7 @@ void PlaylistView::focusInEvent(QFocusEvent *event) { QTreeView::focusInEvent(event); - if (event->reason() == Qt::TabFocusReason || - event->reason() == Qt::BacktabFocusReason) { + if (event->reason() == Qt::TabFocusReason || event->reason() == Qt::BacktabFocusReason) { // If there's a current item but no selection it probably means the list was filtered, and the selected item does not match the filter. // If there's only 1 item in the view it is now impossible to select that item without using the mouse. const QModelIndex ¤t = selectionModel()->currentIndex(); diff --git a/src/playlist/playlistview.h b/src/playlist/playlistview.h index 14916dcfc..59ddd977d 100644 --- a/src/playlist/playlistview.h +++ b/src/playlist/playlistview.h @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #ifndef PLAYLISTVIEW_H @@ -75,7 +75,7 @@ class PlaylistHeader; // that uses Gtk to paint row backgrounds, ignoring any custom brush or palette the caller set in the QStyleOption. // That breaks our currently playing track animation, which relies on the background painted by Qt to be transparent. // This proxy style uses QCommonStyle to paint the affected elements. -// This class is used by the global search view as well. +// This class is used by tidal search view as well. class PlaylistProxyStyle : public QProxyStyle { public: PlaylistProxyStyle(QStyle *base); diff --git a/src/settings/playlistsettingspage.cpp b/src/settings/playlistsettingspage.cpp index c32f6f6be..a76b03259 100644 --- a/src/settings/playlistsettingspage.cpp +++ b/src/settings/playlistsettingspage.cpp @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #include "config.h" diff --git a/src/settings/settingsdialog.cpp b/src/settings/settingsdialog.cpp index d562fff62..e1864fc9a 100644 --- a/src/settings/settingsdialog.cpp +++ b/src/settings/settingsdialog.cpp @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #include "config.h" @@ -62,6 +62,8 @@ #include "playlistsettingspage.h" #include "shortcutssettingspage.h" #include "transcodersettingspage.h" +#include "tidalsettingspage.h" + #include "ui_settingsdialog.h" class QShowEvent; @@ -122,6 +124,7 @@ SettingsDialog::SettingsDialog(Application *app, QWidget *parent) #ifdef HAVE_GSTREAMER AddPage(Page_Transcoding, new TranscoderSettingsPage(this), general); #endif + AddPage(Page_Tidal, new TidalSettingsPage(this), general); // User interface QTreeWidgetItem *iface = AddCategory(tr("User interface")); diff --git a/src/settings/settingsdialog.h b/src/settings/settingsdialog.h index 4577b81e4..0f7e51980 100644 --- a/src/settings/settingsdialog.h +++ b/src/settings/settingsdialog.h @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #ifndef SETTINGSDIALOG_H @@ -79,6 +79,7 @@ public: Page_Notifications, Page_Proxy, Page_Transcoding, + Page_Tidal, }; enum Role { diff --git a/src/settings/tidalsettingspage.cpp b/src/settings/tidalsettingspage.cpp new file mode 100644 index 000000000..73399bd2e --- /dev/null +++ b/src/settings/tidalsettingspage.cpp @@ -0,0 +1,118 @@ +/* + * Strawberry Music Player + * Copyright 2018, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include "config.h" + +#include +#include +#include +#include +#include + +#include "tidalsettingspage.h" +#include "ui_tidalsettingspage.h" +#include "core/application.h" +#include "core/iconloader.h" +#include "internet/internetmodel.h" +#include "tidal/tidalservice.h" + +const char *TidalSettingsPage::kSettingsGroup = "Tidal"; + +TidalSettingsPage::TidalSettingsPage(SettingsDialog *parent) + : SettingsPage(parent), + ui_(new Ui::TidalSettingsPage), + service_(dialog()->app()->internet_model()->Service()) { + + ui_->setupUi(this); + setWindowIcon(IconLoader::Load("tidal")); + + connect(ui_->button_login, SIGNAL(clicked()), SLOT(LoginClicked())); + connect(ui_->login_state, SIGNAL(LogoutClicked()), SLOT(LogoutClicked())); + + connect(service_, SIGNAL(LoginFailure(QString)), SLOT(LoginFailure(QString))); + connect(service_, SIGNAL(LoginSuccess()), SLOT(LoginSuccess())); + + dialog()->installEventFilter(this); + + ui_->combobox_quality->addItem("Low", "LOW"); + ui_->combobox_quality->addItem("High", "HIGH"); + ui_->combobox_quality->addItem("Lossless", "LOSSLESS"); + +} + +TidalSettingsPage::~TidalSettingsPage() { delete ui_; } + +void TidalSettingsPage::Load() { + + QSettings s; + s.beginGroup(kSettingsGroup); + ui_->username->setText(s.value("username").toString()); + ui_->password->setText(s.value("password").toString()); + QString quality = s.value("quality", "HIGH").toString(); + ui_->combobox_quality->setCurrentIndex(ui_->combobox_quality->findData(quality)); + s.endGroup(); + + if (service_->authenticated()) ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedIn); + +} + +void TidalSettingsPage::Save() { + + QSettings s; + s.beginGroup(kSettingsGroup); + s.setValue("username", ui_->username->text()); + s.setValue("password", ui_->password->text()); + s.setValue("quality", ui_->combobox_quality->itemData(ui_->combobox_quality->currentIndex())); + s.endGroup(); + + service_->ReloadSettings(); + +} + +void TidalSettingsPage::LoginClicked() { + service_->Login(ui_->username->text(), ui_->password->text()); + ui_->button_login->setEnabled(false); +} + +bool TidalSettingsPage::eventFilter(QObject *object, QEvent *event) { + + if (object == dialog() && event->type() == QEvent::Enter) { + ui_->button_login->setEnabled(true); + return false; + } + + return SettingsPage::eventFilter(object, event); +} + +void TidalSettingsPage::LogoutClicked() { + service_->Logout(); + ui_->button_login->setEnabled(true); + ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedOut); +} + +void TidalSettingsPage::LoginSuccess() { + if (!this->isVisible()) return; + ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedIn); + ui_->button_login->setEnabled(false); +} + +void TidalSettingsPage::LoginFailure(QString failure_reason) { + if (!this->isVisible()) return; + QMessageBox::warning(this, tr("Authentication failed"), failure_reason); +} diff --git a/src/settings/tidalsettingspage.h b/src/settings/tidalsettingspage.h new file mode 100644 index 000000000..567f10ca5 --- /dev/null +++ b/src/settings/tidalsettingspage.h @@ -0,0 +1,62 @@ +/* + * Strawberry Music Player + * Copyright 2018, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef TIDALSETTINGSPAGE_H +#define TIDALSETTINGSPAGE_H + +#include +#include +#include + +#include "settings/settingspage.h" + +class TidalService; +class Ui_TidalSettingsPage; + +class TidalSettingsPage : public SettingsPage { + Q_OBJECT + + public: + explicit TidalSettingsPage(SettingsDialog* parent = nullptr); + ~TidalSettingsPage(); + + enum SearchBy { + SearchBy_Songs = 1, + SearchBy_Albums = 2, + }; + + static const char *kSettingsGroup; + + void Load(); + void Save(); + + bool eventFilter(QObject *object, QEvent *event); + + private slots: + void LoginClicked(); + void LogoutClicked(); + void LoginSuccess(); + void LoginFailure(QString failure_reason); + + private: + Ui_TidalSettingsPage* ui_; + TidalService *service_; +}; + +#endif diff --git a/src/settings/tidalsettingspage.ui b/src/settings/tidalsettingspage.ui new file mode 100644 index 000000000..dc2389f5c --- /dev/null +++ b/src/settings/tidalsettingspage.ui @@ -0,0 +1,157 @@ + + + TidalSettingsPage + + + + 0 + 0 + 715 + 425 + + + + Tidal + + + + + + + + + + 0 + 0 + + + + Account details + + + + + + Tidal username + + + + + + + + + + + + Login + + + + + + + + + Tidal password + + + + + + + QLineEdit::Password + + + + + + + + + + Preferences + + + + + + Audio quality + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 30 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 64 + 64 + + + + + 64 + 64 + + + + :/icons/64x64/tidal.png + + + + + + + + + + LoginStateWidget + QWidget +
widgets/loginstatewidget.h
+ 1 +
+
+ + username + password + button_login + + + + + +
diff --git a/src/tidal/tidalsearch.cpp b/src/tidal/tidalsearch.cpp new file mode 100644 index 000000000..c8326f4e3 --- /dev/null +++ b/src/tidal/tidalsearch.cpp @@ -0,0 +1,316 @@ +/* + * Strawberry Music Player + * This code was part of Clementine (GlobalSearch) + * Copyright 2010, David Sansome + * Copyright 2018, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include "config.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/application.h" +#include "core/logging.h" +#include "core/closure.h" +#include "core/iconloader.h" +#include "covermanager/albumcoverloader.h" +#include "internet/internetsongmimedata.h" +#include "playlist/songmimedata.h" +#include "tidalsearch.h" +#include "tidalservice.h" +#include "settings/tidalsettingspage.h" + +const int TidalSearch::kDelayedSearchTimeoutMs = 200; +const int TidalSearch::kMaxResultsPerEmission = 1000; +const int TidalSearch::kArtHeight = 32; + +TidalSearch::TidalSearch(Application *app, QObject *parent) + : QObject(parent), + app_(app), + service_(app->internet_model()->Service()), + name_("Tidal"), + id_("tidal"), + icon_(IconLoader::Load("tidal")), + searches_next_id_(1), + art_searches_next_id_(1) { + + cover_loader_options_.desired_height_ = kArtHeight; + cover_loader_options_.pad_output_image_ = true; + cover_loader_options_.scale_output_image_ = true; + + connect(app_->album_cover_loader(), SIGNAL(ImageLoaded(quint64, QImage)), SLOT(AlbumArtLoaded(quint64, QImage))); + connect(this, SIGNAL(SearchAsyncSig(int, QString, TidalSettingsPage::SearchBy)), this, SLOT(DoSearchAsync(int, QString, TidalSettingsPage::SearchBy))); + connect(this, SIGNAL(ResultsAvailable(int, TidalSearch::ResultList)), SLOT(ResultsAvailableSlot(int, TidalSearch::ResultList))); + connect(this, SIGNAL(ArtLoaded(int, QImage)), SLOT(ArtLoadedSlot(int, QImage))); + connect(service_, SIGNAL(SearchResults(int, SongList)), SLOT(SearchDone(int, SongList))); + connect(service_, SIGNAL(SearchError(int, QString)), SLOT(HandleError(int, QString))); + + icon_as_image_ = QImage(icon_.pixmap(48, 48).toImage()); + +} + +TidalSearch::~TidalSearch() {} + +QStringList TidalSearch::TokenizeQuery(const QString &query) { + + QStringList tokens(query.split(QRegExp("\\s+"))); + + for (QStringList::iterator it = tokens.begin(); it != tokens.end(); ++it) { + (*it).remove('('); + (*it).remove(')'); + (*it).remove('"'); + + const int colon = (*it).indexOf(":"); + if (colon != -1) { + (*it).remove(0, colon + 1); + } + } + + return tokens; + +} + +bool TidalSearch::Matches(const QStringList &tokens, const QString &string) { + + for (const QString &token : tokens) { + if (!string.contains(token, Qt::CaseInsensitive)) { + return false; + } + } + + return true; + +} + +int TidalSearch::SearchAsync(const QString &query, TidalSettingsPage::SearchBy searchby) { + + const int id = searches_next_id_++; + + emit SearchAsyncSig(id, query, searchby); + + return id; + +} + +void TidalSearch::SearchAsync(int id, const QString &query, TidalSettingsPage::SearchBy searchby) { + + const int service_id = service_->Search(query, searchby); + pending_searches_[service_id] = PendingState(id, TokenizeQuery(query)); + +} + +void TidalSearch::DoSearchAsync(int id, const QString &query, TidalSettingsPage::SearchBy searchby) { + + int timer_id = startTimer(kDelayedSearchTimeoutMs); + delayed_searches_[timer_id].id_ = id; + delayed_searches_[timer_id].query_ = query; + delayed_searches_[timer_id].searchby_ = searchby; + +} + +void TidalSearch::SearchDone(int service_id, const SongList &songs) { + + // Map back to the original id. + const PendingState state = pending_searches_.take(service_id); + const int search_id = state.orig_id_; + + ResultList ret; + for (const Song &song : songs) { + Result result; + result.metadata_ = song; + ret << result; + } + + emit ResultsAvailable(search_id, ret); + MaybeSearchFinished(search_id); + +} + +void TidalSearch::HandleError(const int id, const QString error) { + + emit SearchError(id, error); + +} + +void TidalSearch::MaybeSearchFinished(int id) { + + if (pending_searches_.keys(PendingState(id, QStringList())).isEmpty()) { + emit SearchFinished(id); + } + +} + +void TidalSearch::CancelSearch(int id) { + QMap::iterator it; + for (it = delayed_searches_.begin(); it != delayed_searches_.end(); ++it) { + if (it.value().id_ == id) { + killTimer(it.key()); + delayed_searches_.erase(it); + return; + } + } +} + +void TidalSearch::timerEvent(QTimerEvent *e) { + QMap::iterator it = delayed_searches_.find(e->timerId()); + if (it != delayed_searches_.end()) { + SearchAsync(it.value().id_, it.value().query_, it.value().searchby_); + delayed_searches_.erase(it); + return; + } + + QObject::timerEvent(e); +} + +void TidalSearch::ResultsAvailableSlot(int id, TidalSearch::ResultList results) { + + if (results.isEmpty()) return; + + // Limit the number of results that are used from each emission. + if (results.count() > kMaxResultsPerEmission) { + TidalSearch::ResultList::iterator begin = results.begin(); + std::advance(begin, kMaxResultsPerEmission); + results.erase(begin, results.end()); + } + + // Load cached pixmaps into the results + for (TidalSearch::ResultList::iterator it = results.begin(); it != results.end(); ++it) { + it->pixmap_cache_key_ = PixmapCacheKey(*it); + } + + emit AddResults(id, results); + +} + +QString TidalSearch::PixmapCacheKey(const TidalSearch::Result &result) const { + return "tidal:" % result.metadata_.url().toString(); +} + +bool TidalSearch::FindCachedPixmap(const TidalSearch::Result &result, QPixmap *pixmap) const { + return pixmap_cache_.find(result.pixmap_cache_key_, pixmap); +} + +void TidalSearch::LoadArtAsync(int id, const Result &result) { + emit ArtLoaded(id, QImage()); +} + +int TidalSearch::LoadArtAsync(const TidalSearch::Result &result) { + + const int id = art_searches_next_id_++; + + pending_art_searches_[id] = result.pixmap_cache_key_; + + quint64 loader_id = app_->album_cover_loader()->LoadImageAsync(cover_loader_options_, result.metadata_); + cover_loader_tasks_[loader_id] = id; + + return id; + +} + +void TidalSearch::ArtLoadedSlot(int id, const QImage &image) { + HandleLoadedArt(id, image); +} + +void TidalSearch::AlbumArtLoaded(quint64 id, const QImage &image) { + + if (!cover_loader_tasks_.contains(id)) return; + int orig_id = cover_loader_tasks_.take(id); + + HandleLoadedArt(orig_id, image); +} + +void TidalSearch::HandleLoadedArt(int id, const QImage &image) { + + const QString key = pending_art_searches_.take(id); + + QPixmap pixmap = QPixmap::fromImage(image); + pixmap_cache_.insert(key, pixmap); + + emit ArtLoaded(id, pixmap); + +} + +QImage TidalSearch::ScaleAndPad(const QImage &image) { + + if (image.isNull()) return QImage(); + + const QSize target_size = QSize(kArtHeight, kArtHeight); + + if (image.size() == target_size) return image; + + // Scale the image down + QImage copy; + copy = image.scaled(target_size, Qt::KeepAspectRatio, Qt::SmoothTransformation); + + // Pad the image to kHeight x kHeight + if (copy.size() == target_size) return copy; + + QImage padded_image(kArtHeight, kArtHeight, QImage::Format_ARGB32); + padded_image.fill(0); + + QPainter p(&padded_image); + p.drawImage((kArtHeight - copy.width()) / 2, (kArtHeight - copy.height()) / 2, copy); + p.end(); + + return padded_image; + +} + +MimeData *TidalSearch::LoadTracks(const ResultList &results) { + + if (results.isEmpty()) { + return nullptr; + } + + ResultList results_copy; + for (const Result &result : results) { + results_copy << result; + } + + SongList songs; + for (const Result &result : results) { + songs << result.metadata_; + } + + InternetSongMimeData *internet_song_mime_data = new InternetSongMimeData(service_); + internet_song_mime_data->songs = songs; + MimeData *mime_data = internet_song_mime_data; + + QList urls; + for (const Result &result : results) { + urls << result.metadata_.url(); + } + mime_data->setUrls(urls); + + return mime_data; + +} diff --git a/src/tidal/tidalsearch.h b/src/tidal/tidalsearch.h new file mode 100644 index 000000000..29cacfd41 --- /dev/null +++ b/src/tidal/tidalsearch.h @@ -0,0 +1,157 @@ +/* + * Strawberry Music Player + * This code was part of Clementine (GlobalSearch) + * Copyright 2010, David Sansome + * Copyright 2018, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef TIDALSEARCH_H +#define TIDALSEARCH_H + +#include "config.h" + +#include +#include +#include +#include +#include + +#include "core/song.h" +#include "covermanager/albumcoverloaderoptions.h" +#include "settings/tidalsettingspage.h" + +class Application; +class MimeData; +class AlbumCoverLoader; +class InternetService; +class TidalService; + +class TidalSearch : public QObject { + Q_OBJECT + + public: + TidalSearch(Application *app, QObject *parent = nullptr); + ~TidalSearch(); + + struct Result { + Song metadata_; + QString pixmap_cache_key_; + }; + typedef QList ResultList; + + static const int kDelayedSearchTimeoutMs; + static const int kMaxResultsPerEmission; + + Application *application() const { return app_; } + TidalService *service() const { return service_; } + + int SearchAsync(const QString &query, TidalSettingsPage::SearchBy searchby); + int LoadArtAsync(const TidalSearch::Result &result); + + void CancelSearch(int id); + void CancelArt(int id); + + // Loads tracks for results that were previously emitted by ResultsAvailable. + // The implementation creates a SongMimeData with one Song for each Result. + MimeData *LoadTracks(const ResultList &results); + + signals: + void SearchAsyncSig(int id, const QString &query, TidalSettingsPage::SearchBy searchby); + void ResultsAvailable(int id, const TidalSearch::ResultList &results); + void AddResults(int id, const TidalSearch::ResultList &results); + void SearchError(const int id, const QString error); + void SearchFinished(int id); + + void ArtLoaded(int id, const QPixmap &pixmap); + void ArtLoaded(int id, const QImage &image); + + protected: + + struct PendingState { + PendingState() : orig_id_(-1) {} + PendingState(int orig_id, QStringList tokens) + : orig_id_(orig_id), tokens_(tokens) {} + int orig_id_; + QStringList tokens_; + + bool operator<(const PendingState &b) const { + return orig_id_ < b.orig_id_; + } + + bool operator==(const PendingState &b) const { + return orig_id_ == b.orig_id_; + } + }; + + void timerEvent(QTimerEvent *e); + + // These functions treat queries in the same way as LibraryQuery. + // They're useful for figuring out whether you got a result because it matched in the song title or the artist/album name. + static QStringList TokenizeQuery(const QString &query); + static bool Matches(const QStringList &tokens, const QString &string); + + private slots: + void DoSearchAsync(int id, const QString &query, TidalSettingsPage::SearchBy searchby); + void SearchDone(int id, const SongList &songs); + void HandleError(const int id, const QString error); + void ResultsAvailableSlot(int id, TidalSearch::ResultList results); + + void ArtLoadedSlot(int id, const QImage &image); + void AlbumArtLoaded(quint64 id, const QImage &image); + + private: + void SearchAsync(int id, const QString &query, TidalSettingsPage::SearchBy searchby); + void HandleLoadedArt(int id, const QImage &image); + bool FindCachedPixmap(const TidalSearch::Result &result, QPixmap *pixmap) const; + QString PixmapCacheKey(const TidalSearch::Result &result) const; + void LoadArtAsync(int id, const Result &result); + void MaybeSearchFinished(int id); + void ShowConfig() {} + static QImage ScaleAndPad(const QImage &image); + + private: + struct DelayedSearch { + int id_; + QString query_; + TidalSettingsPage::SearchBy searchby_; + }; + + static const int kArtHeight; + + Application *app_; + TidalService *service_; + QString name_; + QString id_; + QIcon icon_; + QImage icon_as_image_; + int searches_next_id_; + int art_searches_next_id_; + + QMap delayed_searches_; + QMap pending_art_searches_; + QPixmapCache pixmap_cache_; + AlbumCoverLoaderOptions cover_loader_options_; + QMap cover_loader_tasks_; + + QMap pending_searches_; + +}; + +Q_DECLARE_METATYPE(TidalSearch::Result) +Q_DECLARE_METATYPE(TidalSearch::ResultList) + +#endif // TIDALSEARCH_H diff --git a/src/tidal/tidalsearchitemdelegate.cpp b/src/tidal/tidalsearchitemdelegate.cpp new file mode 100644 index 000000000..e1039f788 --- /dev/null +++ b/src/tidal/tidalsearchitemdelegate.cpp @@ -0,0 +1,35 @@ +/* + * Strawberry Music Player + * This code was part of Clementine (GlobalSearch) + * Copyright 2012, David Sansome + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include +#include + +#include "tidalsearchitemdelegate.h" +#include "tidalsearchview.h" + +TidalSearchItemDelegate::TidalSearchItemDelegate(TidalSearchView* view) + : CollectionItemDelegate(view), view_(view) {} + +void TidalSearchItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { + // Tell the view we painted this item so it can lazy load some art. + const_cast(view_)->LazyLoadArt(index); + + CollectionItemDelegate::paint(painter, option, index); +} diff --git a/src/tidal/tidalsearchitemdelegate.h b/src/tidal/tidalsearchitemdelegate.h new file mode 100644 index 000000000..26d4705e7 --- /dev/null +++ b/src/tidal/tidalsearchitemdelegate.h @@ -0,0 +1,41 @@ +/* + * Strawberry Music Player + * This code was part of Clementine (GlobalSearch) + * Copyright 2012, David Sansome + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef TIDALSEARCHITEMDELEGATE_H +#define TIDALSEARCHITEMDELEGATE_H + +#include +#include + +#include "collection/collectionview.h" + +class TidalSearchView; + +class TidalSearchItemDelegate : public CollectionItemDelegate { + public: + TidalSearchItemDelegate(TidalSearchView *view); + + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const; + + private: + TidalSearchView* view_; +}; + +#endif // TIDALSEARCHITEMDELEGATE_H diff --git a/src/tidal/tidalsearchmodel.cpp b/src/tidal/tidalsearchmodel.cpp new file mode 100644 index 000000000..6e9f87278 --- /dev/null +++ b/src/tidal/tidalsearchmodel.cpp @@ -0,0 +1,314 @@ +/* + * Strawberry Music Player + * This code was part of Clementine (GlobalSearch) + * Copyright 2012, David Sansome + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/mimedata.h" +#include "core/iconloader.h" +#include "tidalsearch.h" +#include "tidalsearchmodel.h" + +TidalSearchModel::TidalSearchModel(TidalSearch *engine, QObject *parent) + : QStandardItemModel(parent), + engine_(engine), + proxy_(nullptr), + use_pretty_covers_(true), + artist_icon_(IconLoader::Load("guitar")) { + + group_by_[0] = CollectionModel::GroupBy_Artist; + group_by_[1] = CollectionModel::GroupBy_Album; + group_by_[2] = CollectionModel::GroupBy_None; + + no_cover_icon_ = QPixmap(":/pictures/noalbumart.png").scaled(CollectionModel::kPrettyCoverSize, CollectionModel::kPrettyCoverSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + album_icon_ = no_cover_icon_; + +} + +void TidalSearchModel::AddResults(const TidalSearch::ResultList &results) { + + int sort_index = 0; + + for (const TidalSearch::Result &result : results) { + QStandardItem *parent = invisibleRootItem(); + + // Find (or create) the container nodes for this result if we can. + ContainerKey key; + key.provider_index_ = sort_index; + parent = BuildContainers(result.metadata_, parent, &key); + + // Create the item + QStandardItem *item = new QStandardItem; + item->setText(result.metadata_.TitleWithCompilationArtist()); + item->setData(QVariant::fromValue(result), Role_Result); + item->setData(sort_index, Role_ProviderIndex); + + parent->appendRow(item); + } + +} + +QStandardItem *TidalSearchModel::BuildContainers(const Song &s, QStandardItem *parent, ContainerKey *key, int level) { + + if (level >= 3) { + return parent; + } + + bool has_artist_icon = false; + bool has_album_icon = false; + QString display_text; + QString sort_text; + int unique_tag = -1; + int year = 0; + + switch (group_by_[level]) { + case CollectionModel::GroupBy_Artist: + if (s.is_compilation()) { + display_text = tr("Various artists"); + sort_text = "aaaaaa"; + } + else { + display_text = CollectionModel::TextOrUnknown(s.artist()); + sort_text = CollectionModel::SortTextForArtist(s.artist()); + } + has_artist_icon = true; + break; + + case CollectionModel::GroupBy_YearAlbum: + year = qMax(0, s.year()); + display_text = CollectionModel::PrettyYearAlbum(year, s.album()); + sort_text = CollectionModel::SortTextForNumber(year) + s.album(); + unique_tag = s.album_id(); + has_album_icon = true; + break; + + case CollectionModel::GroupBy_OriginalYearAlbum: + year = qMax(0, s.effective_originalyear()); + display_text = CollectionModel::PrettyYearAlbum(year, s.album()); + sort_text = CollectionModel::SortTextForNumber(year) + s.album(); + unique_tag = s.album_id(); + has_album_icon = true; + break; + + case CollectionModel::GroupBy_Year: + year = qMax(0, s.year()); + display_text = QString::number(year); + sort_text = CollectionModel::SortTextForNumber(year) + " "; + break; + + case CollectionModel::GroupBy_OriginalYear: + year = qMax(0, s.effective_originalyear()); + display_text = QString::number(year); + sort_text = CollectionModel::SortTextForNumber(year) + " "; + break; + + case CollectionModel::GroupBy_Composer: + display_text = s.composer(); + case CollectionModel::GroupBy_Performer: + display_text = s.performer(); + case CollectionModel::GroupBy_Disc: + display_text = s.disc(); + case CollectionModel::GroupBy_Grouping: + display_text = s.grouping(); + case CollectionModel::GroupBy_Genre: + if (display_text.isNull()) display_text = s.genre(); + case CollectionModel::GroupBy_Album: + unique_tag = s.album_id(); + if (display_text.isNull()) { + display_text = s.album(); + } + // fallthrough + case CollectionModel::GroupBy_AlbumArtist: + if (display_text.isNull()) display_text = s.effective_albumartist(); + display_text = CollectionModel::TextOrUnknown(display_text); + sort_text = CollectionModel::SortTextForArtist(display_text); + has_album_icon = true; + break; + + case CollectionModel::GroupBy_FileType: + display_text = s.TextForFiletype(); + sort_text = display_text; + break; + + case CollectionModel::GroupBy_Bitrate: + display_text = QString(s.bitrate(), 1); + sort_text = display_text; + break; + + case CollectionModel::GroupBy_Samplerate: + display_text = QString(s.samplerate(), 1); + sort_text = display_text; + break; + + case CollectionModel::GroupBy_Bitdepth: + display_text = QString(s.bitdepth(), 1); + sort_text = display_text; + break; + + case CollectionModel::GroupBy_None: + return parent; + } + + // Find a container for this level + key->group_[level] = display_text + QString::number(unique_tag); + QStandardItem *container = containers_[*key]; + if (!container) { + container = new QStandardItem(display_text); + container->setData(key->provider_index_, Role_ProviderIndex); + container->setData(sort_text, CollectionModel::Role_SortText); + container->setData(group_by_[level], CollectionModel::Role_ContainerType); + + if (has_artist_icon) { + container->setIcon(artist_icon_); + } + else if (has_album_icon) { + if (use_pretty_covers_) { + container->setData(no_cover_icon_, Qt::DecorationRole); + } + else { + container->setIcon(album_icon_); + } + } + + parent->appendRow(container); + containers_[*key] = container; + } + + // Create the container for the next level. + return BuildContainers(s, container, key, level + 1); + +} + +void TidalSearchModel::Clear() { + containers_.clear(); + clear(); +} + +TidalSearch::ResultList TidalSearchModel::GetChildResults(const QModelIndexList &indexes) const { + + QList items; + for (const QModelIndex &index : indexes) { + items << itemFromIndex(index); + } + return GetChildResults(items); + +} + +TidalSearch::ResultList TidalSearchModel::GetChildResults(const QList &items) const { + + TidalSearch::ResultList results; + QSet visited; + + for (QStandardItem *item : items) { + GetChildResults(item, &results, &visited); + } + + return results; + +} + +void TidalSearchModel::GetChildResults(const QStandardItem *item, TidalSearch::ResultList *results, QSet *visited) const { + + if (visited->contains(item)) { + return; + } + visited->insert(item); + + // Does this item have children? + if (item->rowCount()) { + const QModelIndex parent_proxy_index = proxy_->mapFromSource(item->index()); + + // Yes - visit all the children, but do so through the proxy so we get them + // in the right order. + for (int i = 0; i < item->rowCount(); ++i) { + const QModelIndex proxy_index = parent_proxy_index.child(i, 0); + const QModelIndex index = proxy_->mapToSource(proxy_index); + GetChildResults(itemFromIndex(index), results, visited); + } + } + else { + // No - maybe it's a song, add its result if valid + QVariant result = item->data(Role_Result); + if (result.isValid()) { + results->append(result.value()); + } + else { + // Maybe it's a provider then? + bool is_provider; + const int sort_index = item->data(Role_ProviderIndex).toInt(&is_provider); + if (is_provider) { + // Go through all the items (through the proxy to keep them ordered) and add the ones belonging to this provider to our list + for (int i = 0; i < proxy_->rowCount(invisibleRootItem()->index()); ++i) { + QModelIndex child_index = proxy_->index(i, 0, invisibleRootItem()->index()); + const QStandardItem *child_item = itemFromIndex(proxy_->mapToSource(child_index)); + if (child_item->data(Role_ProviderIndex).toInt() == sort_index) { + GetChildResults(child_item, results, visited); + } + } + } + } + } + +} + +QMimeData *TidalSearchModel::mimeData(const QModelIndexList &indexes) const { + return engine_->LoadTracks(GetChildResults(indexes)); +} + +namespace { +void GatherResults(const QStandardItem *parent, TidalSearch::ResultList *results) { + + QVariant result_variant = parent->data(TidalSearchModel::Role_Result); + if (result_variant.isValid()) { + TidalSearch::Result result = result_variant.value(); + (*results).append(result); + } + + for (int i = 0; i < parent->rowCount(); ++i) { + GatherResults(parent->child(i), results); + } +} +} + +void TidalSearchModel::SetGroupBy(const CollectionModel::Grouping &grouping, bool regroup_now) { + + const CollectionModel::Grouping old_group_by = group_by_; + group_by_ = grouping; + + if (regroup_now && group_by_ != old_group_by) { + // Walk the tree gathering the results we have already + TidalSearch::ResultList results; + GatherResults(invisibleRootItem(), &results); + + // Reset the model and re-add all the results using the new grouping. + Clear(); + AddResults(results); + } + +} diff --git a/src/tidal/tidalsearchmodel.h b/src/tidal/tidalsearchmodel.h new file mode 100644 index 000000000..d142516b1 --- /dev/null +++ b/src/tidal/tidalsearchmodel.h @@ -0,0 +1,109 @@ +/* + * Strawberry Music Player + * This code was part of Clementine (GlobalSearch) + * Copyright 2012, David Sansome + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef TIDALSEARCHMODEL_H +#define TIDALSEARCHMODEL_H + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "collection/collectionmodel.h" +#include "tidalsearch.h" + +class TidalSearchModel : public QStandardItemModel { + Q_OBJECT + + public: + TidalSearchModel(TidalSearch *engine, QObject *parent = nullptr); + + enum Role { + Role_Result = CollectionModel::LastRole, + Role_LazyLoadingArt, + Role_ProviderIndex, + LastRole + }; + + struct ContainerKey { + int provider_index_; + QString group_[3]; + }; + + void set_proxy(QSortFilterProxyModel *proxy) { proxy_ = proxy; } + void set_use_pretty_covers(bool pretty) { use_pretty_covers_ = pretty; } + void SetGroupBy(const CollectionModel::Grouping &grouping, bool regroup_now); + + void Clear(); + + TidalSearch::ResultList GetChildResults(const QModelIndexList &indexes) const; + TidalSearch::ResultList GetChildResults(const QList &items) const; + + QMimeData *mimeData(const QModelIndexList &indexes) const; + + public slots: + void AddResults(const TidalSearch::ResultList &results); + + private: + QStandardItem *BuildContainers(const Song &metadata, QStandardItem *parent, ContainerKey *key, int level = 0); + void GetChildResults(const QStandardItem *item, TidalSearch::ResultList *results, QSet *visited) const; + + private: + TidalSearch *engine_; + QSortFilterProxyModel *proxy_; + bool use_pretty_covers_; + QIcon artist_icon_; + QPixmap no_cover_icon_; + QIcon album_icon_; + CollectionModel::Grouping group_by_; + QMap containers_; + +}; + +inline uint qHash(const TidalSearchModel::ContainerKey &key) { + return qHash(key.provider_index_) ^ qHash(key.group_[0]) ^ qHash(key.group_[1]) ^ qHash(key.group_[2]); +} + +inline bool operator<(const TidalSearchModel::ContainerKey &left, const TidalSearchModel::ContainerKey &right) { +#define CMP(field) \ + if (left.field < right.field) return true; \ + if (left.field > right.field) return false + + CMP(provider_index_); + CMP(group_[0]); + CMP(group_[1]); + CMP(group_[2]); + return false; + +#undef CMP +} + +#endif // TIDALSEARCHMODEL_H diff --git a/src/tidal/tidalsearchsortmodel.cpp b/src/tidal/tidalsearchsortmodel.cpp new file mode 100644 index 000000000..f516998d0 --- /dev/null +++ b/src/tidal/tidalsearchsortmodel.cpp @@ -0,0 +1,79 @@ +/* + * Strawberry Music Player + * This code was part of Clementine (GlobalSearch) + * Copyright 2010, David Sansome + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include "config.h" + +#include +#include +#include + +#include "core/logging.h" +#include "tidalsearchmodel.h" +#include "tidalsearchsortmodel.h" + +TidalSearchSortModel::TidalSearchSortModel(QObject *parent) + : QSortFilterProxyModel(parent) {} + +bool TidalSearchSortModel::lessThan(const QModelIndex &left, const QModelIndex &right) const { + // Compare the provider sort index first. + const int index_left = left.data(TidalSearchModel::Role_ProviderIndex).toInt(); + const int index_right = right.data(TidalSearchModel::Role_ProviderIndex).toInt(); + if (index_left < index_right) return true; + if (index_left > index_right) return false; + + // Dividers always go first + if (left.data(CollectionModel::Role_IsDivider).toBool()) return true; + if (right.data(CollectionModel::Role_IsDivider).toBool()) return false; + + // Containers go before songs if they're at the same level + const bool left_is_container = left.data(CollectionModel::Role_ContainerType).isValid(); + const bool right_is_container = right.data(CollectionModel::Role_ContainerType).isValid(); + if (left_is_container && !right_is_container) return true; + if (right_is_container && !left_is_container) return false; + + // Containers get sorted on their sort text. + if (left_is_container) { + return QString::localeAwareCompare(left.data(CollectionModel::Role_SortText).toString(), right.data(CollectionModel::Role_SortText).toString()) < 0; + } + + // Otherwise we're comparing songs. Sort by disc, track, then title. + const TidalSearch::Result r1 = left.data(TidalSearchModel::Role_Result).value(); + const TidalSearch::Result r2 = right.data(TidalSearchModel::Role_Result).value(); + +#define CompareInt(field) \ + if (r1.metadata_.field() < r2.metadata_.field()) return true; \ + if (r1.metadata_.field() > r2.metadata_.field()) return false + + int ret = 0; + +#define CompareString(field) \ + ret = QString::localeAwareCompare(r1.metadata_.field(), r2.metadata_.field()); \ + if (ret < 0) return true; \ + if (ret > 0) return false + + CompareInt(disc); + CompareInt(track); + CompareString(title); + + return false; + +#undef CompareInt +#undef CompareString +} diff --git a/src/tidal/tidalsearchsortmodel.h b/src/tidal/tidalsearchsortmodel.h new file mode 100644 index 000000000..66a6ca9b3 --- /dev/null +++ b/src/tidal/tidalsearchsortmodel.h @@ -0,0 +1,35 @@ +/* + * Strawberry Music Player + * This code was part of Clementine (GlobalSearch) + * Copyright 2010, David Sansome + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef TIDALSEARCHSORTMODEL_H +#define TIDALSEARCHSORTMODEL_H + +#include +#include + +class TidalSearchSortModel : public QSortFilterProxyModel { + public: + TidalSearchSortModel(QObject *parent = nullptr); + + protected: + bool lessThan(const QModelIndex &left, const QModelIndex &right) const; +}; + +#endif // TIDALSEARCHSORTMODEL_H diff --git a/src/tidal/tidalsearchview.cpp b/src/tidal/tidalsearchview.cpp new file mode 100644 index 000000000..380895c9c --- /dev/null +++ b/src/tidal/tidalsearchview.cpp @@ -0,0 +1,544 @@ +/* + * Strawberry Music Player + * This code was part of Clementine (GlobalSearch) + * Copyright 2012, David Sansome + * Copyright 2018, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include "config.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/application.h" +#include "core/logging.h" +#include "core/mimedata.h" +#include "core/timeconstants.h" +#include "core/iconloader.h" +#include "internet/internetsongmimedata.h" +#include "collection/collectionfilterwidget.h" +#include "collection/collectionmodel.h" +#include "collection/groupbydialog.h" +#include "playlist/songmimedata.h" +#include "tidalsearch.h" +#include "tidalsearchitemdelegate.h" +#include "tidalsearchmodel.h" +#include "tidalsearchsortmodel.h" +#include "tidalsearchview.h" +#include "ui_tidalsearchview.h" +#include "settings/tidalsettingspage.h" + +using std::placeholders::_1; +using std::placeholders::_2; +using std::swap; + +const int TidalSearchView::kSwapModelsTimeoutMsec = 250; + +TidalSearchView::TidalSearchView(Application *app, QWidget *parent) + : QWidget(parent), + app_(app), + engine_(app_->tidal_search()), + ui_(new Ui_TidalSearchView), + context_menu_(nullptr), + last_search_id_(0), + front_model_(new TidalSearchModel(engine_, this)), + back_model_(new TidalSearchModel(engine_, this)), + current_model_(front_model_), + front_proxy_(new TidalSearchSortModel(this)), + back_proxy_(new TidalSearchSortModel(this)), + current_proxy_(front_proxy_), + swap_models_timer_(new QTimer(this)), + search_icon_(IconLoader::Load("search")), + warning_icon_(IconLoader::Load("dialog-warning")), + error_(false) { + + ui_->setupUi(this); + + front_model_->set_proxy(front_proxy_); + back_model_->set_proxy(back_proxy_); + + ui_->search->installEventFilter(this); + ui_->results_stack->installEventFilter(this); + + ui_->settings->setIcon(IconLoader::Load("configure")); + + // Must be a queued connection to ensure the TidalSearch handles it first. + connect(app_, SIGNAL(SettingsChanged()), SLOT(ReloadSettings()), Qt::QueuedConnection); + + connect(ui_->search, SIGNAL(textChanged(QString)), SLOT(TextEdited(QString))); + connect(ui_->results, SIGNAL(AddToPlaylistSignal(QMimeData*)), SIGNAL(AddToPlaylist(QMimeData*))); + connect(ui_->results, SIGNAL(FocusOnFilterSignal(QKeyEvent*)), SLOT(FocusOnFilter(QKeyEvent*))); + + // Set the appearance of the results list + ui_->results->setItemDelegate(new TidalSearchItemDelegate(this)); + ui_->results->setAttribute(Qt::WA_MacShowFocusRect, false); + ui_->results->setStyleSheet("QTreeView::item{padding-top:1px;}"); + + // Show the help page initially + ui_->results_stack->setCurrentWidget(ui_->help_page); + ui_->help_frame->setBackgroundRole(QPalette::Base); + + // Set the colour of the help text to the disabled window text colour + QPalette help_palette = ui_->label_helptext->palette(); + const QColor help_color = help_palette.color(QPalette::Disabled, QPalette::WindowText); + help_palette.setColor(QPalette::Normal, QPalette::WindowText, help_color); + help_palette.setColor(QPalette::Inactive, QPalette::WindowText, help_color); + ui_->label_helptext->setPalette(help_palette); + + // Make it bold + QFont help_font = ui_->label_helptext->font(); + help_font.setBold(true); + ui_->label_helptext->setFont(help_font); + + // Set up the sorting proxy model + front_proxy_->setSourceModel(front_model_); + front_proxy_->setDynamicSortFilter(true); + front_proxy_->sort(0); + + back_proxy_->setSourceModel(back_model_); + back_proxy_->setDynamicSortFilter(true); + back_proxy_->sort(0); + + swap_models_timer_->setSingleShot(true); + swap_models_timer_->setInterval(kSwapModelsTimeoutMsec); + connect(swap_models_timer_, SIGNAL(timeout()), SLOT(SwapModels())); + + // Add actions to the settings menu + group_by_actions_ = CollectionFilterWidget::CreateGroupByActions(this); + QMenu *settings_menu = new QMenu(this); + settings_menu->addActions(group_by_actions_->actions()); + settings_menu->addSeparator(); + settings_menu->addAction(IconLoader::Load("configure"), tr("Configure Tidal..."), this, SLOT(OpenSettingsDialog())); + ui_->settings->setMenu(settings_menu); + + connect(ui_->radiobutton_searchbyalbums, SIGNAL(clicked(bool)), SLOT(SearchByAlbumsClicked(bool))); + connect(ui_->radiobutton_searchbysongs, SIGNAL(clicked(bool)), SLOT(SearchBySongsClicked(bool))); + + connect(group_by_actions_, SIGNAL(triggered(QAction*)), SLOT(GroupByClicked(QAction*))); + + // These have to be queued connections because they may get emitted before our call to Search() (or whatever) returns and we add the ID to the map. + connect(engine_, SIGNAL(AddResults(int, TidalSearch::ResultList)), SLOT(AddResults(int, TidalSearch::ResultList)), Qt::QueuedConnection); + connect(engine_, SIGNAL(SearchError(int, QString)), SLOT(SearchError(int, QString)), Qt::QueuedConnection); + connect(engine_, SIGNAL(ArtLoaded(int, QPixmap)), SLOT(ArtLoaded(int, QPixmap)), Qt::QueuedConnection); + + ReloadSettings(); + +} + +TidalSearchView::~TidalSearchView() { delete ui_; } + +void TidalSearchView::ReloadSettings() { + + QSettings s; + + // Collection settings + + s.beginGroup(TidalSettingsPage::kSettingsGroup); + const bool pretty = s.value("pretty_covers", true).toBool(); + front_model_->set_use_pretty_covers(pretty); + back_model_->set_use_pretty_covers(pretty); + s.endGroup(); + + // Tidal search settings + + s.beginGroup(TidalSettingsPage::kSettingsGroup); + searchby_ = TidalSettingsPage::SearchBy(s.value("searchby", int(TidalSettingsPage::SearchBy_Songs)).toInt()); + switch (searchby_) { + case TidalSettingsPage::SearchBy_Songs: + ui_->radiobutton_searchbysongs->setChecked(true); + break; + case TidalSettingsPage::SearchBy_Albums: + ui_->radiobutton_searchbyalbums->setChecked(true); + break; + } + + SetGroupBy(CollectionModel::Grouping( + CollectionModel::GroupBy(s.value("group_by1", int(CollectionModel::GroupBy_Artist)).toInt()), + CollectionModel::GroupBy(s.value("group_by2", int(CollectionModel::GroupBy_Album)).toInt()), + CollectionModel::GroupBy(s.value("group_by3", int(CollectionModel::GroupBy_None)).toInt()))); + s.endGroup(); + +} + +void TidalSearchView::StartSearch(const QString &query) { + + ui_->search->setText(query); + TextEdited(query); + + // Swap models immediately + swap_models_timer_->stop(); + SwapModels(); + +} + +void TidalSearchView::TextEdited(const QString &text) { + + const QString trimmed(text.trimmed()); + + error_ = false; + + // Add results to the back model, switch models after some delay. + back_model_->Clear(); + current_model_ = back_model_; + current_proxy_ = back_proxy_; + swap_models_timer_->start(); + + // Cancel the last search (if any) and start the new one. + engine_->CancelSearch(last_search_id_); + // If text query is empty, don't start a new search + if (trimmed.isEmpty()) { + last_search_id_ = -1; + ui_->label_helptext->setText("Enter search terms above to find music"); + } + else { + last_search_id_ = engine_->SearchAsync(trimmed, searchby_); + } + +} + +void TidalSearchView::AddResults(int id, const TidalSearch::ResultList &results) { + if (id != last_search_id_) return; + if (results.isEmpty()) return; + current_model_->AddResults(results); +} + +void TidalSearchView::SearchError(const int id, const QString error) { + error_ = true; + ui_->label_helptext->setText(error); + ui_->results_stack->setCurrentWidget(ui_->help_page); +} + +void TidalSearchView::SwapModels() { + + art_requests_.clear(); + + std::swap(front_model_, back_model_); + std::swap(front_proxy_, back_proxy_); + + ui_->results->setModel(front_proxy_); + + if (ui_->search->text().trimmed().isEmpty() || error_) { + ui_->results_stack->setCurrentWidget(ui_->help_page); + } + else { + ui_->results_stack->setCurrentWidget(ui_->results_page); + } + +} + +void TidalSearchView::LazyLoadArt(const QModelIndex &proxy_index) { + + if (!proxy_index.isValid() || proxy_index.model() != front_proxy_) { + return; + } + + // Already loading art for this item? + if (proxy_index.data(TidalSearchModel::Role_LazyLoadingArt).isValid()) { + return; + } + + // Should we even load art at all? + if (!app_->collection_model()->use_pretty_covers()) { + return; + } + + // Is this an album? + const CollectionModel::GroupBy container_type = CollectionModel::GroupBy(proxy_index.data(CollectionModel::Role_ContainerType).toInt()); + if (container_type != CollectionModel::GroupBy_Album && + container_type != CollectionModel::GroupBy_AlbumArtist && + container_type != CollectionModel::GroupBy_YearAlbum && + container_type != CollectionModel::GroupBy_OriginalYearAlbum) { + return; + } + + // Mark the item as loading art + const QModelIndex source_index = front_proxy_->mapToSource(proxy_index); + QStandardItem *item = front_model_->itemFromIndex(source_index); + item->setData(true, TidalSearchModel::Role_LazyLoadingArt); + + // Walk down the item's children until we find a track + while (item->rowCount()) { + item = item->child(0); + } + + // Get the track's Result + const TidalSearch::Result result = item->data(TidalSearchModel::Role_Result).value(); + + // Load the art. + int id = engine_->LoadArtAsync(result); + art_requests_[id] = source_index; + +} + +void TidalSearchView::ArtLoaded(int id, const QPixmap &pixmap) { + + if (!art_requests_.contains(id)) return; + QModelIndex index = art_requests_.take(id); + + if (!pixmap.isNull()) { + front_model_->itemFromIndex(index)->setData(pixmap, Qt::DecorationRole); + } + +} + +MimeData *TidalSearchView::SelectedMimeData() { + + if (!ui_->results->selectionModel()) return nullptr; + + // Get all selected model indexes + QModelIndexList indexes = ui_->results->selectionModel()->selectedRows(); + if (indexes.isEmpty()) { + // There's nothing selected - take the first thing in the model that isn't a divider. + for (int i = 0; i < front_proxy_->rowCount(); ++i) { + QModelIndex index = front_proxy_->index(i, 0); + if (!index.data(CollectionModel::Role_IsDivider).toBool()) { + indexes << index; + ui_->results->setCurrentIndex(index); + break; + } + } + } + + // Still got nothing? Give up. + if (indexes.isEmpty()) { + return nullptr; + } + + // Get items for these indexes + QList items; + for (const QModelIndex &index : indexes) { + items << (front_model_->itemFromIndex(front_proxy_->mapToSource(index))); + } + + // Get a MimeData for these items + return engine_->LoadTracks(front_model_->GetChildResults(items)); + +} + +bool TidalSearchView::eventFilter(QObject *object, QEvent *event) { + + if (object == ui_->search && event->type() == QEvent::KeyRelease) { + if (SearchKeyEvent(static_cast(event))) { + return true; + } + } + else if (object == ui_->results_stack && event->type() == QEvent::ContextMenu) { + if (ResultsContextMenuEvent(static_cast(event))) { + return true; + } + } + + return QWidget::eventFilter(object, event); + +} + +bool TidalSearchView::SearchKeyEvent(QKeyEvent *event) { + + switch (event->key()) { + case Qt::Key_Up: + ui_->results->UpAndFocus(); + break; + + case Qt::Key_Down: + ui_->results->DownAndFocus(); + break; + + case Qt::Key_Escape: + ui_->search->clear(); + break; + + case Qt::Key_Return: + AddSelectedToPlaylist(); + break; + + default: + return false; + } + + event->accept(); + return true; + +} + +bool TidalSearchView::ResultsContextMenuEvent(QContextMenuEvent *event) { + + context_menu_ = new QMenu(this); + context_actions_ << context_menu_->addAction( IconLoader::Load("media-playback-start"), tr("Append to current playlist"), this, SLOT(AddSelectedToPlaylist())); + context_actions_ << context_menu_->addAction( IconLoader::Load("media-playback-start"), tr("Replace current playlist"), this, SLOT(LoadSelected())); + context_actions_ << context_menu_->addAction( IconLoader::Load("document-new"), tr("Open in new playlist"), this, SLOT(OpenSelectedInNewPlaylist())); + + context_menu_->addSeparator(); + context_actions_ << context_menu_->addAction(IconLoader::Load("go-next"), tr("Queue track"), this, SLOT(AddSelectedToPlaylistEnqueue())); + + context_menu_->addSeparator(); + + if (ui_->results->selectionModel() && ui_->results->selectionModel()->selectedRows().length() == 1) { + context_actions_ << context_menu_->addAction(IconLoader::Load("system-search"), tr("Search for this"), this, SLOT(SearchForThis())); + } + + context_menu_->addSeparator(); + context_menu_->addMenu(tr("Group by"))->addActions(group_by_actions_->actions()); + context_menu_->addAction(IconLoader::Load("configure"), tr("Configure Tidal..."), this, SLOT(OpenSettingsDialog())); + + const bool enable_context_actions = ui_->results->selectionModel() && ui_->results->selectionModel()->hasSelection(); + + for (QAction *action : context_actions_) { + action->setEnabled(enable_context_actions); + } + + context_menu_->popup(event->globalPos()); + + return true; + +} + +void TidalSearchView::AddSelectedToPlaylist() { + emit AddToPlaylist(SelectedMimeData()); +} + +void TidalSearchView::LoadSelected() { + MimeData *data = SelectedMimeData(); + if (!data) return; + + data->clear_first_ = true; + emit AddToPlaylist(data); +} + +void TidalSearchView::AddSelectedToPlaylistEnqueue() { + MimeData *data = SelectedMimeData(); + if (!data) return; + + data->enqueue_now_ = true; + emit AddToPlaylist(data); +} + +void TidalSearchView::OpenSelectedInNewPlaylist() { + MimeData *data = SelectedMimeData(); + if (!data) return; + + data->open_in_new_playlist_ = true; + emit AddToPlaylist(data); +} + +void TidalSearchView::SearchForThis() { + StartSearch(ui_->results->selectionModel()->selectedRows().first().data().toString()); +} + +void TidalSearchView::showEvent(QShowEvent *e) { + QWidget::showEvent(e); + FocusSearchField(); +} + +void TidalSearchView::FocusSearchField() { + ui_->search->setFocus(); + ui_->search->selectAll(); +} + +void TidalSearchView::hideEvent(QHideEvent *e) { + QWidget::hideEvent(e); +} + +void TidalSearchView::FocusOnFilter(QKeyEvent *event) { + ui_->search->setFocus(); + QApplication::sendEvent(ui_->search, event); +} + +void TidalSearchView::OpenSettingsDialog() { + app_->OpenSettingsDialogAtPage(SettingsDialog::Page_Tidal); +} + +void TidalSearchView::GroupByClicked(QAction *action) { + + if (action->property("group_by").isNull()) { + if (!group_by_dialog_) { + group_by_dialog_.reset(new GroupByDialog); + connect(group_by_dialog_.data(), SIGNAL(Accepted(CollectionModel::Grouping)), SLOT(SetGroupBy(CollectionModel::Grouping))); + } + + group_by_dialog_->show(); + return; + } + + SetGroupBy(action->property("group_by").value()); + +} + +void TidalSearchView::SetGroupBy(const CollectionModel::Grouping &g) { + + // Clear requests: changing "group by" on the models will cause all the items to be removed/added again, + // so all the QModelIndex here will become invalid. New requests will be created for those + // songs when they will be displayed again anyway (when TidalSearchItemDelegate::paint will call LazyLoadArt) + art_requests_.clear(); + // Update the models + front_model_->SetGroupBy(g, true); + back_model_->SetGroupBy(g, false); + + // Save the setting + QSettings s; + s.beginGroup(TidalSettingsPage::kSettingsGroup); + s.setValue("group_by1", int(g.first)); + s.setValue("group_by2", int(g.second)); + s.setValue("group_by3", int(g.third)); + s.endGroup(); + + // Make sure the correct action is checked. + for (QAction *action : group_by_actions_->actions()) { + if (action->property("group_by").isNull()) continue; + + if (g == action->property("group_by").value()) { + action->setChecked(true); + return; + } + } + + // Check the advanced action + group_by_actions_->actions().last()->setChecked(true); + +} + +void TidalSearchView::SearchBySongsClicked(bool checked) { + SetSearchBy(TidalSettingsPage::SearchBy_Songs); +} + +void TidalSearchView::SearchByAlbumsClicked(bool checked) { + SetSearchBy(TidalSettingsPage::SearchBy_Albums); +} + +void TidalSearchView::SetSearchBy(TidalSettingsPage::SearchBy searchby) { + searchby_ = searchby; + QSettings s; + s.beginGroup(TidalSettingsPage::kSettingsGroup); + s.setValue("searchby", int(searchby)); + s.endGroup(); + TextEdited(ui_->search->text()); +} diff --git a/src/tidal/tidalsearchview.h b/src/tidal/tidalsearchview.h new file mode 100644 index 000000000..d4dfe7a8d --- /dev/null +++ b/src/tidal/tidalsearchview.h @@ -0,0 +1,139 @@ +/* + * Strawberry Music Player + * This code was part of Clementine (GlobalSearch) + * Copyright 2012, David Sansome + * Copyright 2018, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef TIDALSEARCHVIEW_H +#define TIDALSEARCHVIEW_H + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "collection/collectionmodel.h" +#include "settings/settingsdialog.h" +#include "playlist/playlistmanager.h" +#include "tidalsearch.h" +#include "settings/tidalsettingspage.h" + +class Application; +class GroupByDialog; +class TidalSearchModel; +class Ui_TidalSearchView; + +class TidalSearchView : public QWidget { + Q_OBJECT + + public: + TidalSearchView(Application *app, QWidget *parent = nullptr); + ~TidalSearchView(); + + static const int kSwapModelsTimeoutMsec; + + void LazyLoadArt(const QModelIndex &index); + + void showEvent(QShowEvent *e); + void hideEvent(QHideEvent *e); + bool eventFilter(QObject *object, QEvent *event); + + public slots: + void ReloadSettings(); + void StartSearch(const QString &query); + void FocusSearchField(); + void OpenSettingsDialog(); + +signals: + void AddToPlaylist(QMimeData *data); + + private slots: + void SwapModels(); + void TextEdited(const QString &text); + void AddResults(int id, const TidalSearch::ResultList &results); + void SearchError(const int id, const QString error); + void ArtLoaded(int id, const QPixmap &pixmap); + + void FocusOnFilter(QKeyEvent *event); + + void AddSelectedToPlaylist(); + void LoadSelected(); + void OpenSelectedInNewPlaylist(); + void AddSelectedToPlaylistEnqueue(); + + void SearchForThis(); + + void SearchBySongsClicked(bool); + void SearchByAlbumsClicked(bool); + void GroupByClicked(QAction *action); + void SetSearchBy(TidalSettingsPage::SearchBy searchby); + void SetGroupBy(const CollectionModel::Grouping &g); + + private: + MimeData *SelectedMimeData(); + + bool SearchKeyEvent(QKeyEvent *event); + bool ResultsContextMenuEvent(QContextMenuEvent *event); + + Application *app_; + TidalSearch *engine_; + Ui_TidalSearchView *ui_; + QScopedPointer group_by_dialog_; + + QMenu *context_menu_; + QList context_actions_; + QActionGroup *group_by_actions_; + + int last_search_id_; + + // Like graphics APIs have a front buffer and a back buffer, there's a front model and a back model + // The front model is the one that's shown in the UI and the back model is the one that lies in wait. + // current_model_ will point to either the front or the back model. + TidalSearchModel *front_model_; + TidalSearchModel *back_model_; + TidalSearchModel *current_model_; + + QSortFilterProxyModel *front_proxy_; + QSortFilterProxyModel *back_proxy_; + QSortFilterProxyModel *current_proxy_; + + QMap art_requests_; + + QTimer *swap_models_timer_; + + QIcon search_icon_; + QIcon warning_icon_; + + TidalSettingsPage::SearchBy searchby_; + bool error_; + +}; + +#endif // TIDALSEARCHVIEW_H diff --git a/src/tidal/tidalsearchview.ui b/src/tidal/tidalsearchview.ui new file mode 100644 index 000000000..a1cab7181 --- /dev/null +++ b/src/tidal/tidalsearchview.ui @@ -0,0 +1,259 @@ + + + TidalSearchView + + + + 0 + 0 + 437 + 633 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + + + + + + + Search for anything + + + + + + + + 20 + 0 + + + + QToolButton::InstantPopup + + + true + + + + + + + + + QLayout::SetFixedSize + + + + + true + + + Search by + + + 10 + + + + + + + albu&ms + + + + + + + songs + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + 1 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QAbstractItemView::NoEditTriggers + + + true + + + QAbstractItemView::DragOnly + + + QAbstractItemView::ExtendedSelection + + + true + + + false + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::ScrollBarAlwaysOff + + + true + + + + + 0 + 0 + 435 + 533 + + + + + + 9 + 109 + 420 + 100 + + + + + 32 + + + 16 + + + 32 + + + 64 + + + + + + 0 + 80 + + + + Enter search terms above to find music + + + Qt::AlignCenter + + + true + + + + + + + + + + + + + + + + + QSearchField + QWidget +
3rdparty/qocoa/qsearchfield.h
+
+ + AutoExpandingTreeView + QTreeView +
widgets/autoexpandingtreeview.h
+
+
+ + +
diff --git a/src/tidal/tidalservice.cpp b/src/tidal/tidalservice.cpp new file mode 100644 index 000000000..b93f262ec --- /dev/null +++ b/src/tidal/tidalservice.cpp @@ -0,0 +1,832 @@ +/* + * Strawberry Music Player + * Copyright 2018, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/application.h" +#include "core/closure.h" +#include "core/logging.h" +#include "core/mergedproxymodel.h" +#include "core/network.h" +#include "core/song.h" +#include "core/iconloader.h" +#include "core/taskmanager.h" +#include "core/timeconstants.h" +#include "core/utilities.h" +#include "internet/internetmodel.h" +#include "tidalservice.h" +#include "tidalsearch.h" +#include "settings/tidalsettingspage.h" + +const char *TidalService::kServiceName = "Tidal"; +const char *TidalService::kApiUrl = "https://listen.tidal.com/v1"; +const char *TidalService::kAuthUrl = "https://listen.tidal.com/v1/login/username"; +const char *TidalService::kResourcesUrl = "http://resources.tidal.com"; +const char *TidalService::kApiToken = "P5Xbeo5LFvESeDy6"; + +const int TidalService::kSearchDelayMsec = 1500; +const int TidalService::kSearchAlbumsLimit = 40; +const int TidalService::kSearchTracksLimit = 10; + +typedef QPair Param; + +TidalService::TidalService(Application *app, InternetModel *parent) + : InternetService(kServiceName, app, parent, parent), + network_(new NetworkAccessManager(this)), + search_delay_(new QTimer(this)), + pending_search_id_(0), + next_pending_search_id_(1), + search_requests_(0), + login_sent_(false) { + + search_delay_->setInterval(kSearchDelayMsec); + search_delay_->setSingleShot(true); + connect(search_delay_, SIGNAL(timeout()), SLOT(StartSearch())); + + ReloadSettings(); + LoadSessionID(); + +} + +TidalService::~TidalService() {} + +void TidalService::ShowConfig() { + app_->OpenSettingsDialogAtPage(SettingsDialog::Page_Tidal); +} + +void TidalService::ReloadSettings() { + + QSettings s; + s.beginGroup(TidalSettingsPage::kSettingsGroup); + username_ = s.value("username").toString(); + password_ = s.value("password").toString(); + quality_ = s.value("quality").toString(); + s.endGroup(); + +} + +void TidalService::LoadSessionID() { + + QSettings s; + s.beginGroup(TidalSettingsPage::kSettingsGroup); + if (!s.contains("user_id") ||!s.contains("session_id") || !s.contains("country_code")) return; + session_id_ = s.value("session_id").toString(); + user_id_ = s.value("user_id").toInt(); + country_code_ = s.value("country_code").toString(); + s.endGroup(); + +} + +void TidalService::Login(const QString &username, const QString &password) { + Login(nullptr, username, password); +} + +void TidalService::Login(TidalSearchContext *search_ctx, const QString &username, const QString &password) { + + login_sent_ = true; + + int id = 0; + if (search_ctx) { + search_ctx->login_sent = true; + search_ctx->login_attempts++; + id = search_ctx->id; + } + + typedef QPair Arg; + typedef QList ArgList; + + typedef QPair EncodedArg; + typedef QList EncodedArgList; + + ArgList args = ArgList() <post(req, url_query.toString(QUrl::FullyEncoded).toUtf8()); + NewClosure(reply, SIGNAL(finished()), this, SLOT(HandleAuthReply(QNetworkReply*, int)), reply, id); + +} + +void TidalService::HandleAuthReply(QNetworkReply *reply, int id) { + + reply->deleteLater(); + + login_sent_ = false; + + TidalSearchContext *search_ctx(nullptr); + if (id != 0 && requests_search_.contains(id)) { + search_ctx = requests_search_.value(id); + search_ctx->login_sent = false; + } + + //int http_status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (reply->error() != QNetworkReply::NoError) { + if (reply->error() < 200) { + // This is a network error, there is nothing more to do. + QString failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + if (search_ctx) Error(search_ctx, failure_reason); + emit LoginFailure(failure_reason); + return; + } + else { + // See if there is Json data containing "userMessage" - then use that instead. + QByteArray data(reply->readAll()); + QJsonParseError error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &error); + QString failure_reason; + if (error.error == QJsonParseError::NoError && !json_doc.isNull() && !json_doc.isEmpty() && json_doc.isObject()) { + QJsonObject json_obj = json_doc.object(); + if (!json_obj.isEmpty() && json_obj.contains("userMessage")) { + failure_reason = QString("Authentication failure: %1").arg(json_obj["userMessage"].toString()); + } + else { + failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + } + else { + failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + if (search_ctx) Error(search_ctx, failure_reason); + emit LoginFailure(failure_reason); + return; + } + } + + QByteArray data(reply->readAll()); + QJsonParseError error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &error); + + if (error.error != QJsonParseError::NoError) { + QString failure_reason("Authentication reply from server missing Json data."); + if (search_ctx) Error(search_ctx, failure_reason); + emit LoginFailure(failure_reason); + return; + } + + if (json_doc.isNull() || json_doc.isEmpty()) { + QString failure_reason("Authentication reply from server has empty Json document."); + if (search_ctx) Error(search_ctx, failure_reason); + emit LoginFailure(failure_reason); + return; + } + + if (!json_doc.isObject()) { + QString failure_reason("Authentication reply from server has Json document that is not an object."); + if (search_ctx) Error(search_ctx, failure_reason); + emit LoginFailure(failure_reason); + return; + } + + QJsonObject json_obj = json_doc.object(); + if (json_obj.isEmpty()) { + QString failure_reason("Authentication reply from server has empty Json object."); + if (search_ctx) Error(search_ctx, failure_reason); + emit LoginFailure(failure_reason); + return; + } + + if ( !json_obj.contains("userId") || !json_obj.contains("sessionId") || !json_obj.contains("countryCode") ) { + QString failure_reason = tr("Authentication reply from server is missing userId, sessionId or countryCode"); + if (search_ctx) Error(search_ctx, failure_reason); + emit LoginFailure(failure_reason); + return; + } + + country_code_ = json_obj["countryCode"].toString(); + session_id_ = json_obj["sessionId"].toString(); + user_id_ = json_obj["userId"].toInt(); + + QSettings s; + s.beginGroup(TidalSettingsPage::kSettingsGroup); + s.setValue("user_id", user_id_); + s.setValue("session_id", session_id_); + s.setValue("country_code", country_code_); + s.endGroup(); + + qLog(Debug) << "Tidal: Login successful" << "user id" << user_id_ << "session id" << session_id_ << "country code" << country_code_; + + if (search_ctx) { + qLog(Debug) << "Tidal: Resuming search"; + SendSearch(search_ctx); + } + + emit LoginSuccess(); + +} + +void TidalService::Logout() { + + user_id_ = 0; + session_id_.clear(); + country_code_.clear(); + + QSettings s; + s.beginGroup(TidalSettingsPage::kSettingsGroup); + s.remove("user_id"); + s.remove("session_id"); + s.remove("country_code"); + s.endGroup(); + +} + +QNetworkReply *TidalService::CreateRequest(const QString &ressource_name, const QList ¶ms) { + + typedef QPair Arg; + typedef QList ArgList; + + typedef QPair EncodedArg; + typedef QList EncodedArgList; + + ArgList args = ArgList() << params + << Arg("sessionId", session_id_) + << Arg("countryCode", country_code_); + + QStringList query_items; + QUrlQuery url_query; + for (const Arg& arg : args) { + EncodedArg encoded_arg(QUrl::toPercentEncoding(arg.first), QUrl::toPercentEncoding(arg.second)); + query_items << QString(encoded_arg.first + "=" + encoded_arg.second); + url_query.addQueryItem(encoded_arg.first, encoded_arg.second); + } + + QUrl url(kApiUrl + QString("/") + ressource_name); + url.setQuery(url_query); + QNetworkRequest req(url); + QNetworkReply *reply = network_->get(req); + + //qLog(Debug) << "Tidal: Sending request" << url; + + return reply; + +} + +QJsonObject TidalService::ExtractJsonObj(TidalSearchContext *search_ctx, QNetworkReply *reply) { + + QByteArray data; + + //int http_status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (reply->error() == QNetworkReply::NoError) { + data = reply->readAll(); + } + else { + if (reply->error() < 200) { + // This is a network error, there is nothing more to do. + QString failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + Error(search_ctx, failure_reason); + } + else { + // See if there is Json data containing "userMessage" - then use that instead. + data = reply->readAll(); + QJsonParseError error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &error); + QString failure_reason; + if (error.error == QJsonParseError::NoError && !json_doc.isNull() && !json_doc.isEmpty() && json_doc.isObject()) { + QJsonObject json_obj = json_doc.object(); + if (!json_obj.isEmpty() && json_obj.contains("userMessage")) { + failure_reason = json_obj["userMessage"].toString(); + } + else { + failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + } + else { + failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + if (reply->error() == QNetworkReply::ContentAccessDenied || reply->error() == QNetworkReply::ContentOperationNotPermittedError || reply->error() == QNetworkReply::AuthenticationRequiredError) { + // Session is probably expired, attempt to login once + Logout(); + if (search_ctx->login_attempts < 1 && !username_.isEmpty() && !password_.isEmpty()) { + qLog(Error) << "Tidal:" << failure_reason; + qLog(Error) << "Tidal:" << QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + qLog(Error) << "Tidal:" << "Attempting to login."; + Login(search_ctx, username_, password_); + } + else { + Error(search_ctx, failure_reason); + } + } + else if (reply->error() == QNetworkReply::ContentNotFoundError) { // Ignore this error + qLog(Error) << "Tidal:" << failure_reason; + } + else { // Fail + Error(search_ctx, failure_reason); + } + } + return QJsonObject(); + } + + QJsonParseError error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &error); + + if (error.error != QJsonParseError::NoError) { + Error(search_ctx, "Reply from server missing Json data."); + return QJsonObject(); + } + + if (json_doc.isNull() || json_doc.isEmpty()) { + Error(search_ctx, "Received empty Json document."); + return QJsonObject(); + } + + if (!json_doc.isObject()) { + Error(search_ctx, "Json document is not an object."); + return QJsonObject(); + } + + QJsonObject json_obj = json_doc.object(); + if (json_obj.isEmpty()) { + Error(search_ctx, "Received empty Json object."); + return QJsonObject(); + } + + //qLog(Debug) << json_obj; + + return json_obj; + +} + +QJsonArray TidalService::ExtractItems(TidalSearchContext *search_ctx, QNetworkReply *reply) { + + QJsonObject json_obj = ExtractJsonObj(search_ctx, reply); + if (json_obj.isEmpty()) return QJsonArray(); + + if (!json_obj.contains("items")) { + Error(search_ctx, "Json reply is missing items."); + return QJsonArray(); + } + + QJsonArray json_items = json_obj["items"].toArray(); + if (json_items.isEmpty()) { + Error(search_ctx, "No match."); + return QJsonArray(); + } + + return json_items; + +} + +int TidalService::Search(const QString &text, TidalSettingsPage::SearchBy searchby) { + + pending_search_id_ = next_pending_search_id_; + pending_search_ = text; + pending_searchby_ = searchby; + + next_pending_search_id_++; + + if (text.isEmpty()) { + search_delay_->stop(); + return pending_search_id_; + } + search_delay_->start(); + + return pending_search_id_; + +} + +void TidalService::StartSearch() { + + if (username_.isEmpty() || password_.isEmpty()) { + emit SearchError(pending_search_id_, "Missing username and/or password."); + next_pending_search_id_ = 1; + ShowConfig(); + return; + } + + TidalSearchContext *search_ctx = CreateSearch(pending_search_id_, pending_search_); + if (authenticated()) SendSearch(search_ctx); + else Login(search_ctx, username_, password_); + +} + +TidalSearchContext *TidalService::CreateSearch(const int search_id, const QString text) { + + TidalSearchContext *search_ctx = new TidalSearchContext; + search_ctx->id = search_id; + search_ctx->text = text; + search_ctx->album_requests = 0; + search_ctx->song_requests = 0; + search_ctx->requests_album_.clear(); + search_ctx->requests_song_.clear(); + search_ctx->login_attempts = 0; + requests_search_.insert(search_id, search_ctx); + return search_ctx; + +} + +void TidalService::SendSearch(TidalSearchContext *search_ctx) { + + QList parameters; + parameters << Param("query", search_ctx->text); + + QString searchparam; + switch (pending_searchby_) { + case TidalSettingsPage::SearchBy_Songs: + searchparam = "search/tracks"; + parameters << Param("limit", QString::number(kSearchTracksLimit)); + break; + case TidalSettingsPage::SearchBy_Albums: + default: + searchparam = "search/albums"; + parameters << Param("limit", QString::number(kSearchAlbumsLimit)); + break; + } + + QNetworkReply *reply = CreateRequest(searchparam, parameters); + NewClosure(reply, SIGNAL(finished()), this, SLOT(SearchFinished(QNetworkReply*, int)), reply, search_ctx->id); + +} + +void TidalService::SearchFinished(QNetworkReply *reply, int id) { + + reply->deleteLater(); + + if (!requests_search_.contains(id)) return; + TidalSearchContext *search_ctx = requests_search_.value(id); + + QJsonArray json_items = ExtractItems(search_ctx, reply); + if (json_items.isEmpty()) { + CheckFinish(search_ctx); + return; + } + + //qLog(Debug) << json_items; + + QVector albums; + for (const QJsonValue &value : json_items) { + if (!value.isObject()) { + qLog(Error) << "Tidal: Invalid Json reply, item not a object."; + qLog(Debug) << value; + continue; + } + QJsonObject json_obj = value.toObject(); + //qLog(Debug) << json_obj; + int album_id(0); + QString album(""); + if (json_obj.contains("type")) { + // This was a albums search + if (!json_obj.contains("id") || !json_obj.contains("title")) { + qLog(Error) << "Tidal: Invalid Json reply, item is missing ID or title."; + qLog(Debug) << json_obj; + continue; + } + album_id = json_obj["id"].toInt(); + album = json_obj["title"].toString(); + } + else if (json_obj.contains("album")) { + // This was a tracks search + QJsonValue json_value_album = json_obj["album"]; + if (!json_value_album.isObject()) { + qLog(Error) << "Tidal: Invalid Json reply, item album is not a object."; + qLog(Debug) << json_value_album; + continue; + } + QJsonObject json_album = json_value_album.toObject(); + if (!json_album.contains("id") || !json_album.contains("title")) { + qLog(Error) << "Tidal: Invalid Json reply, item album is missing ID or title."; + qLog(Debug) << json_album; + continue; + } + album_id = json_album["id"].toInt(); + album = json_album["title"].toString(); + } + else { + qLog(Error) << "Tidal: Invalid Json reply, item missing type or album."; + qLog(Debug) << json_obj; + continue; + } + + if (search_ctx->requests_album_.contains(album_id)) continue; + + if (!json_obj.contains("artist") || !json_obj.contains("title") || !json_obj.contains("audioQuality")) { + qLog(Error) << "Tidal: Invalid Json reply, item missing artist, title or audioQuality."; + qLog(Debug) << json_obj; + continue; + } + QJsonValue json_value_artist = json_obj["artist"]; + if (!json_value_artist.isObject()) { + qLog(Error) << "Tidal: Invalid Json reply, item artist is not a object."; + qLog(Debug) << json_value_artist; + continue; + } + QJsonObject json_artist = json_value_artist.toObject(); + if (!json_artist.contains("name")) { + qLog(Error) << "Tidal: Invalid Json reply, item artist missing name."; + qLog(Debug) << json_artist; + continue; + } + QString artist = json_artist["name"].toString(); + + QString quality = json_obj["audioQuality"].toString(); + + //qLog(Debug) << "Tidal:" << artist << album << quality; + + QString artist_album(QString("%1-%2").arg(artist).arg(album)); + if (albums.contains(artist_album)) { + qLog(Debug) << "Tidal: Skipping duplicate album" << artist << album << quality; + continue; + } + albums.insert(0, artist_album); + + search_ctx->requests_album_.insert(album_id, album_id); + GetAlbum(search_ctx, album_id); + search_ctx->album_requests++; + if (search_ctx->album_requests >= kSearchAlbumsLimit) break; + } + + CheckFinish(search_ctx); + +} + +void TidalService::GetAlbum(TidalSearchContext *search_ctx, const int album_id) { + + QList parameters; + parameters << Param("token", session_id_) + << Param("soundQuality", quality_); + + QNetworkReply *reply = CreateRequest(QString("albums/%1/tracks").arg(album_id), parameters); + + NewClosure(reply, SIGNAL(finished()), this, SLOT(GetAlbumFinished(QNetworkReply*, int, int)), reply, search_ctx->id, album_id); + +} + +void TidalService::GetAlbumFinished(QNetworkReply *reply, int search_id, int album_id) { + + reply->deleteLater(); + + if (!requests_search_.contains(search_id)) return; + TidalSearchContext *search_ctx = requests_search_.value(search_id); + + if (!search_ctx->requests_album_.contains(album_id)) return; + search_ctx->album_requests--; + + QJsonArray json_items = ExtractItems(search_ctx, reply); + if (json_items.isEmpty()) { + CheckFinish(search_ctx); + return; + } + + bool compilation = false; + bool multidisc = false; + Song *first_song(nullptr); + QList songs; + for (const QJsonValue &value : json_items) { + Song *song = ParseSong(search_ctx, album_id, value); + if (!song) continue; + songs << song; + if (song->disc() >= 2) multidisc = true; + if (song->is_compilation() || (first_song && song->artist() != first_song->artist())) compilation = true; + if (!first_song) first_song = song; + } + if (compilation || multidisc) { + for (Song *song : songs) { + if (compilation) song->set_compilation_detected(true); + if (multidisc) { + QString album_full(QString("%1 - (Disc %2)").arg(song->album()).arg(song->disc())); + song->set_album(album_full); + } + } + } + + CheckFinish(search_ctx); + +} + +Song *TidalService::ParseSong(TidalSearchContext *search_ctx, const int album_id, const QJsonValue &value) { + + Song song; + + if (!value.isObject()) { + qLog(Error) << "Tidal: Invalid Json reply, track is not a object."; + qLog(Debug) << value; + return nullptr; + } + QJsonObject json_obj = value.toObject(); + + //qLog(Debug) << json_obj; + + if ( + !json_obj.contains("album") || + !json_obj.contains("allowStreaming") || + !json_obj.contains("artist") || + !json_obj.contains("artists") || + !json_obj.contains("audioQuality") || + !json_obj.contains("duration") || + !json_obj.contains("id") || + !json_obj.contains("streamReady") || + !json_obj.contains("title") || + !json_obj.contains("trackNumber") || + !json_obj.contains("url") || + !json_obj.contains("volumeNumber") + ) { + qLog(Error) << "Tidal: Invalid Json reply, track is missing one or more values."; + qLog(Debug) << json_obj; + return nullptr; + } + + QJsonValue json_value_artist = json_obj["artist"]; + QJsonValue json_value_album = json_obj["album"]; + QJsonValue json_duration = json_obj["duration"]; + QJsonArray json_artists = json_obj["artists"].toArray(); + + int id = json_obj["id"].toInt(); + QString title = json_obj["title"].toString(); + QString url = json_obj["url"].toString(); + int track = json_obj["trackNumber"].toInt(); + int disc = json_obj["volumeNumber"].toInt(); + bool allow_streaming = json_obj["allowStreaming"].toBool(); + bool stream_ready = json_obj["streamReady"].toBool(); + + if (!json_value_artist.isObject()) { + qLog(Error) << "Tidal: Invalid Json reply, track artist is not a object."; + qLog(Debug) << json_value_artist; + return nullptr; + } + QJsonObject json_artist = json_value_artist.toObject(); + if (!json_artist.contains("name")) { + qLog(Error) << "Tidal: Invalid Json reply, track artist is missing name."; + qLog(Debug) << json_artist; + return nullptr; + } + QString artist = json_artist["name"].toString(); + + if (!json_value_album.isObject()) { + qLog(Error) << "Tidal: Invalid Json reply, track album is not a object."; + qLog(Debug) << json_value_album; + return nullptr; + } + QJsonObject json_album = json_value_album.toObject(); + if (!json_album.contains("title") || !json_album.contains("cover")) { + qLog(Error) << "Tidal: Invalid Json reply, track album is missing title or cover."; + qLog(Debug) << json_album; + return nullptr; + } + QString album = json_album["title"].toString(); + QString cover = json_album["cover"].toString(); + + if (!allow_streaming || !stream_ready) { + qLog(Error) << "Tidal: Skipping song" << artist << album << title << "because allowStreaming is false OR streamReady is false."; + qLog(Debug) << json_obj; + return nullptr; + } + + //qLog(Debug) << "id" << id << "track" << track << "disc" << disc << "title" << title << "album" << album << "artist" << artist << cover << allow_streaming << url; + + song.set_album_id(album_id); + song.set_artist(artist); + song.set_album(album); + song.set_title(title); + song.set_track(track); + song.set_disc(disc); + song.set_bitrate(0); + song.set_samplerate(0); + song.set_bitdepth(0); + + QVariant q_duration = json_duration.toVariant(); + if (q_duration.isValid()) { + quint64 duration = q_duration.toULongLong() * kNsecPerSec; + song.set_length_nanosec(duration); + } + + // Check and see if there is more than 1 artist on the song. + //int i = 0; + //for (const QJsonValue &a : json_artists) { + //i++; + //qLog(Debug) << a << i; + //} + //if (i > 1) song.set_compilation_detected(true); + + cover = cover.replace("-", "/"); + QUrl cover_url (QString("%1/images/%2/750x750.jpg").arg(kResourcesUrl).arg(cover)); + song.set_art_automatic(cover_url.toEncoded()); + + if (search_ctx->requests_song_.contains(id)) return search_ctx->requests_song_.value(id); + Song *song_new = new Song(song); + search_ctx->requests_song_.insert(id, song_new); + search_ctx->song_requests++; + GetStreamURL(search_ctx, album_id, id); + + return song_new; + +} + +void TidalService::GetStreamURL(TidalSearchContext *search_ctx, const int album_id, const int song_id) { + + QList parameters; + parameters << Param("token", session_id_) + << Param("soundQuality", quality_); + + QNetworkReply *reply = CreateRequest(QString("tracks/%1/streamUrl").arg(song_id), parameters); + + NewClosure(reply, SIGNAL(finished()), this, SLOT(GetStreamURLFinished(QNetworkReply*, int, int)), reply, search_ctx->id, song_id); + +} + +void TidalService::GetStreamURLFinished(QNetworkReply *reply, const int search_id, const int song_id) { + + reply->deleteLater(); + + if (!requests_search_.contains(search_id)) return; + TidalSearchContext *search_ctx = requests_search_.value(search_id); + + if (!search_ctx->requests_song_.contains(song_id)) { + CheckFinish(search_ctx); + return; + } + Song *song = search_ctx->requests_song_.value(song_id); + + search_ctx->song_requests--; + + QJsonObject json_obj = ExtractJsonObj(search_ctx, reply); + if (json_obj.isEmpty()) { + delete search_ctx->requests_song_.take(song_id); + CheckFinish(search_ctx); + return; + } + + if (!json_obj.contains("url") || !json_obj.contains("codec")) { + qLog(Error) << "Tidal: Invalid Json reply, stream missing url or codec."; + qLog(Debug) << json_obj; + delete search_ctx->requests_song_.take(song_id); + CheckFinish(search_ctx); + return; + } + + song->set_url(QUrl(json_obj["url"].toString())); + song->set_valid(true); + QString codec = json_obj["codec"].toString(); + if (codec == "AAC") song->set_filetype(Song::Type_MP4); + else qLog(Debug) << "Tidal codec" << codec; + + //qLog(Debug) << song->artist() << song->album() << song->title() << song->url() << song->filetype(); + + search_ctx->songs << *song; + + delete search_ctx->requests_song_.take(song_id); + + CheckFinish(search_ctx); + +} + +void TidalService::CheckFinish(TidalSearchContext *search_ctx) { + + if (!search_ctx->login_sent && search_ctx->album_requests <= 0 && search_ctx->song_requests <= 0) { + if (search_ctx->songs.isEmpty()) emit SearchError(search_ctx->id, search_ctx->error); + else emit SearchResults(search_ctx->id, search_ctx->songs); + delete requests_search_.take(search_ctx->id); + } + +} + +void TidalService::Error(TidalSearchContext *search_ctx, QString error, QString debug) { + qLog(Error) << "Tidal:" << error; + if (!debug.isEmpty()) qLog(Debug) << debug; + if (search_ctx) { + search_ctx->error = error; + CheckFinish(search_ctx); + } +} diff --git a/src/tidal/tidalservice.cpp.bak b/src/tidal/tidalservice.cpp.bak new file mode 100644 index 000000000..6d388bb8b --- /dev/null +++ b/src/tidal/tidalservice.cpp.bak @@ -0,0 +1,745 @@ +/* + * Strawberry Music Player + * Copyright 2018, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/application.h" +#include "core/closure.h" +#include "core/logging.h" +#include "core/mergedproxymodel.h" +#include "core/network.h" +#include "core/song.h" +#include "core/iconloader.h" +#include "core/taskmanager.h" +#include "core/timeconstants.h" +#include "core/utilities.h" +#include "internet/internetmodel.h" +#include "tidalservice.h" +#include "tidalsearch.h" +#include "settings/tidalsettingspage.h" + +const char *TidalService::kServiceName = "Tidal"; +const char *TidalService::kApiUrl = "https://listen.tidal.com/v1"; +const char *TidalService::kAuthUrl = "https://listen.tidal.com/v1/login/username"; +const char *TidalService::kResourcesUrl = "http://resources.tidal.com"; +const char *TidalService::kApiToken = "P5Xbeo5LFvESeDy6"; + +const int TidalService::kSearchDelayMsec = 1000; +const int TidalService::kSearchAlbumsLimit = 1; +const int TidalService::kSearchTracksLimit = 1; + +typedef QPair Param; + +TidalService::TidalService(Application *app, InternetModel *parent) + : InternetService(kServiceName, app, parent, parent), + network_(new NetworkAccessManager(this)), + search_delay_(new QTimer(this)), + pending_search_id_(0), + next_pending_search_id_(1), + search_requests_(0), + login_sent_(false) { + + search_delay_->setInterval(kSearchDelayMsec); + search_delay_->setSingleShot(true); + connect(search_delay_, SIGNAL(timeout()), SLOT(StartSearch())); + + ReloadSettings(); + LoadSessionID(); + +} + +TidalService::~TidalService() {} + +void TidalService::ShowConfig() { + app_->OpenSettingsDialogAtPage(SettingsDialog::Page_Tidal); +} + +void TidalService::ReloadSettings() { + + QSettings s; + s.beginGroup(TidalSettingsPage::kSettingsGroup); + username_ = s.value("username").toString(); + password_ = s.value("password").toString(); + quality_ = s.value("quality").toString(); + s.endGroup(); + +} + +void TidalService::LoadSessionID() { + + QSettings s; + s.beginGroup(TidalSettingsPage::kSettingsGroup); + if (!s.contains("user_id") ||!s.contains("session_id") || !s.contains("country_code")) return; + session_id_ = s.value("session_id").toString(); + user_id_ = s.value("user_id").toInt(); + country_code_ = s.value("country_code").toString(); + s.endGroup(); + +} + +void TidalService::Login(const QString &username, const QString &password) { + Login(nullptr, username, password); +} + +void TidalService::Login(TidalSearchContext *search_ctx, const QString &username, const QString &password) { + + login_sent_ = true; + + int id = 0; + if (search_ctx) { + search_ctx->login_sent = true; + search_ctx->login_attempts++; + id = search_ctx->id; + } + + typedef QPair Arg; + typedef QList ArgList; + + typedef QPair EncodedArg; + typedef QList EncodedArgList; + + ArgList args = ArgList() <post(req, url_query.toString(QUrl::FullyEncoded).toUtf8()); + NewClosure(reply, SIGNAL(finished()), this, SLOT(HandleAuthReply(QNetworkReply*, int)), reply, id); + +} + +void TidalService::HandleAuthReply(QNetworkReply *reply, int id) { + + reply->deleteLater(); + + login_sent_ = false; + + TidalSearchContext *search_ctx(nullptr); + if (id != 0 && requests_search_.contains(id)) { + search_ctx = requests_search_.value(id); + search_ctx->login_sent = false; + } + + //int http_status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (reply->error() != QNetworkReply::NoError) { + if (reply->error() < 200) { + // This is a network error, there is nothing more to do. + QString failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + if (search_ctx) Error(search_ctx, failure_reason); + emit LoginFailure(failure_reason); + return; + } + else { + // See if there is Json data containing "userMessage" - then use that instead. + QByteArray data(reply->readAll()); + QJsonParseError error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &error); + QString failure_reason; + if (error.error == QJsonParseError::NoError && !json_doc.isNull() && !json_doc.isEmpty() && json_doc.isObject()) { + QJsonObject json_obj = json_doc.object(); + if (!json_obj.isEmpty() && json_obj.contains("userMessage")) { + failure_reason = QString("Authentication failure: %1").arg(json_obj["userMessage"].toString()); + } + else { + failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + } + else { + failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + if (search_ctx) Error(search_ctx, failure_reason); + emit LoginFailure(failure_reason); + return; + } + } + + QByteArray data(reply->readAll()); + QJsonParseError error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &error); + + if (error.error != QJsonParseError::NoError) { + QString failure_reason("Authentication reply from server missing Json data."); + if (search_ctx) Error(search_ctx, failure_reason); + emit LoginFailure(failure_reason); + return; + } + + if (json_doc.isNull() || json_doc.isEmpty()) { + QString failure_reason("Authentication reply from server has empty Json document."); + if (search_ctx) Error(search_ctx, failure_reason); + emit LoginFailure(failure_reason); + return; + } + + if (!json_doc.isObject()) { + QString failure_reason("Authentication reply from server has Json document that is not an object."); + if (search_ctx) Error(search_ctx, failure_reason); + emit LoginFailure(failure_reason); + return; + } + + QJsonObject json_obj = json_doc.object(); + if (json_obj.isEmpty()) { + QString failure_reason("Authentication reply from server has empty Json object."); + if (search_ctx) Error(search_ctx, failure_reason); + emit LoginFailure(failure_reason); + return; + } + + if (json_obj["userId"].isUndefined() || json_obj["sessionId"].isUndefined() || json_obj["countryCode"].isUndefined()) { + QString failure_reason = tr("Authentication reply from server missing userId, sessionId or countryCode"); + if (search_ctx) Error(search_ctx, failure_reason); + emit LoginFailure(failure_reason); + return; + } + + country_code_ = json_obj["countryCode"].toString(); + session_id_ = json_obj["sessionId"].toString(); + user_id_ = json_obj["userId"].toInt(); + + QSettings s; + s.beginGroup(TidalSettingsPage::kSettingsGroup); + s.setValue("user_id", user_id_); + s.setValue("session_id", session_id_); + s.setValue("country_code", country_code_); + s.endGroup(); + + qLog(Debug) << "Tidal: Login successful" << "user id" << user_id_ << "session id" << session_id_ << "country code" << country_code_; + + if (search_ctx) { + qLog(Debug) << "Tidal: Resuming search"; + SendSearch(search_ctx); + } + + emit LoginSuccess(); + +} + +void TidalService::Logout() { + + user_id_ = 0; + session_id_.clear(); + country_code_.clear(); + + QSettings s; + s.beginGroup(TidalSettingsPage::kSettingsGroup); + s.remove("user_id"); + s.remove("session_id"); + s.remove("country_code"); + s.endGroup(); + +} + +QNetworkReply *TidalService::CreateRequest(const QString &ressource_name, const QList ¶ms) { + + typedef QPair Arg; + typedef QList ArgList; + + typedef QPair EncodedArg; + typedef QList EncodedArgList; + + ArgList args = ArgList() << params + << Arg("sessionId", session_id_) + << Arg("countryCode", country_code_); + + QStringList query_items; + QUrlQuery url_query; + for (const Arg& arg : args) { + EncodedArg encoded_arg(QUrl::toPercentEncoding(arg.first), QUrl::toPercentEncoding(arg.second)); + query_items << QString(encoded_arg.first + "=" + encoded_arg.second); + url_query.addQueryItem(encoded_arg.first, encoded_arg.second); + } + + QUrl url(kApiUrl + QString("/") + ressource_name); + url.setQuery(url_query); + QNetworkRequest req(url); + QNetworkReply *reply = network_->get(req); + + //qLog(Debug) << "Tidal: Sending request" << url; + + return reply; + +} + +QJsonObject TidalService::ExtractJsonObj(TidalSearchContext *search_ctx, QNetworkReply *reply) { + + //int http_status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (reply->error() != QNetworkReply::NoError) { + if (reply->error() < 200) { + // This is a network error, there is nothing more to do. + QString failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + Error(search_ctx, failure_reason); + } + else { + // See if there is Json data containing "userMessage" - then use that instead. + QByteArray data(reply->readAll()); + QJsonParseError error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &error); + QString failure_reason; + if (error.error == QJsonParseError::NoError && !json_doc.isNull() && !json_doc.isEmpty() && json_doc.isObject()) { + QJsonObject json_obj = json_doc.object(); + if (!json_obj.isEmpty() && json_obj.contains("userMessage")) { + failure_reason = json_obj["userMessage"].toString(); + } + else { + failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + } + else { + failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + if (reply->error() == QNetworkReply::ContentAccessDenied || + reply->error() == QNetworkReply::ContentOperationNotPermittedError || + reply->error() == QNetworkReply::ContentNotFoundError || + reply->error() == QNetworkReply::AuthenticationRequiredError) { + Logout(); + if (search_ctx->login_attempts < 1 && !username_.isEmpty() && !password_.isEmpty()) { + qLog(Error) << "Tidal:" << failure_reason; + qLog(Error) << "Tidal:" << QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + qLog(Error) << "Tidal:" << "Attempting to login."; + Login(search_ctx, username_, password_); + } + else { + Error(search_ctx, failure_reason); + } + } + else { + Error(search_ctx, failure_reason); + } + } + return QJsonObject(); + } + + QByteArray data(reply->readAll()); + + qLog(Debug) << data; + + QJsonParseError error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &error); + + if (error.error != QJsonParseError::NoError) { + Error(search_ctx, "Error while extracting Json document from results."); + return QJsonObject(); + } + + if (!json_doc.isObject()) { + Error(search_ctx, "Json document is not an object."); + return QJsonObject(); + } + + if (json_doc.isNull() || json_doc.isEmpty()) { + Error(search_ctx, "Received empty Json document."); + return QJsonObject(); + } + + QJsonObject json_obj = json_doc.object(); + if (json_obj.isEmpty()) { + Error(search_ctx, "Received empty Json object."); + return QJsonObject(); + } + + //qLog(Debug) << json_obj; + + return json_obj; + +} + +QJsonArray TidalService::ExtractItems(TidalSearchContext *search_ctx, QNetworkReply *reply) { + + QJsonObject json_obj = ExtractJsonObj(search_ctx, reply); + if (json_obj.isEmpty()) return QJsonArray(); + + if (!json_obj.contains("items")) { + Error(search_ctx, "Json reply is missing items."); + return QJsonArray(); + } + + QJsonArray json_items = json_obj["items"].toArray(); + if (json_items.isEmpty()) { + Error(search_ctx, "No match."); + return QJsonArray(); + } + + return json_items; + +} + +int TidalService::Search(const QString &text, TidalSettingsPage::SearchBy searchby) { + + pending_search_id_ = next_pending_search_id_; + pending_search_ = text; + pending_searchby_ = searchby; + + next_pending_search_id_++; + + if (text.isEmpty()) { + search_delay_->stop(); + return pending_search_id_; + } + search_delay_->start(); + + return pending_search_id_; + +} + +void TidalService::StartSearch() { + + if (username_.isEmpty() || password_.isEmpty()) { + emit SearchError(pending_search_id_, "Missing username and/or password."); + next_pending_search_id_ = 1; + ShowConfig(); + return; + } + + TidalSearchContext *search_ctx = CreateSearch(pending_search_id_, pending_search_); + if (authenticated()) SendSearch(search_ctx); + else Login(search_ctx, username_, password_); + +} + +TidalSearchContext *TidalService::CreateSearch(const int search_id, const QString text) { + + TidalSearchContext *search_ctx = new TidalSearchContext; + search_ctx->id = search_id; + search_ctx->text = text; + search_ctx->album_requests = 0; + search_ctx->song_requests = 0; + search_ctx->requests_album_.clear(); + search_ctx->requests_song_.clear(); + search_ctx->login_attempts = 0; + requests_search_.insert(search_id, search_ctx); + return search_ctx; + +} + +void TidalService::SendSearch(TidalSearchContext *search_ctx) { + + QList parameters; + parameters << Param("query", search_ctx->text); + + QString searchparam; + switch (pending_searchby_) { + case TidalSettingsPage::SearchBy_Songs: + searchparam = "search/tracks"; + parameters << Param("limit", QString::number(kSearchTracksLimit)); + break; + case TidalSettingsPage::SearchBy_Albums: + default: + searchparam = "search/albums"; + parameters << Param("limit", QString::number(kSearchAlbumsLimit)); + break; + } + + QNetworkReply *reply = CreateRequest(searchparam, parameters); + NewClosure(reply, SIGNAL(finished()), this, SLOT(SearchFinished(QNetworkReply*, int)), reply, search_ctx->id); + +} + +void TidalService::SearchFinished(QNetworkReply *reply, int id) { + + reply->deleteLater(); + + if (!requests_search_.contains(id)) return; + TidalSearchContext *search_ctx = requests_search_.value(id); + + QJsonArray json_items = ExtractItems(search_ctx, reply); + if (json_items.isEmpty()) { + CheckFinish(search_ctx); + return; + } + + //qLog(Debug) << json_items; + + QVector albums; + for (const QJsonValue &value : json_items) { + int album_id(0); + QString album(""); + if (!value["type"].isUndefined()) { + if (value["id"].isUndefined() || value["title"].isUndefined()) { + qLog(Error) << "Tidal: Invalid Json reply, missing ID or title."; + qLog(Debug) << value; + continue; + } + album_id = value["id"].toInt(); + album = value["title"].toString(); + } + else if (!value["album"].isUndefined()) { + QJsonValue json_album = value["album"]; + if (json_album["id"].isUndefined() || json_album["title"].isUndefined()) { + qLog(Error) << "Tidal: Invalid Json reply, missing ID or title."; + qLog(Debug) << value; + continue; + } + album_id = json_album["id"].toInt(); + album = json_album["title"].toString(); + } + else { + qLog(Error) << "Tidal: Invalid Json reply, missing type or album."; + qLog(Debug) << value; + continue; + } + + if (search_ctx->requests_album_.contains(album_id)) continue; + + if (value["artist"].isUndefined() || value["title"].isUndefined()) { + qLog(Error) << "Tidal: Invalid Json reply, missing artist or title."; + qLog(Debug) << value; + continue; + } + QJsonValue json_artist = value["artist"]; + QString artist(json_artist["name"].toString()); + QString quality(value["audioQuality"].toString()); + + //qLog(Debug) << "Tidal:" << artist << album << quality; + + QString artist_album(QString("%1-%2").arg(artist).arg(album)); + if (albums.contains(artist_album)) { + qLog(Debug) << "Tidal: Skipping duplicate album" << artist << album << quality; + continue; + } + albums.insert(0, artist_album); + + search_ctx->requests_album_.insert(album_id, album_id); + GetAlbum(search_ctx, album_id); + search_ctx->album_requests++; + if (search_ctx->album_requests >= kSearchAlbumsLimit) break; + } + + CheckFinish(search_ctx); + +} + +void TidalService::GetAlbum(TidalSearchContext *search_ctx, const int album_id) { + + QList parameters; + parameters << Param("token", session_id_) + << Param("soundQuality", quality_); + + QNetworkReply *reply = CreateRequest(QString("albums/%1/tracks").arg(album_id), parameters); + + NewClosure(reply, SIGNAL(finished()), this, SLOT(GetAlbumFinished(QNetworkReply*, int, int)), reply, search_ctx->id, album_id); + +} + +void TidalService::GetAlbumFinished(QNetworkReply *reply, int search_id, int album_id) { + + reply->deleteLater(); + + if (!requests_search_.contains(search_id)) return; + TidalSearchContext *search_ctx = requests_search_.value(search_id); + + if (!search_ctx->requests_album_.contains(album_id)) return; + search_ctx->album_requests--; + + QJsonArray json_items = ExtractItems(search_ctx, reply); + if (json_items.isEmpty()) { + CheckFinish(search_ctx); + return; + } + + bool compilation = false; + bool multidisc = false; + Song *first_song(nullptr); + QList songs; + for (const QJsonValue &value : json_items) { + Song *song = ParseSong(search_ctx, album_id, value); + if (!song) continue; + songs << song; + if (song->disc() >= 2) multidisc = true; + if (song->is_compilation() || (first_song && song->artist() != first_song->artist())) compilation = true; + if (!first_song) first_song = song; + } + if (compilation || multidisc) { + for (Song *song : songs) { + if (compilation) song->set_compilation_detected(true); + if (multidisc) { + QString album_full(QString("%1 - (Disc %2)").arg(song->album()).arg(song->disc())); + song->set_album(album_full); + } + } + } + + CheckFinish(search_ctx); + +} + +Song *TidalService::ParseSong(TidalSearchContext *search_ctx, const int album_id, const QJsonValue &value) { + + Song song; + + bool allow_streaming = value["allowStreaming"].toBool(); + bool stream_ready = value["streamReady"].toBool(); + if (!allow_streaming || !stream_ready) { + return nullptr; + } + + int id = value["id"].toInt(); + QJsonValue json_artist = value["artist"]; + QJsonArray json_artists = value["artists"].toArray(); + QJsonValue json_album = value["album"]; + QString title = value["title"].toString(); + QString artist = json_artist["name"].toString(); + QString album = json_album["title"].toString(); + QString cover = json_album["cover"].toString(); + QString url = value["url"].toString(); + int track = value["trackNumber"].toInt(); + int disc = value["volumeNumber"].toInt(); + + //qLog(Debug) << "id" << id << "track" << track << "disc" << disc << "title" << title << "album" << album << "artist" << artist << cover << allow_streaming << url; + + song.set_album_id(album_id); + song.set_artist(artist); + song.set_album(album); + song.set_title(title); + song.set_track(track); + song.set_disc(disc); + song.set_bitrate(0); + song.set_samplerate(0); + song.set_bitdepth(0); + + QVariant q_duration = value["duration"]; + if (q_duration.isValid()) { + quint64 duration = q_duration.toULongLong() * kNsecPerSec; + song.set_length_nanosec(duration); + } + + // Check and see if there is more than 1 artist on the song. + //int i = 0; + //for (const QJsonValue &artist : json_artists) { + //i++; + //qLog(Debug) << artist << i; + //} + //if (i > 1) song.set_compilation_detected(true); + + cover = cover.replace("-", "/"); + QUrl cover_url (QString("%1/images/%2/750x750.jpg").arg(kResourcesUrl).arg(cover)); + song.set_art_automatic(cover_url.toEncoded()); + + if (search_ctx->requests_song_.contains(id)) return search_ctx->requests_song_.value(id); + Song *song_new = new Song(song); + search_ctx->requests_song_.insert(id, song_new); + search_ctx->song_requests++; + GetStreamURL(search_ctx, album_id, id); + + return song_new; + +} + +void TidalService::GetStreamURL(TidalSearchContext *search_ctx, const int album_id, const int song_id) { + + QList parameters; + parameters << Param("token", session_id_) + << Param("soundQuality", quality_); + + QNetworkReply *reply = CreateRequest(QString("tracks/%1/streamUrl").arg(song_id), parameters); + + NewClosure(reply, SIGNAL(finished()), this, SLOT(GetStreamURLFinished(QNetworkReply*, int, int)), reply, search_ctx->id, song_id); + +} + +void TidalService::GetStreamURLFinished(QNetworkReply *reply, const int search_id, const int song_id) { + + reply->deleteLater(); + + if (!requests_search_.contains(search_id)) return; + TidalSearchContext *search_ctx = requests_search_.value(search_id); + + if (!search_ctx->requests_song_.contains(song_id)) { + CheckFinish(search_ctx); + return; + } + Song *song = search_ctx->requests_song_.value(song_id); + + search_ctx->song_requests--; + + QJsonObject json_obj = ExtractJsonObj(search_ctx, reply); + if (json_obj.isEmpty()) { + delete search_ctx->requests_song_.take(song_id); + CheckFinish(search_ctx); + return; + } + + if (json_obj["url"].isUndefined() || json_obj["codec"].isUndefined()) { + delete search_ctx->requests_song_.take(song_id); + CheckFinish(search_ctx); + return; + } + + song->set_url(QUrl(json_obj["url"].toString())); + song->set_valid(true); + QString codec = json_obj["codec"].toString(); + if (codec == "AAC") song->set_filetype(Song::Type_MP4); + else qLog(Debug) << "Tidal codec" << codec; + + //qLog(Debug) << song->title() << song->artist() << song->album() << song->url() << song->filetype(); + + search_ctx->songs << *song; + + delete search_ctx->requests_song_.take(song_id); + + CheckFinish(search_ctx); + +} + +void TidalService::CheckFinish(TidalSearchContext *search_ctx) { + + if (!search_ctx->login_sent && search_ctx->album_requests <= 0 && search_ctx->song_requests <= 0) { + if (search_ctx->songs.isEmpty()) emit SearchError(search_ctx->id, search_ctx->error); + else emit SearchResults(search_ctx->id, search_ctx->songs); + delete requests_search_.take(search_ctx->id); + } + +} + +void TidalService::Error(TidalSearchContext *search_ctx, QString error, QString debug) { + qLog(Error) << "Tidal:" << error; + if (!debug.isEmpty()) qLog(Debug) << debug; + if (search_ctx) { + search_ctx->error = error; + CheckFinish(search_ctx); + } +} diff --git a/src/tidal/tidalservice.h b/src/tidal/tidalservice.h new file mode 100644 index 000000000..96ba93fba --- /dev/null +++ b/src/tidal/tidalservice.h @@ -0,0 +1,134 @@ +/* + * Strawberry Music Player + * Copyright 2018, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef TIDALSERVICE_H +#define TIDALSERVICE_H + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/song.h" +#include "internet/internetmodel.h" +#include "internet/internetservice.h" +#include "settings/tidalsettingspage.h" + +class NetworkAccessManager; + +struct TidalSearchContext { + int id; + QString text; + QHash requests_album_; + QHash requests_song_; + int album_requests; + int song_requests; + SongList songs; + QString error; + bool login_sent; + int login_attempts; +}; +Q_DECLARE_METATYPE(TidalSearchContext); + +class TidalService : public InternetService { + Q_OBJECT + + public: + TidalService(Application *app, InternetModel *parent); + ~TidalService(); + + static const char *kServiceName; + + void ReloadSettings(); + + void Login(const QString &username, const QString &password); + void Logout(); + int Search(const QString &query, TidalSettingsPage::SearchBy searchby); + + const bool login_sent() { return login_sent_; } + const bool authenticated() { return (!session_id_.isEmpty() && !country_code_.isEmpty()); } + + signals: + void LoginSuccess(); + void LoginFailure(QString failure_reason); + void SearchResults(int id, SongList songs); + void SearchError(int id, QString message); + + public slots: + void ShowConfig(); + + private slots: + void HandleAuthReply(QNetworkReply *reply, int id); + void StartSearch(); + void SearchFinished(QNetworkReply *reply, int id); + void GetAlbumFinished(QNetworkReply *reply, int search_id, int album_id); + void GetStreamURLFinished(QNetworkReply *reply, const int search_id, const int song_id); + + private: + void Login(TidalSearchContext *search_ctx, const QString &username, const QString &password); + void LoadSessionID(); + QNetworkReply *CreateRequest(const QString &ressource_name, const QList> ¶ms); + QJsonObject ExtractJsonObj(TidalSearchContext *search_ctx, QNetworkReply *reply); + QJsonArray ExtractItems(TidalSearchContext *search_ctx, QNetworkReply *reply); + TidalSearchContext *CreateSearch(const int search_id, const QString text); + void SendSearch(TidalSearchContext *search_ctx); + void GetAlbum(TidalSearchContext *search_ctx, const int album_id); + Song *ParseSong(TidalSearchContext *search_ctx, const int album_id, const QJsonValue &value); + Song ExtractSong(TidalSearchContext *search_ctx, const QJsonValue &value); + void GetStreamURL(TidalSearchContext *search_ctx, const int album_id, const int song_id); + void CheckFinish(TidalSearchContext *search_ctx); + void Error(TidalSearchContext *search_ctx, QString error, QString debug = ""); + + static const char *kApiUrl; + static const char *kAuthUrl; + static const char *kResourcesUrl; + static const char *kApiToken; + + NetworkAccessManager *network_; + QTimer *search_delay_; + int pending_search_id_; + int next_pending_search_id_; + int search_requests_; + bool login_sent_; + static const int kSearchAlbumsLimit; + static const int kSearchTracksLimit; + static const int kSearchDelayMsec; + + QString username_; + QString password_; + QString quality_; + QString session_id_; + quint64 user_id_; + QString country_code_; + + QString pending_search_; + TidalSettingsPage::SearchBy pending_searchby_; + QHash requests_search_; + +}; + +#endif // TIDALSERVICE_H diff --git a/src/transcoder/transcoder.cpp b/src/transcoder/transcoder.cpp index 2aa25c4c7..dcc74429c 100644 --- a/src/transcoder/transcoder.cpp +++ b/src/transcoder/transcoder.cpp @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #include "config.h" @@ -222,14 +222,14 @@ Transcoder::Transcoder(QObject *parent, const QString &settings_postfix) QList Transcoder::GetAllPresets() { QList ret; - ret << PresetForFileType(Song::Type_Flac); - ret << PresetForFileType(Song::Type_Mp4); - ret << PresetForFileType(Song::Type_Mpeg); + ret << PresetForFileType(Song::Type_FLAC); + ret << PresetForFileType(Song::Type_MP4); + ret << PresetForFileType(Song::Type_MPEG); ret << PresetForFileType(Song::Type_OggVorbis); ret << PresetForFileType(Song::Type_OggFlac); ret << PresetForFileType(Song::Type_OggSpeex); - ret << PresetForFileType(Song::Type_Asf); - ret << PresetForFileType(Song::Type_Wav); + ret << PresetForFileType(Song::Type_ASF); + ret << PresetForFileType(Song::Type_WAV); ret << PresetForFileType(Song::Type_OggOpus); return ret; @@ -238,11 +238,11 @@ QList Transcoder::GetAllPresets() { TranscoderPreset Transcoder::PresetForFileType(Song::FileType type) { switch (type) { - case Song::Type_Flac: + case Song::Type_FLAC: return TranscoderPreset(type, tr("FLAC"), "flac", "audio/x-flac"); - case Song::Type_Mp4: + case Song::Type_MP4: return TranscoderPreset(type, tr("M4A AAC"), "mp4", "audio/mpeg, mpegversion=(int)4", "audio/mp4"); - case Song::Type_Mpeg: + case Song::Type_MPEG: return TranscoderPreset(type, tr("MP3"), "mp3", "audio/mpeg, mpegversion=(int)1, layer=(int)3"); case Song::Type_OggVorbis: return TranscoderPreset(type, tr("Ogg Vorbis"), "ogg", "audio/x-vorbis", "application/ogg"); @@ -252,9 +252,9 @@ TranscoderPreset Transcoder::PresetForFileType(Song::FileType type) { return TranscoderPreset(type, tr("Ogg Speex"), "spx", "audio/x-speex", "application/ogg"); case Song::Type_OggOpus: return TranscoderPreset(type, tr("Ogg Opus"), "opus", "audio/x-opus", "application/ogg"); - case Song::Type_Asf: + case Song::Type_ASF: return TranscoderPreset(type, tr("Windows Media audio"), "wma", "audio/x-wma", "video/x-ms-asf"); - case Song::Type_Wav: + case Song::Type_WAV: return TranscoderPreset(type, tr("Wav"), "wav", QString(), "audio/x-wav"); default: qLog(Warning) << "Unsupported format in PresetForFileType:" << type; @@ -268,9 +268,9 @@ Song::FileType Transcoder::PickBestFormat(QList supported) { if (supported.isEmpty()) return Song::Type_Unknown; QList best_formats; - best_formats << Song::Type_Mpeg; + best_formats << Song::Type_MPEG; best_formats << Song::Type_OggVorbis; - best_formats << Song::Type_Asf; + best_formats << Song::Type_ASF; for (Song::FileType type : best_formats) { if (supported.isEmpty() || supported.contains(type)) return type; diff --git a/src/transcoder/transcoderoptionsdialog.cpp b/src/transcoder/transcoderoptionsdialog.cpp index 48ff27645..798e0aee2 100644 --- a/src/transcoder/transcoderoptionsdialog.cpp +++ b/src/transcoder/transcoderoptionsdialog.cpp @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #include "config.h" @@ -44,14 +44,14 @@ TranscoderOptionsDialog::TranscoderOptionsDialog(Song::FileType type, QWidget *p ui_->setupUi(this); switch (type) { - case Song::Type_Flac: + case Song::Type_FLAC: case Song::Type_OggFlac: options_ = new TranscoderOptionsFlac(this); break; - case Song::Type_Mp4: options_ = new TranscoderOptionsAAC(this); break; - case Song::Type_Mpeg: options_ = new TranscoderOptionsMP3(this); break; + case Song::Type_MP4: options_ = new TranscoderOptionsAAC(this); break; + case Song::Type_MPEG: options_ = new TranscoderOptionsMP3(this); break; case Song::Type_OggVorbis: options_ = new TranscoderOptionsVorbis(this); break; case Song::Type_OggOpus: options_ = new TranscoderOptionsOpus(this); break; case Song::Type_OggSpeex: options_ = new TranscoderOptionsSpeex(this); break; - case Song::Type_Asf: options_ = new TranscoderOptionsWma(this); break; + case Song::Type_ASF: options_ = new TranscoderOptionsWma(this); break; default: break; } diff --git a/src/widgets/fileviewlist.cpp b/src/widgets/fileviewlist.cpp index f0e3b7cc7..5da81fec0 100644 --- a/src/widgets/fileviewlist.cpp +++ b/src/widgets/fileviewlist.cpp @@ -15,7 +15,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #include "config.h" @@ -59,7 +59,6 @@ FileViewList::FileViewList(QWidget *parent) void FileViewList::contextMenuEvent(QContextMenuEvent *e) { - //qLog(Debug) << __PRETTY_FUNCTION__; menu_selection_ = selectionModel()->selection(); menu_->popup(e->globalPos()); @@ -69,7 +68,6 @@ void FileViewList::contextMenuEvent(QContextMenuEvent *e) { QList FileViewList::UrlListFromSelection() const { - //qLog(Debug) << __PRETTY_FUNCTION__; QList urls; for (const QModelIndex& index : menu_selection_.indexes()) { if (index.column() == 0) @@ -81,7 +79,6 @@ QList FileViewList::UrlListFromSelection() const { MimeData *FileViewList::MimeDataFromSelection() const { - //qLog(Debug) << __PRETTY_FUNCTION__; MimeData *data = new MimeData; data->setUrls(UrlListFromSelection()); @@ -101,7 +98,6 @@ MimeData *FileViewList::MimeDataFromSelection() const { QStringList FileViewList::FilenamesFromSelection() const { - //qLog(Debug) << __PRETTY_FUNCTION__; QStringList filenames; for (const QModelIndex& index : menu_selection_.indexes()) { if (index.column() == 0) @@ -112,14 +108,12 @@ QStringList FileViewList::FilenamesFromSelection() const { } void FileViewList::LoadSlot() { - //qLog(Debug) << __PRETTY_FUNCTION__; MimeData *data = MimeDataFromSelection(); data->clear_first_ = true; emit AddToPlaylist(data); } void FileViewList::AddToPlaylistSlot() { - //qLog(Debug) << __PRETTY_FUNCTION__; emit AddToPlaylist(MimeDataFromSelection()); } @@ -143,18 +137,15 @@ void FileViewList::CopyToDeviceSlot() { } void FileViewList::DeleteSlot() { - //qLog(Debug) << __PRETTY_FUNCTION__; emit Delete(FilenamesFromSelection()); } void FileViewList::EditTagsSlot() { - //qLog(Debug) << __PRETTY_FUNCTION__; emit EditTags(UrlListFromSelection()); } void FileViewList::mousePressEvent(QMouseEvent *e) { - //qLog(Debug) << __PRETTY_FUNCTION__; switch (e->button()) { case Qt::XButton1: emit Back(); @@ -183,6 +174,5 @@ void FileViewList::mousePressEvent(QMouseEvent *e) { } void FileViewList::ShowInBrowser() { - //qLog(Debug) << __PRETTY_FUNCTION__; Utilities::OpenInFileBrowser(UrlListFromSelection()); } diff --git a/src/widgets/loginstatewidget.cpp b/src/widgets/loginstatewidget.cpp new file mode 100644 index 000000000..735d2b803 --- /dev/null +++ b/src/widgets/loginstatewidget.cpp @@ -0,0 +1,148 @@ +/* + This file was part of Clementine. + Copyright 2010, David Sansome + + Strawberry is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Strawberry is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Strawberry. If not, see . + +*/ + +#include "loginstatewidget.h" +#include "ui_loginstatewidget.h" +#include "core/iconloader.h" + +#include +#include +#include +#include +#include +#include + +LoginStateWidget::LoginStateWidget(QWidget *parent) + : QWidget(parent), ui_(new Ui_LoginStateWidget), state_(LoggedOut) { + + ui_->setupUi(this); + ui_->signed_in->hide(); + ui_->expires->hide(); + ui_->account_type->hide(); + ui_->busy->hide(); + + ui_->sign_out->setIcon(IconLoader::Load("list-remove")); + ui_->signed_in_icon_label->setPixmap(IconLoader::Load("dialog-ok-apply").pixmap(22)); + ui_->expires_icon_label->setPixmap(IconLoader::Load("user-away").pixmap(22)); + ui_->account_type_icon_label->setPixmap(IconLoader::Load("dialog-warning").pixmap(22)); + + QFont bold_font(font()); + bold_font.setBold(true); + ui_->signed_out_label->setFont(bold_font); + + connect(ui_->sign_out, SIGNAL(clicked()), SLOT(Logout())); + +} + +LoginStateWidget::~LoginStateWidget() { delete ui_; } + +void LoginStateWidget::Logout() { + SetLoggedIn(LoggedOut); + emit LogoutClicked(); +} + +void LoginStateWidget::SetAccountTypeText(const QString &text) { + ui_->account_type_label->setText(text); +} + +void LoginStateWidget::SetAccountTypeVisible(bool visible) { + ui_->account_type->setVisible(visible); +} + +void LoginStateWidget::SetLoggedIn(State state, const QString &account_name) { + + State last_state = state_; + state_ = state; + + ui_->signed_in->setVisible(state == LoggedIn); + ui_->signed_out->setVisible(state != LoggedIn); + ui_->busy->setVisible(state == LoginInProgress); + + if (account_name.isEmpty()) ui_->signed_in_label->setText("" + tr("You are signed in.") + ""); + else ui_->signed_in_label->setText(tr("You are signed in as %1.").arg("" + account_name + "")); + + for (QWidget *widget : credential_groups_) { + widget->setVisible(state != LoggedIn); + widget->setEnabled(state != LoginInProgress); + } + + if (state == LoggedOut && last_state == LoginInProgress) { + // A login just failed - give focus back to the last crediental field (usually password). + // We have to do this after control gets back to the + // event loop because the user might have just closed a dialog and our widget might not be active yet. + QTimer::singleShot(0, this, SLOT(FocusLastCredentialField())); + } + +} + +void LoginStateWidget::FocusLastCredentialField() { + + if (!credential_fields_.isEmpty()) { + QObject *object = credential_fields_.last(); + QWidget *widget = qobject_cast(object); + QLineEdit *line_edit = qobject_cast(object); + + if (widget) { + widget->setFocus(); + } + + if (line_edit) { + line_edit->selectAll(); + } + } +} + +void LoginStateWidget::HideLoggedInState() { + ui_->signed_in->hide(); + ui_->signed_out->hide(); +} + +void LoginStateWidget::AddCredentialField(QWidget *widget) { + widget->installEventFilter(this); + credential_fields_ << widget; +} + +void LoginStateWidget::AddCredentialGroup(QWidget *widget) { + credential_groups_ << widget; +} + +bool LoginStateWidget::eventFilter(QObject *object, QEvent *event) { + if (!credential_fields_.contains(object)) + return QWidget::eventFilter(object, event); + + if (event->type() == QEvent::KeyPress) { + QKeyEvent *key_event = static_cast(event); + if (key_event->key() == Qt::Key_Enter || key_event->key() == Qt::Key_Return) { + emit LoginClicked(); + return true; + } + } + + return QWidget::eventFilter(object, event); +} + +void LoginStateWidget::SetExpires(const QDate &expires) { + + ui_->expires->setVisible(expires.isValid()); + + if (expires.isValid()) { + const QString expires_text = expires.toString(Qt::SystemLocaleLongDate); + ui_->expires_label->setText(tr("Expires on %1").arg("" + expires_text + "")); + } +} diff --git a/src/widgets/loginstatewidget.h b/src/widgets/loginstatewidget.h new file mode 100644 index 000000000..7a5d7354a --- /dev/null +++ b/src/widgets/loginstatewidget.h @@ -0,0 +1,80 @@ +/* + This file was part of Clementine. + Copyright 2010, David Sansome + + Strawberry is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Strawberry is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Strawberry. If not, see . + +*/ + +#ifndef LOGINSTATEWIDGET_H +#define LOGINSTATEWIDGET_H + +#include +#include +#include +#include +#include +#include + +class Ui_LoginStateWidget; + +class LoginStateWidget : public QWidget { + Q_OBJECT + + public: + LoginStateWidget(QWidget *parent = nullptr); + ~LoginStateWidget(); + + enum State { LoggedIn, LoginInProgress, LoggedOut }; + + // Installs an event handler on the field so that pressing enter will emit + // LoginClicked() instead of doing the default action (closing the dialog). + void AddCredentialField(QWidget *widget); + + // This widget (usually a QGroupBox) will be hidden when SetLoggedIn(true) is called. + void AddCredentialGroup(QWidget *widget); + + // QObject + bool eventFilter(QObject *object, QEvent *event); + + public slots: + // Changes the "You are logged in/out" label, shows/hides any QGroupBoxes added with AddCredentialGroup. + void SetLoggedIn(State state, const QString &account_name = QString::null); + + // Hides the "You are logged in/out" label completely. + void HideLoggedInState(); + + void SetAccountTypeText(const QString &text); + void SetAccountTypeVisible(bool visible); + + void SetExpires(const QDate &expires); + +signals: + void LogoutClicked(); + void LoginClicked(); + + private slots: + void Logout(); + void FocusLastCredentialField(); + + private: + Ui_LoginStateWidget *ui_; + + State state_; + + QList credential_fields_; + QList credential_groups_; +}; + +#endif // LOGINSTATEWIDGET_H diff --git a/src/widgets/loginstatewidget.ui b/src/widgets/loginstatewidget.ui new file mode 100644 index 000000000..20ef1e32e --- /dev/null +++ b/src/widgets/loginstatewidget.ui @@ -0,0 +1,182 @@ + + + LoginStateWidget + + + + 0 + 0 + 526 + 187 + + + + Form + + + + 0 + + + + + + 0 + + + + + + 22 + 22 + + + + + + + + + 0 + 0 + + + + You are not signed in. + + + true + + + + + + + + + + + 0 + + + + + + 22 + 22 + + + + + + + + + 0 + 0 + + + + Qt::RichText + + + true + + + + + + + Sign out + + + + + + + + + + + 0 + + + + + + 22 + 22 + + + + + + + + + 0 + 0 + + + + + + + + + + + + 0 + + + + + + 22 + 22 + + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + + 0 + 0 + + + + + + + true + + + + + + + + + + Signing in... + + + + + + + + BusyIndicator + QWidget +
widgets/busyindicator.h
+ 1 +
+
+ +
diff --git a/src/widgets/statusview.cpp b/src/widgets/statusview.cpp index d8a6d70e0..cc3dc7352 100644 --- a/src/widgets/statusview.cpp +++ b/src/widgets/statusview.cpp @@ -14,7 +14,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #include "config.h" @@ -84,7 +84,6 @@ StatusView::StatusView(CollectionViewContainer *collectionviewcontainer, QWidget label_playing_text_(nullptr), album_cover_choice_controller_(new AlbumCoverChoiceController(this)), - show_hide_animation_(new QTimeLine(500, this)), fade_animation_(new QTimeLine(1000, this)), image_blank_(""), image_nosong_(":/pictures/strawberry.png"), @@ -92,8 +91,6 @@ StatusView::StatusView(CollectionViewContainer *collectionviewcontainer, QWidget menu_(new QMenu(this)) { - //qLog(Debug) << __PRETTY_FUNCTION__; - collectionview_ = collectionviewcontainer->view(); connect(collectionview_, SIGNAL(TotalSongCountUpdated_()), this, SLOT(UpdateNoSong())); connect(collectionview_, SIGNAL(TotalArtistCountUpdated_()), this, SLOT(UpdateNoSong())); @@ -125,8 +122,6 @@ StatusView::~StatusView() { void StatusView::AddActions() { - //qLog(Debug) << __PRETTY_FUNCTION__; - QList actions = album_cover_choice_controller_->GetAllActions(); // Here we add the search automatically action, too! @@ -147,8 +142,6 @@ void StatusView::AddActions() { void StatusView::CreateWidget() { - //qLog(Debug) << __PRETTY_FUNCTION__; - setLayout(layout_); layout_->setSizeConstraint(QLayout::SetMinAndMaxSize); @@ -174,8 +167,6 @@ void StatusView::CreateWidget() { void StatusView::SetApplication(Application *app) { - //qLog(Debug) << __PRETTY_FUNCTION__; - app_ = app; album_cover_choice_controller_->SetApplication(app_); @@ -185,8 +176,6 @@ void StatusView::SetApplication(Application *app) { void StatusView::NoSongWidget() { - //qLog(Debug) << __PRETTY_FUNCTION__; - if (widgetstate_ == Playing) { container_layout_->removeWidget(widget_playing_); widget_playing_->setVisible(false); @@ -221,8 +210,6 @@ void StatusView::NoSongWidget() { void StatusView::SongWidget() { - //qLog(Debug) << __PRETTY_FUNCTION__; - if (widgetstate_ == Stopped) { container_layout_->removeWidget(widget_stopped_); widget_stopped_->setVisible(false); @@ -275,8 +262,6 @@ void StatusView::SongWidget() { void StatusView::SwitchWidgets(WidgetState state) { - //qLog(Debug) << __PRETTY_FUNCTION__; - if (widgetstate_ == None) NoSongWidget(); if ((state == Stopped) && (widgetstate_ != Stopped)) { @@ -291,8 +276,6 @@ void StatusView::SwitchWidgets(WidgetState state) { void StatusView::UpdateSong() { - //qLog(Debug) << __PRETTY_FUNCTION__; - SwitchWidgets(Playing); const QueryOptions opt; @@ -342,8 +325,6 @@ void StatusView::UpdateSong() { void StatusView::NoSong() { - //qLog(Debug) << __PRETTY_FUNCTION__; - QString html; QImage image_logo(":/pictures/strawberry.png"); QImage image_logo_scaled = image_logo.scaled(300, 300, Qt::KeepAspectRatio); @@ -377,8 +358,6 @@ void StatusView::NoSong() { void StatusView::SongChanged(const Song &song) { - //qLog(Debug) << __PRETTY_FUNCTION__; - stopped_ = false; metadata_ = song; @@ -390,8 +369,6 @@ void StatusView::SongChanged(const Song &song) { void StatusView::SongFinished() { - //qLog(Debug) << __PRETTY_FUNCTION__; - stopped_ = true; SetImage(image_blank_); @@ -399,8 +376,6 @@ void StatusView::SongFinished() { bool StatusView::eventFilter(QObject *object, QEvent *event) { - //qLog(Debug) << __PRETTY_FUNCTION__; - switch(event->type()) { case QEvent::Paint:{ handlePaintEvent(object, event); @@ -416,8 +391,6 @@ bool StatusView::eventFilter(QObject *object, QEvent *event) { void StatusView::handlePaintEvent(QObject *object, QEvent *event) { - //qLog(Debug) << __PRETTY_FUNCTION__ << object->objectName(); - if (object == label_playing_album_) { paintEvent_album(event); } @@ -428,8 +401,6 @@ void StatusView::handlePaintEvent(QObject *object, QEvent *event) { void StatusView::paintEvent_album(QEvent *event) { - //qLog(Debug) << __PRETTY_FUNCTION__; - QPainter p(label_playing_album_); DrawImage(&p); @@ -443,8 +414,6 @@ void StatusView::paintEvent_album(QEvent *event) { void StatusView::DrawImage(QPainter *p) { - //qLog(Debug) << __PRETTY_FUNCTION__; - p->drawPixmap(0, 0, 300, 300, pixmap_current_); if ((downloading_covers_) && (spinner_animation_ != nullptr)) { p->drawPixmap(50, 50, 16, 16, spinner_animation_->currentPixmap()); @@ -454,8 +423,6 @@ void StatusView::DrawImage(QPainter *p) { void StatusView::FadePreviousTrack(qreal value) { - //qLog(Debug) << __PRETTY_FUNCTION__; - pixmap_previous_opacity_ = value; if (qFuzzyCompare(pixmap_previous_opacity_, qreal(0.0))) { pixmap_previous_ = QPixmap(); @@ -477,31 +444,22 @@ void StatusView::contextMenuEvent(QContextMenuEvent *e) { } void StatusView::mouseReleaseEvent(QMouseEvent *) { - - //qLog(Debug) << __PRETTY_FUNCTION__; - } void StatusView::dragEnterEvent(QDragEnterEvent *e) { - //qLog(Debug) << __PRETTY_FUNCTION__; - QWidget::dragEnterEvent(e); } void StatusView::dropEvent(QDropEvent *e) { - //qLog(Debug) << __PRETTY_FUNCTION__; - QWidget::dropEvent(e); } void StatusView::ScaleCover() { - //qLog(Debug) << __PRETTY_FUNCTION__; - pixmap_current_ = QPixmap::fromImage(AlbumCoverLoader::ScaleAndPad(cover_loader_options_, original_)); update(); @@ -509,8 +467,6 @@ void StatusView::ScaleCover() { void StatusView::AlbumArtLoaded(const Song &metadata, const QString&, const QImage &image) { - //qLog(Debug) << __PRETTY_FUNCTION__; - SwitchWidgets(Playing); label_playing_album_->clear(); @@ -527,8 +483,6 @@ void StatusView::AlbumArtLoaded(const Song &metadata, const QString&, const QIma void StatusView::SetImage(const QImage &image) { - //qLog(Debug) << __PRETTY_FUNCTION__; - // Cache the current pixmap so we can fade between them pixmap_previous_ = QPixmap(size()); pixmap_previous_.fill(palette().background().color()); @@ -543,7 +497,7 @@ void StatusView::SetImage(const QImage &image) { ScaleCover(); // Were we waiting for this cover to load before we started fading? - if (!pixmap_previous_.isNull() && fade_animation_ != nullptr) { + if (!pixmap_previous_.isNull() && fade_animation_) { fade_animation_->start(); } @@ -551,8 +505,6 @@ void StatusView::SetImage(const QImage &image) { bool StatusView::GetCoverAutomatically() { - //qLog(Debug) << __PRETTY_FUNCTION__; - SwitchWidgets(Playing); // Search for cover automatically? @@ -581,8 +533,6 @@ bool StatusView::GetCoverAutomatically() { void StatusView::AutomaticCoverSearchDone() { - //qLog(Debug) << __PRETTY_FUNCTION__; - downloading_covers_ = false; spinner_animation_.reset(); update(); @@ -591,8 +541,6 @@ void StatusView::AutomaticCoverSearchDone() { void StatusView::UpdateNoSong() { - //qLog(Debug) << __PRETTY_FUNCTION__; - if (widgetstate_ == Playing) return; NoSong(); diff --git a/src/widgets/statusview.h b/src/widgets/statusview.h index 8f47169fb..e999e6703 100644 --- a/src/widgets/statusview.h +++ b/src/widgets/statusview.h @@ -14,7 +14,7 @@ * * You should have received a copy of the GNU General Public License * along with Strawberry. If not, see . - * + * */ #ifndef STATUSVIEW_H @@ -131,7 +131,6 @@ private: int small_ideal_height_; int total_height_; bool fit_width_; - QTimeLine *show_hide_animation_; QTimeLine *fade_animation_; QImage image_blank_; QImage image_nosong_; @@ -175,3 +174,4 @@ protected: }; #endif // STATUSVIEW_H +