From 0a81fa99fcd8400f0ec50e76df82c3a7df5b03cd Mon Sep 17 00:00:00 2001 From: Jonas Kvinge Date: Sun, 14 Oct 2018 00:08:33 +0200 Subject: [PATCH] Add Deezer support --- CMakeLists.txt | 27 +- data/data.qrc | 438 +--------- data/icons.qrc | 444 +++++++++- data/icons/128x128/deezer.png | Bin 0 -> 2793 bytes data/icons/22x22/deezer.png | Bin 0 -> 1115 bytes data/icons/32x32/deezer.png | Bin 0 -> 1469 bytes data/icons/48x48/deezer.png | Bin 0 -> 2187 bytes data/icons/64x64/deezer.png | Bin 0 -> 2015 bytes data/icons/full/deezer.png | Bin 0 -> 4155 bytes data/misc/oauthsuccess.html | 41 + data/pictures/deezer.png | Bin 0 -> 6217 bytes data/pictures/deezer_black_big.jpg | Bin 0 -> 29569 bytes data/pictures/deezer_white_big.jpg | Bin 0 -> 28862 bytes src/CMakeLists.txt | 46 +- src/collection/groupbydialog.ui | 3 +- src/collection/savedgroupingmanager.ui | 1 + src/config.h.in | 35 +- src/context/contextviewcontainer.ui | 1 + src/core/application.cpp | 101 +-- src/core/application.h | 11 +- src/core/main.cpp | 1 + src/core/mainwindow.cpp | 51 +- src/core/mainwindow.h | 7 +- src/core/mainwindow.ui | 5 +- src/core/metatypes.cpp | 4 + src/core/player.cpp | 24 +- src/core/player.h | 5 + src/core/song.cpp | 4 +- src/core/song.h | 1 + src/covermanager/albumcoverexport.ui | 3 +- src/covermanager/albumcovermanager.ui | 3 +- src/covermanager/coverfromurldialog.ui | 3 +- src/deezer/deezersearch.cpp | 329 ++++++++ src/deezer/deezersearch.h | 164 ++++ src/deezer/deezersearchitemdelegate.cpp | 35 + src/deezer/deezersearchitemdelegate.h | 41 + src/deezer/deezersearchmodel.cpp | 319 +++++++ src/deezer/deezersearchmodel.h | 109 +++ src/deezer/deezersearchsortmodel.cpp | 79 ++ src/deezer/deezersearchsortmodel.h | 35 + src/deezer/deezersearchview.cpp | 574 +++++++++++++ src/deezer/deezersearchview.h | 142 ++++ src/deezer/deezersearchview.ui | 283 +++++++ src/deezer/deezerservice.cpp | 823 +++++++++++++++++++ src/deezer/deezerservice.h | 163 ++++ src/deezer/deezerurlhandler.cpp | 65 ++ src/deezer/deezerurlhandler.h | 56 ++ src/device/deviceproperties.ui | 3 +- src/dialogs/about.ui | 5 +- src/dialogs/edittagdialog.ui | 3 +- src/dialogs/organisedialog.ui | 3 +- src/dialogs/trackselectiondialog.ui | 3 +- src/engine/deezerengine.cpp | 487 +++++++++++ src/engine/deezerengine.h | 88 ++ src/engine/enginebase.h | 3 +- src/engine/enginedevice.cpp | 3 +- src/engine/enginedevice.h | 3 +- src/engine/enginetype.cpp | 21 +- src/engine/enginetype.h | 3 +- src/equalizer/equalizer.ui | 3 +- src/globalshortcuts/globalshortcutgrabber.ui | 3 +- src/internet/internetmodel.cpp | 2 + src/internet/internetmodel.h | 1 - src/internet/localredirectserver.cpp | 111 +++ src/internet/localredirectserver.h | 63 ++ src/playlist/playlistview.h | 2 +- src/playlist/queuemanager.ui | 3 +- src/settings/backendsettingspage.cpp | 3 + src/settings/deezersettingspage.cpp | 142 ++++ src/settings/deezersettingspage.h | 66 ++ src/settings/deezersettingspage.ui | 417 ++++++++++ src/settings/settingsdialog.cpp | 7 +- src/settings/settingsdialog.h | 1 + src/settings/settingsdialog.ui | 3 +- src/settings/shortcutssettingspage.ui | 3 +- src/settings/tidalsettingspage.cpp | 2 +- src/settings/tidalsettingspage.ui | 3 +- src/transcoder/transcodelogdialog.ui | 3 +- 78 files changed, 5309 insertions(+), 630 deletions(-) create mode 100644 data/icons/128x128/deezer.png create mode 100644 data/icons/22x22/deezer.png create mode 100644 data/icons/32x32/deezer.png create mode 100644 data/icons/48x48/deezer.png create mode 100644 data/icons/64x64/deezer.png create mode 100644 data/icons/full/deezer.png create mode 100644 data/misc/oauthsuccess.html create mode 100644 data/pictures/deezer.png create mode 100644 data/pictures/deezer_black_big.jpg create mode 100644 data/pictures/deezer_white_big.jpg create mode 100644 src/deezer/deezersearch.cpp create mode 100644 src/deezer/deezersearch.h create mode 100644 src/deezer/deezersearchitemdelegate.cpp create mode 100644 src/deezer/deezersearchitemdelegate.h create mode 100644 src/deezer/deezersearchmodel.cpp create mode 100644 src/deezer/deezersearchmodel.h create mode 100644 src/deezer/deezersearchsortmodel.cpp create mode 100644 src/deezer/deezersearchsortmodel.h create mode 100644 src/deezer/deezersearchview.cpp create mode 100644 src/deezer/deezersearchview.h create mode 100644 src/deezer/deezersearchview.ui create mode 100644 src/deezer/deezerservice.cpp create mode 100644 src/deezer/deezerservice.h create mode 100644 src/deezer/deezerurlhandler.cpp create mode 100644 src/deezer/deezerurlhandler.h create mode 100644 src/engine/deezerengine.cpp create mode 100644 src/engine/deezerengine.h create mode 100644 src/internet/localredirectserver.cpp create mode 100644 src/internet/localredirectserver.h create mode 100644 src/settings/deezersettingspage.cpp create mode 100644 src/settings/deezersettingspage.h create mode 100644 src/settings/deezersettingspage.ui diff --git a/CMakeLists.txt b/CMakeLists.txt index 57466aaa..118f2f22 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,5 @@ # Strawberry Music Player # Copyright 2013, Jonas Kvinge -# 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 @@ -111,14 +109,13 @@ pkg_check_modules(PHONON phonon4qt5) pkg_check_modules(SQLITE REQUIRED sqlite3>=3.7) pkg_check_modules(LIBPULSE libpulse) pkg_check_modules(CHROMAPRINT libchromaprint) -#if(CHROMAPRINT_FOUND) -# set(HAVE_CHROMAPRINT ON) -#endif() pkg_check_modules(LIBGPOD libgpod-1.0>=0.7.92) pkg_check_modules(LIBMTP libmtp>=1.0) pkg_check_modules(IMOBILEDEVICE libimobiledevice-1.0) pkg_check_modules(USBMUXD libusbmuxd) pkg_check_modules(PLIST libplist) +pkg_check_modules(LIBDEEZER libdeezer) +pkg_check_modules(LIBDZMEDIA libdzmedia) if(WIN32) find_package(ZLIB REQUIRED) @@ -287,6 +284,18 @@ optional_component(PHONON OFF "Engine: Phonon backend" DEPENDS "phonon4qt5" PHONON_FOUND ) +if (WIN32) + optional_component(DEEZER ON "Engine: Deezer backend" + DEPENDS "libdeezer" LIBDEEZER_FOUND + ) +else () + optional_component(DEEZER ON "Engine: Deezer backend" + DEPENDS "Linux" LINUX + DEPENDS "libdeezer" LIBDEEZER_FOUND + DEPENDS "libpulse" LIBPULSE_FOUND + ) +endif() + optional_component(LIBPULSE ON "Pulse audio integration" DEPENDS "libpulse" LIBPULSE_FOUND ) @@ -336,6 +345,10 @@ optional_component(SPARKLE ON "Sparkle integration" DEPENDS "Sparkle" SPARKLE ) +optional_component(DZMEDIA ON "DZMedia" + DEPENDS "libdzmedia" LIBDZMEDIA_FOUND +) + #if(IMOBILEDEVICE_FOUND AND PLIST_FOUND) #add_subdirectory(ext/gstafc) #endif(IMOBILEDEVICE_FOUND AND PLIST_FOUND) @@ -374,6 +387,6 @@ add_custom_target(uninstall # Show a summary of what we have enabled summary_show() -if(NOT HAVE_GSTREAMER AND NOT HAVE_XINE AND NOT HAVE_VLC AND NOT HAVE_PHONON) - message(FATAL_ERROR "You need to enable either GStreamer, Xine, VLC or Phonon to compile!") +if(NOT HAVE_GSTREAMER AND NOT HAVE_XINE AND NOT HAVE_VLC AND NOT HAVE_PHONON AND NOT HAVE_DEEZER) + message(FATAL_ERROR "You need to enable either GStreamer, Xine, VLC, Phonon or Deezer to compile!") endif() diff --git a/data/data.qrc b/data/data.qrc index d0a30ed0..f614b0a3 100644 --- a/data/data.qrc +++ b/data/data.qrc @@ -7,6 +7,7 @@ schema/device-schema.sql style/strawberry.css misc/playing_tooltip.txt + misc/oauthsuccess.html pictures/strawberry.png pictures/strawbs-transparent.png pictures/noalbumart.png @@ -27,442 +28,7 @@ pictures/osd_background.png pictures/osd_shadow_corner.png pictures/osd_shadow_edge.png - icons/128x128/albums.png - icons/128x128/alsa.png - icons/128x128/application-exit.png - icons/128x128/applications-internet.png - icons/128x128/bluetooth.png - icons/128x128/cdcase.png - icons/128x128/cd.png - icons/128x128/configure.png - icons/128x128/device-ipod-nano.png - icons/128x128/device-ipod.png - icons/128x128/device-phone.png - icons/128x128/device.png - icons/128x128/device-usb-drive.png - icons/128x128/device-usb-flash.png - icons/128x128/dialog-error.png - icons/128x128/dialog-information.png - icons/128x128/dialog-ok-apply.png - icons/128x128/dialog-password.png - icons/128x128/dialog-warning.png - icons/128x128/document-download.png - icons/128x128/document-new.png - icons/128x128/document-open-folder.png - icons/128x128/document-open.png - icons/128x128/document-save.png - icons/128x128/document-search.png - icons/128x128/download.png - icons/128x128/edit-clear-list.png - icons/128x128/edit-clear-locationbar-ltr.png - icons/128x128/edit-copy.png - icons/128x128/edit-delete.png - icons/128x128/edit-find.png - icons/128x128/edit-redo.png - icons/128x128/edit-rename.png - icons/128x128/edit-undo.png - icons/128x128/electrocompaniet.png - icons/128x128/equalizer.png - icons/128x128/folder-new.png - icons/128x128/folder.png - icons/128x128/folder-sound.png - icons/128x128/footsteps.png - icons/128x128/go-down.png - icons/128x128/go-home.png - icons/128x128/go-jump.png - icons/128x128/go-next.png - icons/128x128/go-previous.png - icons/128x128/go-up.png - icons/128x128/gstreamer.png - icons/128x128/headset.png - icons/128x128/help-hint.png - icons/128x128/intel.png - icons/128x128/jack.png - icons/128x128/keyboard.png - icons/128x128/list-add.png - icons/128x128/list-remove.png - icons/128x128/mcintosh-player.png - icons/128x128/mcintosh-text.png - icons/128x128/media-eject.png - icons/128x128/media-forward.png - icons/128x128/media-pause.png - icons/128x128/media-play.png - icons/128x128/media-rewind.png - icons/128x128/media-stop.png - icons/128x128/nvidia.png - icons/128x128/play2.png - icons/128x128/realtek.png - icons/128x128/search.png - icons/128x128/soundcard.png - icons/128x128/speaker.png - icons/128x128/star-grey.png - icons/128x128/star.png - icons/128x128/strawberry.png - icons/128x128/strawberry.svg - icons/128x128/tools-wizard.png - icons/128x128/view-choose.png - icons/128x128/view-fullscreen.png - icons/128x128/view-media-lyrics.png - icons/128x128/view-media-playlist.png - icons/128x128/view-media-visualization.png - icons/128x128/view-refresh.png - icons/128x128/vinyl.png - icons/128x128/vlc.png - 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 - icons/64x64/applications-internet.png - icons/64x64/bluetooth.png - icons/64x64/cdcase.png - icons/64x64/cd.png - icons/64x64/configure.png - icons/64x64/device-ipod-nano.png - icons/64x64/device-ipod.png - icons/64x64/device-phone.png - icons/64x64/device.png - icons/64x64/device-usb-drive.png - icons/64x64/device-usb-flash.png - icons/64x64/dialog-error.png - icons/64x64/dialog-information.png - icons/64x64/dialog-ok-apply.png - icons/64x64/dialog-password.png - icons/64x64/dialog-warning.png - icons/64x64/document-download.png - icons/64x64/document-new.png - icons/64x64/document-open-folder.png - icons/64x64/document-open.png - icons/64x64/document-save.png - icons/64x64/document-search.png - icons/64x64/download.png - icons/64x64/edit-clear-list.png - icons/64x64/edit-clear-locationbar-ltr.png - icons/64x64/edit-copy.png - icons/64x64/edit-delete.png - icons/64x64/edit-find.png - icons/64x64/edit-redo.png - icons/64x64/edit-rename.png - icons/64x64/edit-undo.png - icons/64x64/electrocompaniet.png - icons/64x64/equalizer.png - icons/64x64/folder-new.png - icons/64x64/folder.png - icons/64x64/folder-sound.png - icons/64x64/footsteps.png - icons/64x64/go-down.png - icons/64x64/go-home.png - icons/64x64/go-jump.png - icons/64x64/go-next.png - icons/64x64/go-previous.png - icons/64x64/go-up.png - icons/64x64/gstreamer.png - icons/64x64/headset.png - icons/64x64/help-hint.png - icons/64x64/intel.png - icons/64x64/jack.png - icons/64x64/keyboard.png - icons/64x64/list-add.png - icons/64x64/list-remove.png - icons/64x64/mcintosh-player.png - icons/64x64/mcintosh-text.png - icons/64x64/media-eject.png - icons/64x64/media-forward.png - icons/64x64/media-pause.png - icons/64x64/media-play.png - icons/64x64/media-rewind.png - icons/64x64/media-stop.png - icons/64x64/nvidia.png - icons/64x64/play2.png - icons/64x64/pulseaudio.png - icons/64x64/realtek.png - icons/64x64/search.png - icons/64x64/soundcard.png - icons/64x64/speaker.png - icons/64x64/star-grey.png - icons/64x64/star.png - icons/64x64/strawberry.png - icons/64x64/tools-wizard.png - icons/64x64/view-choose.png - icons/64x64/view-fullscreen.png - icons/64x64/view-media-lyrics.png - icons/64x64/view-media-playlist.png - icons/64x64/view-media-visualization.png - icons/64x64/view-refresh.png - icons/64x64/vinyl.png - icons/64x64/vlc.png - 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 - icons/48x48/applications-internet.png - icons/48x48/bluetooth.png - icons/48x48/cdcase.png - icons/48x48/cd.png - icons/48x48/configure.png - icons/48x48/device-ipod-nano.png - icons/48x48/device-ipod.png - icons/48x48/device-phone.png - icons/48x48/device.png - icons/48x48/device-usb-drive.png - icons/48x48/device-usb-flash.png - icons/48x48/dialog-error.png - icons/48x48/dialog-information.png - icons/48x48/dialog-ok-apply.png - icons/48x48/dialog-password.png - icons/48x48/dialog-warning.png - icons/48x48/document-download.png - icons/48x48/document-new.png - icons/48x48/document-open-folder.png - icons/48x48/document-open.png - icons/48x48/document-save.png - icons/48x48/document-search.png - icons/48x48/download.png - icons/48x48/edit-clear-list.png - icons/48x48/edit-clear-locationbar-ltr.png - icons/48x48/edit-copy.png - icons/48x48/edit-delete.png - icons/48x48/edit-find.png - icons/48x48/edit-redo.png - icons/48x48/edit-rename.png - icons/48x48/edit-undo.png - icons/48x48/electrocompaniet.png - icons/48x48/equalizer.png - icons/48x48/folder-new.png - icons/48x48/folder.png - icons/48x48/folder-sound.png - icons/48x48/footsteps.png - icons/48x48/go-down.png - icons/48x48/go-home.png - icons/48x48/go-jump.png - icons/48x48/go-next.png - icons/48x48/go-previous.png - icons/48x48/go-up.png - icons/48x48/gstreamer.png - icons/48x48/headset.png - icons/48x48/help-hint.png - icons/48x48/intel.png - icons/48x48/jack.png - icons/48x48/keyboard.png - icons/48x48/list-add.png - icons/48x48/list-remove.png - icons/48x48/mcintosh-player.png - icons/48x48/mcintosh.png - icons/48x48/mcintosh-text.png - icons/48x48/media-eject.png - icons/48x48/media-forward.png - icons/48x48/media-pause.png - icons/48x48/media-playlist-repeat.png - icons/48x48/media-playlist-shuffle.png - icons/48x48/media-play.png - icons/48x48/media-rewind.png - icons/48x48/media-stop.png - icons/48x48/nvidia.png - icons/48x48/play2.png - icons/48x48/pulseaudio.png - icons/48x48/realtek.png - icons/48x48/search.png - icons/48x48/soundcard.png - icons/48x48/speaker.png - icons/48x48/star-grey.png - icons/48x48/star.png - icons/48x48/strawberry.png - icons/48x48/tools-wizard.png - icons/48x48/view-choose.png - icons/48x48/view-fullscreen.png - icons/48x48/view-media-lyrics.png - icons/48x48/view-media-playlist.png - icons/48x48/view-media-visualization.png - icons/48x48/view-refresh.png - icons/48x48/vinyl.png - icons/48x48/vlc.png - 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 - icons/32x32/applications-internet.png - icons/32x32/bluetooth.png - icons/32x32/cdcase.png - icons/32x32/cd.png - icons/32x32/configure.png - icons/32x32/device-ipod-nano.png - icons/32x32/device-ipod.png - icons/32x32/device-phone.png - icons/32x32/device.png - icons/32x32/device-usb-drive.png - icons/32x32/device-usb-flash.png - icons/32x32/dialog-error.png - icons/32x32/dialog-information.png - icons/32x32/dialog-ok-apply.png - icons/32x32/dialog-password.png - icons/32x32/dialog-warning.png - icons/32x32/document-download.png - icons/32x32/document-new.png - icons/32x32/document-open-folder.png - icons/32x32/document-open.png - icons/32x32/document-save.png - icons/32x32/document-search.png - icons/32x32/download.png - icons/32x32/edit-clear-list.png - icons/32x32/edit-clear-locationbar-ltr.png - icons/32x32/edit-copy.png - icons/32x32/edit-delete.png - icons/32x32/edit-find.png - icons/32x32/edit-redo.png - icons/32x32/edit-rename.png - icons/32x32/edit-undo.png - icons/32x32/electrocompaniet.png - icons/32x32/equalizer.png - icons/32x32/folder-new.png - icons/32x32/folder.png - icons/32x32/folder-sound.png - icons/32x32/footsteps.png - icons/32x32/go-down.png - icons/32x32/go-home.png - icons/32x32/go-jump.png - icons/32x32/go-next.png - icons/32x32/go-previous.png - icons/32x32/go-up.png - icons/32x32/gstreamer.png - icons/32x32/headset.png - icons/32x32/help-hint.png - icons/32x32/intel.png - icons/32x32/jack.png - icons/32x32/keyboard.png - icons/32x32/list-add.png - icons/32x32/list-remove.png - icons/32x32/mcintosh-player.png - icons/32x32/mcintosh.png - icons/32x32/mcintosh-text.png - icons/32x32/media-eject.png - icons/32x32/media-forward.png - icons/32x32/media-pause.png - icons/32x32/media-playlist-repeat.png - icons/32x32/media-playlist-shuffle.png - icons/32x32/media-play.png - icons/32x32/media-rewind.png - icons/32x32/media-stop.png - icons/32x32/nvidia.png - icons/32x32/play2.png - icons/32x32/pulseaudio.png - icons/32x32/realtek.png - icons/32x32/search.png - icons/32x32/soundcard.png - icons/32x32/speaker.png - icons/32x32/star-grey.png - icons/32x32/star.png - icons/32x32/strawberry.png - icons/32x32/strawberry.svg - icons/32x32/tools-wizard.png - icons/32x32/view-choose.png - icons/32x32/view-fullscreen.png - icons/32x32/view-media-lyrics.png - icons/32x32/view-media-playlist.png - icons/32x32/view-media-visualization.png - icons/32x32/view-refresh.png - icons/32x32/vinyl.png - icons/32x32/vlc.png - 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 - icons/22x22/applications-internet.png - icons/22x22/bluetooth.png - icons/22x22/cdcase.png - icons/22x22/cd.png - icons/22x22/configure.png - icons/22x22/device-ipod-nano.png - icons/22x22/device-ipod.png - icons/22x22/device-phone.png - icons/22x22/device.png - icons/22x22/device-usb-drive.png - icons/22x22/device-usb-flash.png - icons/22x22/dialog-error.png - icons/22x22/dialog-information.png - icons/22x22/dialog-ok-apply.png - icons/22x22/dialog-password.png - icons/22x22/dialog-warning.png - icons/22x22/document-download.png - icons/22x22/document-new.png - icons/22x22/document-open-folder.png - icons/22x22/document-open.png - icons/22x22/document-save.png - icons/22x22/document-search.png - icons/22x22/download.png - icons/22x22/edit-clear-list.png - icons/22x22/edit-clear-locationbar-ltr.png - icons/22x22/edit-copy.png - icons/22x22/edit-delete.png - icons/22x22/edit-find.png - icons/22x22/edit-redo.png - icons/22x22/edit-rename.png - icons/22x22/edit-undo.png - icons/22x22/electrocompaniet.png - icons/22x22/equalizer.png - icons/22x22/folder-new.png - icons/22x22/folder.png - icons/22x22/folder-sound.png - icons/22x22/footsteps.png - icons/22x22/go-down.png - icons/22x22/go-home.png - icons/22x22/go-jump.png - icons/22x22/go-next.png - icons/22x22/go-previous.png - icons/22x22/go-up.png - icons/22x22/gstreamer.png - icons/22x22/headset.png - icons/22x22/help-hint.png - icons/22x22/intel.png - icons/22x22/jack.png - icons/22x22/keyboard.png - icons/22x22/list-add.png - icons/22x22/list-remove.png - icons/22x22/mcintosh-player.png - icons/22x22/mcintosh.png - icons/22x22/mcintosh-text.png - icons/22x22/media-eject.png - icons/22x22/media-forward.png - icons/22x22/media-pause.png - icons/22x22/media-playlist-repeat.png - icons/22x22/media-playlist-shuffle.png - icons/22x22/media-play.png - icons/22x22/media-rewind.png - icons/22x22/media-stop.png - icons/22x22/nvidia.png - icons/22x22/play2.png - icons/22x22/pulseaudio.png - icons/22x22/realtek.png - icons/22x22/search.png - icons/22x22/soundcard.png - icons/22x22/speaker.png - icons/22x22/star-grey.png - icons/22x22/star.png - icons/22x22/strawberry.png - icons/22x22/strawberry.svg - icons/22x22/tools-wizard.png - icons/22x22/view-choose.png - icons/22x22/view-fullscreen.png - icons/22x22/view-media-lyrics.png - icons/22x22/view-media-playlist.png - icons/22x22/view-media-visualization.png - icons/22x22/view-refresh.png - icons/22x22/vinyl.png - icons/22x22/vlc.png - icons/22x22/xine.png - icons/22x22/zoom-in.png - icons/22x22/zoom-out.png - icons/22x22/tidal.png + pictures/deezer.png fonts/HumongousofEternitySt.ttf diff --git a/data/icons.qrc b/data/icons.qrc index 63ea2638..cc9f52f8 100644 --- a/data/icons.qrc +++ b/data/icons.qrc @@ -1,4 +1,444 @@ - - + + icons/128x128/albums.png + icons/128x128/alsa.png + icons/128x128/application-exit.png + icons/128x128/applications-internet.png + icons/128x128/bluetooth.png + icons/128x128/cdcase.png + icons/128x128/cd.png + icons/128x128/configure.png + icons/128x128/device-ipod-nano.png + icons/128x128/device-ipod.png + icons/128x128/device-phone.png + icons/128x128/device.png + icons/128x128/device-usb-drive.png + icons/128x128/device-usb-flash.png + icons/128x128/dialog-error.png + icons/128x128/dialog-information.png + icons/128x128/dialog-ok-apply.png + icons/128x128/dialog-password.png + icons/128x128/dialog-warning.png + icons/128x128/document-download.png + icons/128x128/document-new.png + icons/128x128/document-open-folder.png + icons/128x128/document-open.png + icons/128x128/document-save.png + icons/128x128/document-search.png + icons/128x128/download.png + icons/128x128/edit-clear-list.png + icons/128x128/edit-clear-locationbar-ltr.png + icons/128x128/edit-copy.png + icons/128x128/edit-delete.png + icons/128x128/edit-find.png + icons/128x128/edit-redo.png + icons/128x128/edit-rename.png + icons/128x128/edit-undo.png + icons/128x128/electrocompaniet.png + icons/128x128/equalizer.png + icons/128x128/folder-new.png + icons/128x128/folder.png + icons/128x128/folder-sound.png + icons/128x128/footsteps.png + icons/128x128/go-down.png + icons/128x128/go-home.png + icons/128x128/go-jump.png + icons/128x128/go-next.png + icons/128x128/go-previous.png + icons/128x128/go-up.png + icons/128x128/gstreamer.png + icons/128x128/headset.png + icons/128x128/help-hint.png + icons/128x128/intel.png + icons/128x128/jack.png + icons/128x128/keyboard.png + icons/128x128/list-add.png + icons/128x128/list-remove.png + icons/128x128/mcintosh-player.png + icons/128x128/mcintosh-text.png + icons/128x128/media-eject.png + icons/128x128/media-forward.png + icons/128x128/media-pause.png + icons/128x128/media-play.png + icons/128x128/media-rewind.png + icons/128x128/media-stop.png + icons/128x128/nvidia.png + icons/128x128/play2.png + icons/128x128/realtek.png + icons/128x128/search.png + icons/128x128/soundcard.png + icons/128x128/speaker.png + icons/128x128/star-grey.png + icons/128x128/star.png + icons/128x128/strawberry.png + icons/128x128/strawberry.svg + icons/128x128/tools-wizard.png + icons/128x128/view-choose.png + icons/128x128/view-fullscreen.png + icons/128x128/view-media-lyrics.png + icons/128x128/view-media-playlist.png + icons/128x128/view-media-visualization.png + icons/128x128/view-refresh.png + icons/128x128/vinyl.png + icons/128x128/vlc.png + icons/128x128/xine.png + icons/128x128/zoom-in.png + icons/128x128/zoom-out.png + icons/128x128/tidal.png + icons/128x128/deezer.png + icons/64x64/albums.png + icons/64x64/alsa.png + icons/64x64/application-exit.png + icons/64x64/applications-internet.png + icons/64x64/bluetooth.png + icons/64x64/cdcase.png + icons/64x64/cd.png + icons/64x64/configure.png + icons/64x64/device-ipod-nano.png + icons/64x64/device-ipod.png + icons/64x64/device-phone.png + icons/64x64/device.png + icons/64x64/device-usb-drive.png + icons/64x64/device-usb-flash.png + icons/64x64/dialog-error.png + icons/64x64/dialog-information.png + icons/64x64/dialog-ok-apply.png + icons/64x64/dialog-password.png + icons/64x64/dialog-warning.png + icons/64x64/document-download.png + icons/64x64/document-new.png + icons/64x64/document-open-folder.png + icons/64x64/document-open.png + icons/64x64/document-save.png + icons/64x64/document-search.png + icons/64x64/download.png + icons/64x64/edit-clear-list.png + icons/64x64/edit-clear-locationbar-ltr.png + icons/64x64/edit-copy.png + icons/64x64/edit-delete.png + icons/64x64/edit-find.png + icons/64x64/edit-redo.png + icons/64x64/edit-rename.png + icons/64x64/edit-undo.png + icons/64x64/electrocompaniet.png + icons/64x64/equalizer.png + icons/64x64/folder-new.png + icons/64x64/folder.png + icons/64x64/folder-sound.png + icons/64x64/footsteps.png + icons/64x64/go-down.png + icons/64x64/go-home.png + icons/64x64/go-jump.png + icons/64x64/go-next.png + icons/64x64/go-previous.png + icons/64x64/go-up.png + icons/64x64/gstreamer.png + icons/64x64/headset.png + icons/64x64/help-hint.png + icons/64x64/intel.png + icons/64x64/jack.png + icons/64x64/keyboard.png + icons/64x64/list-add.png + icons/64x64/list-remove.png + icons/64x64/mcintosh-player.png + icons/64x64/mcintosh-text.png + icons/64x64/media-eject.png + icons/64x64/media-forward.png + icons/64x64/media-pause.png + icons/64x64/media-play.png + icons/64x64/media-rewind.png + icons/64x64/media-stop.png + icons/64x64/nvidia.png + icons/64x64/play2.png + icons/64x64/pulseaudio.png + icons/64x64/realtek.png + icons/64x64/search.png + icons/64x64/soundcard.png + icons/64x64/speaker.png + icons/64x64/star-grey.png + icons/64x64/star.png + icons/64x64/strawberry.png + icons/64x64/tools-wizard.png + icons/64x64/view-choose.png + icons/64x64/view-fullscreen.png + icons/64x64/view-media-lyrics.png + icons/64x64/view-media-playlist.png + icons/64x64/view-media-visualization.png + icons/64x64/view-refresh.png + icons/64x64/vinyl.png + icons/64x64/vlc.png + icons/64x64/xine.png + icons/64x64/zoom-in.png + icons/64x64/zoom-out.png + icons/64x64/tidal.png + icons/64x64/deezer.png + icons/48x48/albums.png + icons/48x48/alsa.png + icons/48x48/application-exit.png + icons/48x48/applications-internet.png + icons/48x48/bluetooth.png + icons/48x48/cdcase.png + icons/48x48/cd.png + icons/48x48/configure.png + icons/48x48/device-ipod-nano.png + icons/48x48/device-ipod.png + icons/48x48/device-phone.png + icons/48x48/device.png + icons/48x48/device-usb-drive.png + icons/48x48/device-usb-flash.png + icons/48x48/dialog-error.png + icons/48x48/dialog-information.png + icons/48x48/dialog-ok-apply.png + icons/48x48/dialog-password.png + icons/48x48/dialog-warning.png + icons/48x48/document-download.png + icons/48x48/document-new.png + icons/48x48/document-open-folder.png + icons/48x48/document-open.png + icons/48x48/document-save.png + icons/48x48/document-search.png + icons/48x48/download.png + icons/48x48/edit-clear-list.png + icons/48x48/edit-clear-locationbar-ltr.png + icons/48x48/edit-copy.png + icons/48x48/edit-delete.png + icons/48x48/edit-find.png + icons/48x48/edit-redo.png + icons/48x48/edit-rename.png + icons/48x48/edit-undo.png + icons/48x48/electrocompaniet.png + icons/48x48/equalizer.png + icons/48x48/folder-new.png + icons/48x48/folder.png + icons/48x48/folder-sound.png + icons/48x48/footsteps.png + icons/48x48/go-down.png + icons/48x48/go-home.png + icons/48x48/go-jump.png + icons/48x48/go-next.png + icons/48x48/go-previous.png + icons/48x48/go-up.png + icons/48x48/gstreamer.png + icons/48x48/headset.png + icons/48x48/help-hint.png + icons/48x48/intel.png + icons/48x48/jack.png + icons/48x48/keyboard.png + icons/48x48/list-add.png + icons/48x48/list-remove.png + icons/48x48/mcintosh-player.png + icons/48x48/mcintosh.png + icons/48x48/mcintosh-text.png + icons/48x48/media-eject.png + icons/48x48/media-forward.png + icons/48x48/media-pause.png + icons/48x48/media-playlist-repeat.png + icons/48x48/media-playlist-shuffle.png + icons/48x48/media-play.png + icons/48x48/media-rewind.png + icons/48x48/media-stop.png + icons/48x48/nvidia.png + icons/48x48/play2.png + icons/48x48/pulseaudio.png + icons/48x48/realtek.png + icons/48x48/search.png + icons/48x48/soundcard.png + icons/48x48/speaker.png + icons/48x48/star-grey.png + icons/48x48/star.png + icons/48x48/strawberry.png + icons/48x48/tools-wizard.png + icons/48x48/view-choose.png + icons/48x48/view-fullscreen.png + icons/48x48/view-media-lyrics.png + icons/48x48/view-media-playlist.png + icons/48x48/view-media-visualization.png + icons/48x48/view-refresh.png + icons/48x48/vinyl.png + icons/48x48/vlc.png + 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 + icons/32x32/applications-internet.png + icons/32x32/bluetooth.png + icons/32x32/cdcase.png + icons/32x32/cd.png + icons/32x32/configure.png + icons/32x32/device-ipod-nano.png + icons/32x32/device-ipod.png + icons/32x32/device-phone.png + icons/32x32/device.png + icons/32x32/device-usb-drive.png + icons/32x32/device-usb-flash.png + icons/32x32/dialog-error.png + icons/32x32/dialog-information.png + icons/32x32/dialog-ok-apply.png + icons/32x32/dialog-password.png + icons/32x32/dialog-warning.png + icons/32x32/document-download.png + icons/32x32/document-new.png + icons/32x32/document-open-folder.png + icons/32x32/document-open.png + icons/32x32/document-save.png + icons/32x32/document-search.png + icons/32x32/download.png + icons/32x32/edit-clear-list.png + icons/32x32/edit-clear-locationbar-ltr.png + icons/32x32/edit-copy.png + icons/32x32/edit-delete.png + icons/32x32/edit-find.png + icons/32x32/edit-redo.png + icons/32x32/edit-rename.png + icons/32x32/edit-undo.png + icons/32x32/electrocompaniet.png + icons/32x32/equalizer.png + icons/32x32/folder-new.png + icons/32x32/folder.png + icons/32x32/folder-sound.png + icons/32x32/footsteps.png + icons/32x32/go-down.png + icons/32x32/go-home.png + icons/32x32/go-jump.png + icons/32x32/go-next.png + icons/32x32/go-previous.png + icons/32x32/go-up.png + icons/32x32/gstreamer.png + icons/32x32/headset.png + icons/32x32/help-hint.png + icons/32x32/intel.png + icons/32x32/jack.png + icons/32x32/keyboard.png + icons/32x32/list-add.png + icons/32x32/list-remove.png + icons/32x32/mcintosh-player.png + icons/32x32/mcintosh.png + icons/32x32/mcintosh-text.png + icons/32x32/media-eject.png + icons/32x32/media-forward.png + icons/32x32/media-pause.png + icons/32x32/media-playlist-repeat.png + icons/32x32/media-playlist-shuffle.png + icons/32x32/media-play.png + icons/32x32/media-rewind.png + icons/32x32/media-stop.png + icons/32x32/nvidia.png + icons/32x32/play2.png + icons/32x32/pulseaudio.png + icons/32x32/realtek.png + icons/32x32/search.png + icons/32x32/soundcard.png + icons/32x32/speaker.png + icons/32x32/star-grey.png + icons/32x32/star.png + icons/32x32/strawberry.png + icons/32x32/strawberry.svg + icons/32x32/tools-wizard.png + icons/32x32/view-choose.png + icons/32x32/view-fullscreen.png + icons/32x32/view-media-lyrics.png + icons/32x32/view-media-playlist.png + icons/32x32/view-media-visualization.png + icons/32x32/view-refresh.png + icons/32x32/vinyl.png + icons/32x32/vlc.png + icons/32x32/xine.png + icons/32x32/zoom-in.png + icons/32x32/zoom-out.png + icons/32x32/tidal.png + icons/32x32/deezer.png + icons/22x22/albums.png + icons/22x22/alsa.png + icons/22x22/application-exit.png + icons/22x22/applications-internet.png + icons/22x22/bluetooth.png + icons/22x22/cdcase.png + icons/22x22/cd.png + icons/22x22/configure.png + icons/22x22/device-ipod-nano.png + icons/22x22/device-ipod.png + icons/22x22/device-phone.png + icons/22x22/device.png + icons/22x22/device-usb-drive.png + icons/22x22/device-usb-flash.png + icons/22x22/dialog-error.png + icons/22x22/dialog-information.png + icons/22x22/dialog-ok-apply.png + icons/22x22/dialog-password.png + icons/22x22/dialog-warning.png + icons/22x22/document-download.png + icons/22x22/document-new.png + icons/22x22/document-open-folder.png + icons/22x22/document-open.png + icons/22x22/document-save.png + icons/22x22/document-search.png + icons/22x22/download.png + icons/22x22/edit-clear-list.png + icons/22x22/edit-clear-locationbar-ltr.png + icons/22x22/edit-copy.png + icons/22x22/edit-delete.png + icons/22x22/edit-find.png + icons/22x22/edit-redo.png + icons/22x22/edit-rename.png + icons/22x22/edit-undo.png + icons/22x22/electrocompaniet.png + icons/22x22/equalizer.png + icons/22x22/folder-new.png + icons/22x22/folder.png + icons/22x22/folder-sound.png + icons/22x22/footsteps.png + icons/22x22/go-down.png + icons/22x22/go-home.png + icons/22x22/go-jump.png + icons/22x22/go-next.png + icons/22x22/go-previous.png + icons/22x22/go-up.png + icons/22x22/gstreamer.png + icons/22x22/headset.png + icons/22x22/help-hint.png + icons/22x22/intel.png + icons/22x22/jack.png + icons/22x22/keyboard.png + icons/22x22/list-add.png + icons/22x22/list-remove.png + icons/22x22/mcintosh-player.png + icons/22x22/mcintosh.png + icons/22x22/mcintosh-text.png + icons/22x22/media-eject.png + icons/22x22/media-forward.png + icons/22x22/media-pause.png + icons/22x22/media-playlist-repeat.png + icons/22x22/media-playlist-shuffle.png + icons/22x22/media-play.png + icons/22x22/media-rewind.png + icons/22x22/media-stop.png + icons/22x22/nvidia.png + icons/22x22/play2.png + icons/22x22/pulseaudio.png + icons/22x22/realtek.png + icons/22x22/search.png + icons/22x22/soundcard.png + icons/22x22/speaker.png + icons/22x22/star-grey.png + icons/22x22/star.png + icons/22x22/strawberry.png + icons/22x22/strawberry.svg + icons/22x22/tools-wizard.png + icons/22x22/view-choose.png + icons/22x22/view-fullscreen.png + icons/22x22/view-media-lyrics.png + icons/22x22/view-media-playlist.png + icons/22x22/view-media-visualization.png + icons/22x22/view-refresh.png + icons/22x22/vinyl.png + icons/22x22/vlc.png + icons/22x22/xine.png + icons/22x22/zoom-in.png + icons/22x22/zoom-out.png + icons/22x22/tidal.png + icons/22x22/deezer.png + diff --git a/data/icons/128x128/deezer.png b/data/icons/128x128/deezer.png new file mode 100644 index 0000000000000000000000000000000000000000..ca22afc366e90c66ce5e30ea6278ae4d2643bbfc GIT binary patch literal 2793 zcmZWrc{tSDAN~$8GBU^&5hYO=#!_Y`M432f#tT`n3M0i2&Lua5GFi|q9?u?+wK;rBlWBuzzFnhQdSrsi1agdpz$ zbupb}``@@}1ExlXHl(4&C*8gZ#=p?dl)t0sHPC8*)43^jLckrHBoOfpY(ADOGI3vk zV_2Xdmh<-ZbkarPMu@Ogkh&jFa$^MSti0Hn<_NjHD%alZIVL78&S56fdwU%owsdaN zhqQ9batCe6W5#wYFZ>4n^Ts>&=k1_=&{^{SfAtwTDN4_;>h&hMGte}*60f!geqH&+ zv|aXhO1fU4!qf2GEk{F98cSZjL*81BQs#fj%IUzhWsV%^-CveaqJ3Kg8uity%JWqA zbsk3`mp|R&mG?-1ZUtmVv?h0EOD-n>z&C{5^?Z>Zh00|9shfdHybjdEbe|W$RBrnb z6D#D?T^+GZtR40Cmw6T7GrM?L)mls-R9^nQzgda` zyw!i_!VBvy_)50bTq`ejRkvRL2-@>XReI(&Jhn< zi6i!m@&pA3LlR3k36TVr`wF z0nI$tWq9{>uuv0uV07*fFBkZwxUPxAzSu*v2~`w{IGw#R?OjnRrMo|qw^MB9PE@!* zx)Z{G>VlIn(%S~Ud#?`>SIRRj69A&=+}f`toxa3IV^{JwmYQXp(n$}$Ixd$_{>o3R zbI(&L>dWHo6cpWgg0MTY6*iv#RR^N2Ku^ou+X?ZAWAzO2%gypw-e`P@%@0E4jc=Lr!iHbUYPS^p=B4dz&KMkyV@LWO2!aTP&omFX( zY>-wT4Sg6>VaCXD6-Lf*&j&rD&o9DOH<;d_N3!uY97Z#7YrFZ}^&7 z!YmDBY%-MluBI?%Hd{AtHEog94d%?Jus6UYjPqi4iA3JewiSscX-yx4AwA;Ijej3k zY9b`s@i2~fWTHmLKEc8cmgE?dT=2WJk`i?OhAr&F728E3`z z#2-E^GXiAeBCL?F@d$$P(bya&NzWxf=-*}aEqHr*Ea+5F^pF;!5yko`mT4d$kpO9# ze=OmLDdZFXUO|8aJKKQ-NFiTNAR=$@=KXSGY~sFMh3f9*s&cm_$EEhsjK%UtN10j2 zSOO`wijjL>hRDfu7AX^mNIpyitcqsi6-rhgYREG>GS@b~$h#JA4COcS|3e0!$4iO` zdf%8Q|GZBC9+MU=ej?dgWePCO{d248qy$fcZOMM@&Mo(Q7UKCs(mTHTwO!VF@b(8j z3+H!ct2=HkoR2l=3)I~y$D~m=Pl)AZALsm7SF`;DV^ebGSIu94tLP?2ukj{+-PXtTbsYL~=wV-Zr~)i7$O`PCzH*Z-craZ`U~P zYBGBCsk!FKvLECU{??my%qJqab@>*{D0bcra2+0|#3KjA)(yqS?leIoQ%s~Ut>WW(;)rZC zD0NG;QmJ|Q)fE8;{_~>0%x%>R-2CkxF8m5s8$O#_AeL+lEwK zNG&LeRCZU>uF+`W?f!+dxo0`+GMqibIQ9Pe016-8>kdB8u)IBJmKTQV1Grh-ivamX_iZiVBJsRXaXex!=b$ZZ9`n$||&fsK*>QqmtHs!(&wH8Ok>O$K>=M+4iO> zLI|xsh|fq3q{r6Lad$3F1(zGx<&YH^^I&y#%(tYhYIQ`2^dX&nPp`OQJX4A2@u#;6DX;51R4}^GVi3ty(EHe+ZvRm zBKyH(RGeMp|j0)v``;HN@*NAS;M0bqqZFg<}G?5x8ga#6U)Uwgk yz-a4Wv@}(aniwR~+TUUBe+ajH-3T5b|4-0B{X=LCsq^3x08^}mQQ29i$iD!ip($Yi literal 0 HcmV?d00001 diff --git a/data/icons/22x22/deezer.png b/data/icons/22x22/deezer.png new file mode 100644 index 0000000000000000000000000000000000000000..2fd13abc3f7a83e6b96a11f8edc6b2b7468c914d GIT binary patch literal 1115 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 XGCD9Y#d*DE00000NkvXXu0mjfQb&ST literal 0 HcmV?d00001 diff --git a/data/icons/48x48/deezer.png b/data/icons/48x48/deezer.png new file mode 100644 index 0000000000000000000000000000000000000000..ffda072f3e45c8cc7811b4b3f2caafef24c4b4af GIT binary patch literal 2187 zcmV;62z2*}P)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 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 literal 0 HcmV?d00001 diff --git a/data/icons/64x64/deezer.png b/data/icons/64x64/deezer.png new file mode 100644 index 0000000000000000000000000000000000000000..06b9d92bfc49723a5c951cdc6490c31f00ffbdd0 GIT binary patch literal 2015 zcmV<52O#)~P)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 zVoOIv0RI600RN!9r;`8x010qNS#tmY4#WTe4#WYKD-Ig~000McNliru;tB&7FA_JA zTebiI2F^)DK~#9!?VC?*9CsDRKkxUOnf;e_*0xM7rBFzE0f|O%sDwm8>YaugN1|ME z?u{El;=rvO0MP?gZb+P(79`++1Vt)2A&W|IAQ3@TS!5h1c6PlxGr!;CFk{E-otYiS zj{l_lE@SWh_U-R|=e_s&&CG9MZgZR4r`crxKi98cKkfOPh~2t%Yg&N)!HY-;tN`I@ zPxhEB0oGMDIVeJKaHNR5?7jcKs(vjOKu>%2M>GKv5&6&<^8;1==l(bM1P~FdwT;ne z^uzIZ{2k{Us^>lSiP)|*8jY~oY<@RQ)A5u5aU3HeD?2+oucm2w+H*d3bI#$te-&6! z)sLqH7!HR3)_ZSHd$P~U(loV$!C)f3JpsJ;yMLaqyq{I3G66c>7w4n^=hBRUb{9-Z z(s~EOZ8#A0^C5t07W}tvK({xY2_mrl5Z-?$J5FZ8b07d@XTIBmi_66V?2f5&aoRB) z-Q+3X%j|q-fOq&u8(FQFlz{Q>Yzd$*cbh?<=-pFWBlmf$T_~1PlY@ZDZ7!18&f#vYx)OE&gnwrD(RpLnt;MDUVeuVeFqy)BcHS0f%@dPL^OT(Hy8$sAg zAM=xc{j%JtfQu9nsjpw&b15v$?9qdSzyAE+jM5I~jT_Ki zEGdD_qg@La5tgHf&omlSXL&a(%q-AO1Zn66U6x8Hp!pav&|C^>Ek~tme6gV3Al)8Z zTq+r-Gp_~eH^D;$dTds*!kh6z&G%{u;8j^4jqiw%nnhovCO1^H;T6jDq7-4ur5T^ zFcdqR0#8QV2fhj1zkT~QFe3x@x^@KxQs-*lL#s;xBM`M(NXep5J){E%JZlO>5pQ0; zTup&{_t=QzW2eAZZ+`9!Pyl9AfciG4z+r7pfipmXC{2-)AXS7o2+&$<_lyETY$_>` zMjkba3zF;u#b1m~GYae&i!V9T2BqbXKmh;;l^9gO2rRT6revU3*xIppg;X7)#hIP? z%bi%Q_+o`L`+$t4v=0fAMOaY@l>$WG^UCHX4Og5-V{2JkTBhBBn_qvxV#gI`;^x5e z_Mey7ei(A;UYl?%*?qkI;}nvH^2y6XlEpFl`hTF46e~3d;SZOHx4Qge_zEM}oN7_3 z(6l4I)Lo+?W6G=&g90?1qvgu$NXRl^vEx|oxYC|w@)8KgLO3>s0)Wqs0(26z8&fn; z@}nY*T$7P+O`T-jI|_n6$*co*tj`6CKye!IIE{ki^FcRTTyN#9z zAjV*gkPP9i-?b5n$0-J91Rpx?zPOS5o1U^5Llib)U`c+xj4$zLFoa?WWz6N^FL+bv z4r3t1!qW<1M2MQrN(!VRh>%}16l2S)Foz~2XFg#zt41?Ci(Q(#VkIR)kv zm{Xv}YiG&k6sS^XEW({ut9+y3J_`K!_pO`)(^F9lspJ&6w1IjpoUj;qLV@4Rpa8oR zxPvprP0kw0DNt_JcZ7_=T)0sA;6d^%80m$|?rtT!+Z*;8mE*EU*+R>AtH+tlU#}w- z!=&`tLX`j$C&!q2ZNk-YOVT3cKD)BY_pdw&H%K>kaSsMG1Zw6e1DqP6z9_|1@WE?Hk+|9CJM+y`Q7 zEiPZ&O=Li}IZ1b4ufB>nU#4V_ICNi(bDP`T=Ena4WdxRPx#C3HntbYx+4Wjbwd xWNBu305UK!I4v(e)op(>&S9PntTlJl) z?@rm^w|=Jb8316b<=5tR0KlLn3~bs6eU>7A7DAuRL0>zC03iSB<12M%pF9!(_>86b zu`?0&xj&XWj@XeN6Vr$gry@+0ec5#E#4dfj*RT6uVBd!oGkf zzL#3A?y#p+wDw-Nei%&eGHiS8^VE;fQNcZal3o66>nm%Qz1nt&pg}ieudkM4n2foW*1FWBNhmjX`&EY+jiFb0Y;!1h%x){ zzR&(hoJi1XRxodZ2zD`FQnm%gJ2&bYs3dFASP~(NL-)3|0-_Cr!40bQ@$vD|T?XQ} z-&@r&~(}B|Hn{F!Nn?OpyqemhHeT0zKja zdHfnO@BmbuG`afzoSpVg=B{qjJ~$YfHubOUr+p<}>ehgbY`;1?X!ORflBtS7Q;=Ys zG-r_AN{C%-(rTcG+TDL7V@dB5MWtzG)~ujal6?$RIxihA?7M_3H5Aun`chPkKtCrd zZEE7D1hX#^>pgcS)ob55aeA-Y2L+)O1n?ADz$Z7hjDKHZ0>X#F*lJ%|F21vhb*pht zaV}HfM-AcN*oMDCuH$WOZNG1R*K9P#EGQ_zZ>W`l9DDw-$4@npEJ<0Vl$4ZYQ7DOC zbl*kLc+>NV5^uaUDK9T?ZJ*NXE(15Tfk7y0%S6uIOyFGBn~4eF1sbU6>FJ$+E32MP zY4y69l7g>vPL@a{+6u&_T&^!gevjp1CSqY=7PP#n^k?^b(O1-hQ7G&UOd%JiKvK#kJrL_G`?qO6CF zMXf*(v9!G0&fGHJHz32rMLYl`h#58jND6=y0Gj~7LMw>ew*Bsw6u{;Ut_3k^#JSi% zr0}U@J|+9@qg}6e%7%{#h5PO#LY}Upe-Ss#k=0!{rl10Rd9*s4&05X9At&{Zpy$*1 z(L~A|cdehg8GtK3zijtcU)R9z;Dv=0{{SSU=aX%7F?wt^q2!F*G1+8HRhV)`Qzy=Bu?P>Fn%ug=6pAEx36|UR;$tqVl=pYmylbi&Yr?*aU#v$06W;9b~>$ zgxb;ZcA?Yy05snEzX!nO`i+oSv92y*xOXAtGSQUH4WF}b?l+F($0s~@XB8sbD#Bu7 z*{QBH!pef-W!1HxrVc>zRx85x-!1oF-^t%eLLEx=KSdl>sP?ULAM5w>Zq`hvB!))F z-V))#Z^~&WL~l95T=wI!uOqG6$6{n3j0vZQAlsibk99b^cbC0~7)l^|+`6=N8^Mr+ zPY8tscjn8ow`v2DxN`Q~aB)Uum}pvh3se$#bdE1qAS~B_U|4+X50%QDZkH=HaoL$n z%|9F2zw&mS`dyz~gDmopN{xKJWB<{(fkN?$JL|CGeC>3)5ta2pAt+|9XPmYbosrkH zB7drh-%|}y`6tPR^yuN4y+yT9jc~oi>yoItMu76}Ha?&AGA7cc!StC}>`LR**dFlb z3VLJ}#-qfy0*-X|F7Vy(5Df1c*cD--eZ`EiDDh$4SW5_!PaeH4Y5g;35~R3J0)2XG zB=IQv;B79KRuL(BhF|5?#9h`IiSN-0!4PK-j0lEHTM5X3{a>Z?RP)ogrQx#cnRhx{ za^_?_lp?+D!Y?2vKAB4ZsQ=~`7TSf4?C82lzDN8FT>0VdDu6m!lx5m6I>5UB;2UKF zewZRdR&1e0$!AtNztbmYa%#%y{O6!C30Jrf0Erw*Rk>4BeHTB9oXf86UzR#}=+KoQ z4-b#bV4$vx!{Ow|Fkm=_*Lu7;E3L(AqU!>g6Txkk=)8iz_^2dng>sZRWcT1?yU-&PT&!v}Yj6UQ| zgmB9k5{tmVIfX)4t?TJB2P$!)UQsQF-?5Aut&W6~ek2Q8;6%M6Zy3JaMYN0)_o?VZ zAhbHFCT_CFksuy~`nI1R-AKWifHLrfDK zHEOQVW@shVp&z0+a<6@<`Q4TCVj-WQ>-2PN)~w)hSC-W6yTzi`@rBgX)Piqh;mn{^ zZ?UQ9Df!6fh%l-7!nnOC2iNHwHjS{>oTRABtYI+13*qvx{2kDE1vT-)4~>nDX!!1< zg23P@wP_P&L^1t}#Bw8hsh@l<#)$BS&1Tc1_|uh13I~MsP>;`Tez#Z*4Mb<4yZ%-R zn3gAFth@b6dxTK#%CY?_*LEu_uPhhhQ2Mo!f&rsH+w2(%UZPCd|mwKM@+LYhE> z%>p^X1mz4H=}Oc`j5r+yLzg_gc!6FsuhQgnoxeaiSiCahNn;g_k%$I*)r$95ppD8o zi&)Ti3n~ZO^u%=}P7-+zf5UW!mMuN8lQegKC-Kuw(2cWzKV7_6oU51 zQzQGC7RBBOXcWz)udeF`FYPZ4J{ylB9NIY4mKeYMmK`v%de zh^RrF&in4#wZ07_4f%%jKDLJEUeq29&;3P#%Z&YSsHp#w5B1wMnQ8<^?v(I6#B=~y MTG*JEn|WOO8w + + + + tr("Return to Strawberry") + + + + + +

+

tr("Success!")

+ +

tr("Please close your browser and return to Strawberry.")

+
+ + diff --git a/data/pictures/deezer.png b/data/pictures/deezer.png new file mode 100644 index 0000000000000000000000000000000000000000..ebf4a057c138d5e861d67b380b46f6b42c6c76f9 GIT binary patch literal 6217 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%0A;v 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 J2=MOH{s;1Y3~c}a literal 0 HcmV?d00001 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3a8c0efc..70eb252d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,7 +1,5 @@ # Strawberry Music Player # Copyright 2013, Jonas Kvinge -# 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 @@ -59,6 +57,14 @@ if(HAVE_PHONON) include_directories(${PHONON_INCLUDE_DIRS}) endif() +if(HAVE_LIBDEEZER) + include_directories(${DEEZER_INCLUDE_DIRS}) +endif() + +if(HAVE_LIBDZMEDIA) + include_directories(${DZMEDIA_INCLUDE_DIRS}) +endif() + link_directories(${TAGLIB_LIBRARY_DIRS}) include_directories(${TAGLIB_INCLUDE_DIRS}) @@ -212,6 +218,7 @@ set(SOURCES settings/appearancesettingspage.cpp settings/notificationssettingspage.cpp settings/tidalsettingspage.cpp + settings/deezersettingspage.cpp dialogs/about.cpp dialogs/console.cpp @@ -257,6 +264,7 @@ set(SOURCES internet/internetmodel.cpp internet/internetservice.cpp internet/internetplaylistitem.cpp + internet/localredirectserver.cpp tidal/tidalservice.cpp tidal/tidalsearch.cpp @@ -266,6 +274,14 @@ set(SOURCES tidal/tidalsearchitemdelegate.cpp tidal/tidalurlhandler.cpp + deezer/deezerservice.cpp + deezer/deezersearch.cpp + deezer/deezersearchview.cpp + deezer/deezersearchmodel.cpp + deezer/deezersearchsortmodel.cpp + deezer/deezersearchitemdelegate.cpp + deezer/deezerurlhandler.cpp + ) set(HEADERS @@ -379,6 +395,7 @@ set(HEADERS settings/appearancesettingspage.h settings/notificationssettingspage.h settings/tidalsettingspage.h + settings/deezersettingspage.h dialogs/about.h dialogs/errordialog.h @@ -422,6 +439,7 @@ set(HEADERS internet/internetservice.h internet/internetmimedata.h internet/internetsongmimedata.h + internet/localredirectserver.h tidal/tidalservice.h tidal/tidalsearch.h @@ -429,6 +447,12 @@ set(HEADERS tidal/tidalsearchmodel.h tidal/tidalurlhandler.h + deezer/deezerservice.h + deezer/deezersearch.h + deezer/deezersearchview.h + deezer/deezersearchmodel.h + deezer/deezerurlhandler.h + ) set(UI @@ -465,6 +489,7 @@ set(UI settings/appearancesettingspage.ui settings/notificationssettingspage.ui settings/tidalsettingspage.ui + settings/deezersettingspage.ui equalizer/equalizer.ui equalizer/equalizerslider.ui @@ -483,10 +508,11 @@ set(UI globalshortcuts/globalshortcutgrabber.ui tidal/tidalsearchview.ui + deezer/deezersearchview.ui ) -set(RESOURCES ../data/data.qrc) +set(RESOURCES ../data/data.qrc ../data/icons.qrc) set(OTHER_SOURCES) option(USE_INSTALL_PREFIX "Look for data in CMAKE_INSTALL_PREFIX" ON) @@ -524,6 +550,12 @@ optional_source(HAVE_PHONON HEADERS engine/phononengine.h ) +# Deezer +optional_source(HAVE_DEEZER + SOURCES engine/deezerengine.cpp + HEADERS engine/deezerengine.h +) + # Lastfm optional_source(HAVE_LIBLASTFM SOURCES @@ -906,6 +938,14 @@ if(HAVE_PHONON) target_link_libraries(strawberry_lib ${PHONON_LIBRARIES}) endif() +if(HAVE_DEEZER) + target_link_libraries(strawberry_lib ${LIBDEEZER_LIBRARIES}) +endif() + +if(HAVE_DZMEDIA) + target_link_libraries(strawberry_lib ${LIBDZMEDIA_LIBRARIES}) +endif() + if(HAVE_LIBLASTFM) target_link_libraries(strawberry_lib ${LASTFM5_LIBRARIES}) endif(HAVE_LIBLASTFM) diff --git a/src/collection/groupbydialog.ui b/src/collection/groupbydialog.ui index b81a999d..6614c7e6 100644 --- a/src/collection/groupbydialog.ui +++ b/src/collection/groupbydialog.ui @@ -14,7 +14,7 @@ Collection advanced grouping - + :/icons/64x64/strawberry.png:/icons/64x64/strawberry.png @@ -358,6 +358,7 @@ + diff --git a/src/collection/savedgroupingmanager.ui b/src/collection/savedgroupingmanager.ui index 6f8e5105..1ecd124b 100644 --- a/src/collection/savedgroupingmanager.ui +++ b/src/collection/savedgroupingmanager.ui @@ -106,6 +106,7 @@ + diff --git a/src/config.h.in b/src/config.h.in index 864304ce..aa93c8a1 100644 --- a/src/config.h.in +++ b/src/config.h.in @@ -1,18 +1,21 @@ -/* This file is part of Strawberry. - - 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 . -*/ +/* + * Strawberry Music Player + * Copyright 2013, 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 CONFIG_H_IN #define CONFIG_H_IN @@ -38,6 +41,7 @@ #cmakedefine HAVE_SPARKLE #cmakedefine HAVE_CHROMAPRINT #cmakedefine HAVE_TAGLIB_DSFFILE +#cmakedefine HAVE_DZMEDIA #cmakedefine IMOBILEDEVICE_USES_UDIDS #cmakedefine USE_INSTALL_PREFIX #cmakedefine USE_SYSTEM_SHA2 @@ -46,6 +50,7 @@ #cmakedefine HAVE_VLC #cmakedefine HAVE_XINE #cmakedefine HAVE_PHONON +#cmakedefine HAVE_DEEZER #endif // CONFIG_H_IN diff --git a/src/context/contextviewcontainer.ui b/src/context/contextviewcontainer.ui index e5639888..2a28a61b 100644 --- a/src/context/contextviewcontainer.ui +++ b/src/context/contextviewcontainer.ui @@ -562,6 +562,7 @@ + diff --git a/src/core/application.cpp b/src/core/application.cpp index 6cd954fa..42bc8cd4 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -60,6 +60,7 @@ #include "internet/internetmodel.h" #include "tidal/tidalsearch.h" +#include "deezer/deezersearch.h" bool Application::kIsPortable = false; @@ -108,15 +109,16 @@ class ApplicationImpl { return loader; }), current_art_loader_([=]() { return new CurrentArtLoader(app, app); }), - internet_model_([=]() { return new InternetModel(app, app); }), - tidal_search_([=]() { return new TidalSearch(app, app); }), lyrics_providers_([=]() { LyricsProviders *lyrics_providers = new LyricsProviders(app); lyrics_providers->AddProvider(new AuddLyricsProvider(app)); - lyrics_providers->AddProvider(new APISeedsLyricsProvider(app)); - return lyrics_providers; - }) - { } + lyrics_providers->AddProvider(new APISeedsLyricsProvider(app)); + return lyrics_providers; + }), + internet_model_([=]() { return new InternetModel(app, app); }), + tidal_search_([=]() { return new TidalSearch(app, app); }), + deezer_search_([=]() { return new DeezerSearch(app, app); }) + {} Lazy tag_reader_client_; Lazy database_; @@ -133,9 +135,10 @@ class ApplicationImpl { Lazy cover_providers_; Lazy album_cover_loader_; Lazy current_art_loader_; + Lazy lyrics_providers_; Lazy internet_model_; Lazy tidal_search_; - Lazy lyrics_providers_; + Lazy deezer_search_; }; @@ -181,73 +184,27 @@ void Application::MoveToThread(QObject *object, QThread *thread) { } void Application::AddError(const QString& message) { emit ErrorAdded(message); } - void Application::ReloadSettings() { emit SettingsChanged(); } +void Application::OpenSettingsDialogAtPage(SettingsDialog::Page page) { emit SettingsDialogRequested(page); } -void Application::OpenSettingsDialogAtPage(SettingsDialog::Page page) { - emit SettingsDialogRequested(page); -} - -AlbumCoverLoader *Application::album_cover_loader() const { - return p_->album_cover_loader_.get(); -} - +TagReaderClient *Application::tag_reader_client() const { return p_->tag_reader_client_.get(); } Appearance *Application::appearance() const { return p_->appearance_.get(); } - -CoverProviders *Application::cover_providers() const { - return p_->cover_providers_.get(); -} - -CurrentArtLoader *Application::current_art_loader() const { - return p_->current_art_loader_.get(); -} - Database *Application::database() const { return p_->database_.get(); } - -#ifndef Q_OS_WIN -DeviceManager *Application::device_manager() const { - return p_->device_manager_.get(); -} -#endif - -SCollection *Application::collection() const { return p_->collection_.get(); } - -CollectionBackend *Application::collection_backend() const { - return collection()->backend(); -} - -CollectionModel *Application::collection_model() const { return collection()->model(); } - +TaskManager *Application::task_manager() const { return p_->task_manager_.get(); } Player *Application::player() const { return p_->player_.get(); } - -PlaylistBackend *Application::playlist_backend() const { - return p_->playlist_backend_.get(); -} - -PlaylistManager *Application::playlist_manager() const { - return p_->playlist_manager_.get(); -} - -TagReaderClient *Application::tag_reader_client() const { - return p_->tag_reader_client_.get(); -} - -TaskManager *Application::task_manager() const { - return p_->task_manager_.get(); -} - -EngineDevice *Application::enginedevice() const { - return p_->enginedevice_.get(); -} - -InternetModel* Application::internet_model() const { - return p_->internet_model_.get(); -} - -TidalSearch* Application::tidal_search() const { - return p_->tidal_search_.get(); -} - -LyricsProviders *Application::lyrics_providers() const { - return p_->lyrics_providers_.get(); -} +EngineDevice *Application::enginedevice() const { return p_->enginedevice_.get(); } +#ifndef Q_OS_WIN +DeviceManager *Application::device_manager() const { return p_->device_manager_.get(); } +#endif +SCollection *Application::collection() const { return p_->collection_.get(); } +CollectionBackend *Application::collection_backend() const { return collection()->backend(); } +CollectionModel *Application::collection_model() const { return collection()->model(); } +AlbumCoverLoader *Application::album_cover_loader() const { return p_->album_cover_loader_.get(); } +CoverProviders *Application::cover_providers() const { return p_->cover_providers_.get(); } +CurrentArtLoader *Application::current_art_loader() const { return p_->current_art_loader_.get(); } +LyricsProviders *Application::lyrics_providers() const { return p_->lyrics_providers_.get(); } +PlaylistBackend *Application::playlist_backend() const { return p_->playlist_backend_.get(); } +PlaylistManager *Application::playlist_manager() const { return p_->playlist_manager_.get(); } +InternetModel *Application::internet_model() const { return p_->internet_model_.get(); } +TidalSearch *Application::tidal_search() const { return p_->tidal_search_.get(); } +DeezerSearch *Application::deezer_search() const { return p_->deezer_search_.get(); } diff --git a/src/core/application.h b/src/core/application.h index 3c20903d..11ad8acb 100644 --- a/src/core/application.h +++ b/src/core/application.h @@ -51,9 +51,10 @@ class DeviceManager; class CoverProviders; class AlbumCoverLoader; class CurrentArtLoader; +class LyricsProviders; class InternetModel; class TidalSearch; -class LyricsProviders; +class DeezerSearch; class Application : public QObject { Q_OBJECT @@ -75,6 +76,8 @@ class Application : public QObject { #endif SCollection *collection() const; + CollectionBackend *collection_backend() const; + CollectionModel *collection_model() const; PlaylistBackend *playlist_backend() const; PlaylistManager *playlist_manager() const; @@ -83,13 +86,11 @@ class Application : public QObject { AlbumCoverLoader *album_cover_loader() const; CurrentArtLoader *current_art_loader() const; - CollectionBackend *collection_backend() const; - CollectionModel *collection_model() const; + LyricsProviders *lyrics_providers() const; InternetModel *internet_model() const; TidalSearch *tidal_search() const; - - LyricsProviders *lyrics_providers() const; + DeezerSearch *deezer_search() const; void MoveToNewThread(QObject *object); void MoveToThread(QObject *object, QThread *thread); diff --git a/src/core/main.cpp b/src/core/main.cpp index 17ccb6c6..931dc5be 100644 --- a/src/core/main.cpp +++ b/src/core/main.cpp @@ -200,6 +200,7 @@ int main(int argc, char* argv[]) { // Resources Q_INIT_RESOURCE(data); + Q_INIT_RESOURCE(icons); Application app; diff --git a/src/core/mainwindow.cpp b/src/core/mainwindow.cpp index b06ca6aa..844bbc3c 100644 --- a/src/core/mainwindow.cpp +++ b/src/core/mainwindow.cpp @@ -134,7 +134,11 @@ #include "settings/playlistsettingspage.h" #include "settings/settingsdialog.h" +#include "internet/internetmodel.h" +#include "internet/internetservice.h" + #include "tidal/tidalsearchview.h" +#include "deezer/deezersearchview.h" #if defined(HAVE_GSTREAMER) && defined(HAVE_CHROMAPRINT) # include "musicbrainz/tagfetcher.h" @@ -200,6 +204,7 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co return manager; }), tidal_search_view_(new TidalSearchView(app_, this)), + deezer_search_view_(new DeezerSearchView(app_, this)), playlist_menu_(new QMenu(this)), playlist_add_to_another_(nullptr), playlistitem_actions_separator_(nullptr), @@ -243,7 +248,7 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co ui_->volume->setValue(volume); VolumeChanged(volume); - // Initialise the tidal search widget + // Initialise the search widget StyleHelper::setBaseColor(palette().color(QPalette::Highlight).darker()); // Add tabs to the fancy tab widget @@ -255,6 +260,7 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co ui_->tabs->addTab(device_view_, IconLoader::Load("device"), tr("Devices")); #endif ui_->tabs->addTab(tidal_search_view_, IconLoader::Load("tidal"), tr("Tidal", "Tidal")); + ui_->tabs->addTab(deezer_search_view_, IconLoader::Load("deezer"), tr("Deezer", "Deezer")); //ui_->tabs->AddSpacer(); // Add the playing widget to the fancy tab widget @@ -515,6 +521,8 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co // Tidal connect(tidal_search_view_, SIGNAL(AddToPlaylist(QMimeData*)), SLOT(AddToPlaylist(QMimeData*))); + // Deezer + connect(deezer_search_view_, SIGNAL(AddToPlaylist(QMimeData*)), SLOT(AddToPlaylist(QMimeData*))); // Playlist menu playlist_play_pause_ = playlist_menu_->addAction(tr("Play"), this, SLOT(PlaylistPlay())); @@ -710,12 +718,6 @@ 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(); @@ -809,6 +811,7 @@ void MainWindow::ReloadAllSettings() { collection_view_->ReloadSettings(); ui_->playlist->view()->ReloadSettings(); tidal_search_view_->ReloadSettings(); + deezer_search_view_->ReloadSettings(); } @@ -2311,39 +2314,6 @@ 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()); - } - -} - void MainWindow::LoadCoverFromFile() { album_cover_choice_controller_->LoadCoverFromFile(&song_); } @@ -2402,4 +2372,3 @@ void MainWindow::GetCoverAutomatically() { if (search) album_cover_choice_controller_->SearchCoverAutomatically(song_); } - diff --git a/src/core/mainwindow.h b/src/core/mainwindow.h index a7c29f41..52846708 100644 --- a/src/core/mainwindow.h +++ b/src/core/mainwindow.h @@ -89,6 +89,7 @@ class TranscodeDialog; class Ui_MainWindow; class Windows7ThumbBar; class TidalSearchView; +class DeezerSearchView; class MainWindow : public QMainWindow, public PlatformInterface { Q_OBJECT @@ -273,11 +274,6 @@ signals: void ShowConsole(); - void FocusTidalSearchField(); - void DoTidalSearch(const QString& query); - void SearchForArtist(); - void SearchForAlbum(); - void LoadCoverFromFile(); void SaveCoverToFile(); void LoadCoverFromURL(); @@ -342,6 +338,7 @@ signals: #endif TidalSearchView *tidal_search_view_; + DeezerSearchView *deezer_search_view_; QAction *collection_show_all_; QAction *collection_show_duplicates_; diff --git a/src/core/mainwindow.ui b/src/core/mainwindow.ui index 545f5d2b..9e332b48 100644 --- a/src/core/mainwindow.ui +++ b/src/core/mainwindow.ui @@ -14,7 +14,7 @@ Strawberry Music Player - + :/icons/64x64/strawberry.png:/icons/64x64/strawberry.png @@ -553,7 +553,7 @@ - + :/icons/64x64/strawberry.png:/icons/64x64/strawberry.png @@ -782,6 +782,7 @@ + diff --git a/src/core/metatypes.cpp b/src/core/metatypes.cpp index 455c8fde..592b704c 100644 --- a/src/core/metatypes.cpp +++ b/src/core/metatypes.cpp @@ -61,6 +61,7 @@ #endif #include "tidal/tidalsearch.h" +#include "deezer/deezersearch.h" void RegisterMetaTypes() { @@ -118,4 +119,7 @@ void RegisterMetaTypes() { qRegisterMetaType("TidalSearch::ResultList"); qRegisterMetaType("TidalSearch::Result"); + qRegisterMetaType("DeezerSearch::ResultList"); + qRegisterMetaType("DeezerSearch::Result"); + } diff --git a/src/core/player.cpp b/src/core/player.cpp index 6dca439e..df1547e3 100644 --- a/src/core/player.cpp +++ b/src/core/player.cpp @@ -59,6 +59,9 @@ #ifdef HAVE_VLC # include "engine/vlcengine.h" #endif +#ifdef HAVE_DEEZER +# include "engine/deezerengine.h" +#endif #include "collection/collectionbackend.h" #include "playlist/playlist.h" @@ -70,6 +73,8 @@ #include "settings/backendsettingspage.h" #include "settings/behavioursettingspage.h" #include "settings/playlistsettingspage.h" +#include "internet/internetmodel.h" +#include "internet/internetservice.h" using std::shared_ptr; @@ -103,7 +108,7 @@ Player::~Player() { void Player::CreateEngine(Engine::EngineType enginetype) { - Engine::EngineType use_enginetype = Engine::None; + Engine::EngineType use_enginetype(Engine::None); for (int i = 0 ; use_enginetype == Engine::None ; i++) { switch(enginetype) { @@ -131,6 +136,15 @@ void Player::CreateEngine(Engine::EngineType enginetype) { use_enginetype=Engine::Phonon; engine_.reset(new PhononEngine(app_->task_manager())); break; +#endif +#ifdef HAVE_DEEZER + case Engine::Deezer:{ + use_enginetype=Engine::Deezer; + DeezerEngine *deezerengine = new DeezerEngine(app_->task_manager()); + connect(this, SIGNAL(Authenticated()), deezerengine, SLOT(LoadAccessToken())); + engine_.reset(deezerengine); + break; + } #endif default: if (i > 0) { qFatal("No engine available!"); } @@ -144,7 +158,7 @@ void Player::CreateEngine(Engine::EngineType enginetype) { s.beginGroup(BackendSettingsPage::kSettingsGroup); s.setValue("engine", EngineName(use_enginetype)); s.setValue("output", engine_->DefaultOutput()); - s.setValue("device", QVariant("")); + s.setValue("device", QVariant()); s.endGroup(); } @@ -499,7 +513,7 @@ void Player::PlayAt(int index, Engine::TrackChangeFlags change, bool reshuffle) current_item_ = app_->playlist_manager()->active()->current_item(); const QUrl url = current_item_->Url(); - if (url_handlers_.contains(url.scheme())) { + if (url_handlers_.contains(url.scheme()) && !(engine_->type() == Engine::Deezer && url.scheme() == "dzmedia")) { // It's already loading if (url == loading_async_) return; @@ -761,3 +775,7 @@ void Player::UrlHandlerDestroyed(QObject *object) { } } + +void Player::HandleAuthentication() { + emit Authenticated(); +} diff --git a/src/core/player.h b/src/core/player.h index 4bce66a7..ceb72981 100644 --- a/src/core/player.h +++ b/src/core/player.h @@ -114,6 +114,9 @@ class PlayerInterface : public QObject { // The toggle parameter is true when user requests to toggle visibility for Pretty OSD void ForceShowOSD(Song, bool toogle); + + void Authenticated(); + }; class Player : public PlayerInterface { @@ -176,6 +179,8 @@ class Player : public PlayerInterface { void Play(); void ShowOSD(); void TogglePrettyOSD(); + + void HandleAuthentication(); private slots: void EngineStateChanged(Engine::State); diff --git a/src/core/song.cpp b/src/core/song.cpp index 36ae8e85..8958decf 100644 --- a/src/core/song.cpp +++ b/src/core/song.cpp @@ -298,7 +298,7 @@ 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_stream() const { return d->source_ == Source_Stream || d->source_ == Source_Tidal; } +bool Song::is_stream() const { return d->source_ == Source_Stream || d->source_ == Source_Tidal || d->source_ == Source_Deezer; } bool Song::is_cdda() const { return d->source_ == Source_CDDA; } bool Song::is_collection_song() const { return !is_cdda() && !is_stream() && id() != -1; @@ -384,6 +384,7 @@ QString Song::TextForSource(Source source) { case Song::Source_Device: return QObject::tr("Device"); case Song::Source_Stream: return QObject::tr("Stream"); case Song::Source_Tidal: return QObject::tr("Tidal"); + case Song::Source_Deezer: return QObject::tr("Deezer"); default: return QObject::tr("Unknown"); } @@ -398,6 +399,7 @@ QIcon Song::IconForSource(Source source) { case Song::Source_Device: return IconLoader::Load("device"); case Song::Source_Stream: return IconLoader::Load("applications-internet"); case Song::Source_Tidal: return IconLoader::Load("tidal"); + case Song::Source_Deezer: return IconLoader::Load("deezer"); default: return IconLoader::Load("edit-delete"); } diff --git a/src/core/song.h b/src/core/song.h index 8c91f169..8f6708c3 100644 --- a/src/core/song.h +++ b/src/core/song.h @@ -98,6 +98,7 @@ class Song { Source_Device = 4, Source_Stream = 5, Source_Tidal = 6, + Source_Deezer = 7, }; enum FileType { diff --git a/src/covermanager/albumcoverexport.ui b/src/covermanager/albumcoverexport.ui index e8ed915f..14cd2ccf 100644 --- a/src/covermanager/albumcoverexport.ui +++ b/src/covermanager/albumcoverexport.ui @@ -20,7 +20,7 @@ Export covers - + :/icons/64x64/strawberry.png:/icons/64x64/strawberry.png @@ -202,6 +202,7 @@ + diff --git a/src/covermanager/albumcovermanager.ui b/src/covermanager/albumcovermanager.ui index 66958348..b5d25c03 100644 --- a/src/covermanager/albumcovermanager.ui +++ b/src/covermanager/albumcovermanager.ui @@ -14,7 +14,7 @@ Cover Manager - + :/icons/64x64/strawberry.png:/icons/64x64/strawberry.png @@ -297,6 +297,7 @@ + diff --git a/src/covermanager/coverfromurldialog.ui b/src/covermanager/coverfromurldialog.ui index f9a90832..971d499d 100644 --- a/src/covermanager/coverfromurldialog.ui +++ b/src/covermanager/coverfromurldialog.ui @@ -14,7 +14,7 @@ Load cover from URL - + :/icons/64x64/strawberry.png:/icons/64x64/strawberry.png @@ -79,6 +79,7 @@ + diff --git a/src/deezer/deezersearch.cpp b/src/deezer/deezersearch.cpp new file mode 100644 index 00000000..41f1fa37 --- /dev/null +++ b/src/deezer/deezersearch.cpp @@ -0,0 +1,329 @@ +/* + * 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 "deezersearch.h" +#include "deezerservice.h" +#include "settings/deezersettingspage.h" + +const int DeezerSearch::kDelayedSearchTimeoutMs = 200; +const int DeezerSearch::kMaxResultsPerEmission = 2000; +const int DeezerSearch::kArtHeight = 32; + +DeezerSearch::DeezerSearch(Application *app, QObject *parent) + : QObject(parent), + app_(app), + service_(app->internet_model()->Service()), + name_("Deezer"), + id_("deezer"), + icon_(IconLoader::Load("deezer")), + 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, DeezerSettingsPage::SearchBy)), this, SLOT(DoSearchAsync(int, QString, DeezerSettingsPage::SearchBy))); + connect(this, SIGNAL(ResultsAvailable(int, DeezerSearch::ResultList)), SLOT(ResultsAvailableSlot(int, DeezerSearch::ResultList))); + connect(this, SIGNAL(ArtLoaded(int, QImage)), SLOT(ArtLoadedSlot(int, QImage))); + connect(service_, SIGNAL(UpdateStatus(QString)), SLOT(UpdateStatusSlot(QString))); + connect(service_, SIGNAL(ProgressSetMaximum(int)), SLOT(ProgressSetMaximumSlot(int))); + connect(service_, SIGNAL(UpdateProgress(int)), SLOT(UpdateProgressSlot(int))); + 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()); + +} + +DeezerSearch::~DeezerSearch() {} + +QStringList DeezerSearch::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 DeezerSearch::Matches(const QStringList &tokens, const QString &string) { + + for (const QString &token : tokens) { + if (!string.contains(token, Qt::CaseInsensitive)) { + return false; + } + } + + return true; + +} + +int DeezerSearch::SearchAsync(const QString &query, DeezerSettingsPage::SearchBy searchby) { + + const int id = searches_next_id_++; + + emit SearchAsyncSig(id, query, searchby); + + return id; + +} + +void DeezerSearch::SearchAsync(int id, const QString &query, DeezerSettingsPage::SearchBy searchby) { + + const int service_id = service_->Search(query, searchby); + pending_searches_[service_id] = PendingState(id, TokenizeQuery(query)); + +} + +void DeezerSearch::DoSearchAsync(int id, const QString &query, DeezerSettingsPage::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 DeezerSearch::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 DeezerSearch::HandleError(const int id, const QString error) { + + emit SearchError(id, error); + +} + +void DeezerSearch::MaybeSearchFinished(int id) { + + if (pending_searches_.keys(PendingState(id, QStringList())).isEmpty()) { + emit SearchFinished(id); + } + +} + +void DeezerSearch::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; + } + } + service_->CancelSearch(); +} + +void DeezerSearch::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 DeezerSearch::ResultsAvailableSlot(int id, DeezerSearch::ResultList results) { + + if (results.isEmpty()) return; + + // Limit the number of results that are used from each emission. + if (results.count() > kMaxResultsPerEmission) { + DeezerSearch::ResultList::iterator begin = results.begin(); + std::advance(begin, kMaxResultsPerEmission); + results.erase(begin, results.end()); + } + + // Load cached pixmaps into the results + for (DeezerSearch::ResultList::iterator it = results.begin(); it != results.end(); ++it) { + it->pixmap_cache_key_ = PixmapCacheKey(*it); + } + + emit AddResults(id, results); + +} + +QString DeezerSearch::PixmapCacheKey(const DeezerSearch::Result &result) const { + return "deezer:" % result.metadata_.url().toString(); +} + +bool DeezerSearch::FindCachedPixmap(const DeezerSearch::Result &result, QPixmap *pixmap) const { + return pixmap_cache_.find(result.pixmap_cache_key_, pixmap); +} + +int DeezerSearch::LoadArtAsync(const DeezerSearch::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 DeezerSearch::ArtLoadedSlot(int id, const QImage &image) { + HandleLoadedArt(id, image); +} + +void DeezerSearch::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 DeezerSearch::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 DeezerSearch::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 *DeezerSearch::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; + +} + +void DeezerSearch::UpdateStatusSlot(QString text) { + emit UpdateStatus(text); +} + +void DeezerSearch::ProgressSetMaximumSlot(int max) { + emit ProgressSetMaximum(max); +} + +void DeezerSearch::UpdateProgressSlot(int progress) { + emit UpdateProgress(progress); +} diff --git a/src/deezer/deezersearch.h b/src/deezer/deezersearch.h new file mode 100644 index 00000000..b0f4b0f4 --- /dev/null +++ b/src/deezer/deezersearch.h @@ -0,0 +1,164 @@ +/* + * 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 DEEZERSEARCH_H +#define DEEZERSEARCH_H + +#include "config.h" + +#include +#include +#include +#include +#include + +#include "core/song.h" +#include "covermanager/albumcoverloaderoptions.h" +#include "settings/deezersettingspage.h" + +class Application; +class MimeData; +class AlbumCoverLoader; +class InternetService; +class DeezerService; + +class DeezerSearch : public QObject { + Q_OBJECT + + public: + DeezerSearch(Application *app, QObject *parent = nullptr); + ~DeezerSearch(); + + struct Result { + Song metadata_; + QString pixmap_cache_key_; + }; + typedef QList ResultList; + + static const int kDelayedSearchTimeoutMs; + static const int kMaxResultsPerEmission; + + Application *application() const { return app_; } + DeezerService *service() const { return service_; } + + int SearchAsync(const QString &query, DeezerSettingsPage::SearchBy searchby); + int LoadArtAsync(const DeezerSearch::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, DeezerSettingsPage::SearchBy searchby); + void ResultsAvailable(int id, const DeezerSearch::ResultList &results); + void AddResults(int id, const DeezerSearch::ResultList &results); + void SearchError(const int id, const QString error); + void SearchFinished(int id); + void UpdateStatus(QString text); + void ProgressSetMaximum(int progress); + void UpdateProgress(int max); + + 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, DeezerSettingsPage::SearchBy searchby); + void SearchDone(int id, const SongList &songs); + void HandleError(const int id, const QString error); + void ResultsAvailableSlot(int id, DeezerSearch::ResultList results); + + void ArtLoadedSlot(int id, const QImage &image); + void AlbumArtLoaded(quint64 id, const QImage &image); + + void UpdateStatusSlot(QString text); + void ProgressSetMaximumSlot(int progress); + void UpdateProgressSlot(int max); + + private: + void SearchAsync(int id, const QString &query, DeezerSettingsPage::SearchBy searchby); + void HandleLoadedArt(int id, const QImage &image); + bool FindCachedPixmap(const DeezerSearch::Result &result, QPixmap *pixmap) const; + QString PixmapCacheKey(const DeezerSearch::Result &result) const; + void MaybeSearchFinished(int id); + void ShowConfig() {} + static QImage ScaleAndPad(const QImage &image); + + private: + struct DelayedSearch { + int id_; + QString query_; + DeezerSettingsPage::SearchBy searchby_; + }; + + static const int kArtHeight; + + Application *app_; + DeezerService *service_; + Song::Source source_; + 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(DeezerSearch::Result) +Q_DECLARE_METATYPE(DeezerSearch::ResultList) + +#endif // DEEZERSEARCH_H diff --git a/src/deezer/deezersearchitemdelegate.cpp b/src/deezer/deezersearchitemdelegate.cpp new file mode 100644 index 00000000..e1639d7f --- /dev/null +++ b/src/deezer/deezersearchitemdelegate.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 "deezersearchitemdelegate.h" +#include "deezersearchview.h" + +DeezerSearchItemDelegate::DeezerSearchItemDelegate(DeezerSearchView* view) + : CollectionItemDelegate(view), view_(view) {} + +void DeezerSearchItemDelegate::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/deezer/deezersearchitemdelegate.h b/src/deezer/deezersearchitemdelegate.h new file mode 100644 index 00000000..d00a3906 --- /dev/null +++ b/src/deezer/deezersearchitemdelegate.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 DEEZERSEARCHITEMDELEGATE_H +#define DEEZERSEARCHITEMDELEGATE_H + +#include +#include + +#include "collection/collectionview.h" + +class DeezerSearchView; + +class DeezerSearchItemDelegate : public CollectionItemDelegate { + public: + DeezerSearchItemDelegate(DeezerSearchView *view); + + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const; + + private: + DeezerSearchView* view_; +}; + +#endif // DEEZERSEARCHITEMDELEGATE_H diff --git a/src/deezer/deezersearchmodel.cpp b/src/deezer/deezersearchmodel.cpp new file mode 100644 index 00000000..21f5c437 --- /dev/null +++ b/src/deezer/deezersearchmodel.cpp @@ -0,0 +1,319 @@ +/* + * 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 "core/logging.h" +#include "deezersearch.h" +#include "deezersearchmodel.h" + +DeezerSearchModel::DeezerSearchModel(DeezerSearch *engine, QObject *parent) + : QStandardItemModel(parent), + engine_(engine), + proxy_(nullptr), + use_pretty_covers_(true), + artist_icon_(IconLoader::Load("folder-sound")) { + + group_by_[0] = CollectionModel::GroupBy_Artist; + group_by_[1] = CollectionModel::GroupBy_Album; + group_by_[2] = CollectionModel::GroupBy_None; + + QIcon nocover = IconLoader::Load("cdcase"); + no_cover_icon_ = nocover.pixmap(nocover.availableSizes().last()).scaled(CollectionModel::kPrettyCoverSize, CollectionModel::kPrettyCoverSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + + //no_cover_icon_ = QPixmap(":/pictures/noalbumart.png").scaled(CollectionModel::kPrettyCoverSize, CollectionModel::kPrettyCoverSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + album_icon_ = no_cover_icon_; + +} + +void DeezerSearchModel::AddResults(const DeezerSearch::ResultList &results) { + + int sort_index = 0; + + for (const DeezerSearch::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 *DeezerSearchModel::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 DeezerSearchModel::Clear() { + containers_.clear(); + clear(); +} + +DeezerSearch::ResultList DeezerSearchModel::GetChildResults(const QModelIndexList &indexes) const { + + QList items; + for (const QModelIndex &index : indexes) { + items << itemFromIndex(index); + } + return GetChildResults(items); + +} + +DeezerSearch::ResultList DeezerSearchModel::GetChildResults(const QList &items) const { + + DeezerSearch::ResultList results; + QSet visited; + + for (QStandardItem *item : items) { + GetChildResults(item, &results, &visited); + } + + return results; + +} + +void DeezerSearchModel::GetChildResults(const QStandardItem *item, DeezerSearch::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 *DeezerSearchModel::mimeData(const QModelIndexList &indexes) const { + return engine_->LoadTracks(GetChildResults(indexes)); +} + +namespace { +void GatherResults(const QStandardItem *parent, DeezerSearch::ResultList *results) { + + QVariant result_variant = parent->data(DeezerSearchModel::Role_Result); + if (result_variant.isValid()) { + DeezerSearch::Result result = result_variant.value(); + (*results).append(result); + } + + for (int i = 0; i < parent->rowCount(); ++i) { + GatherResults(parent->child(i), results); + } +} +} + +void DeezerSearchModel::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 + DeezerSearch::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/deezer/deezersearchmodel.h b/src/deezer/deezersearchmodel.h new file mode 100644 index 00000000..6b5ec192 --- /dev/null +++ b/src/deezer/deezersearchmodel.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 DEEZERSEARCHMODEL_H +#define DEEZERSEARCHMODEL_H + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "collection/collectionmodel.h" +#include "deezersearch.h" + +class DeezerSearchModel : public QStandardItemModel { + Q_OBJECT + + public: + DeezerSearchModel(DeezerSearch *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(); + + DeezerSearch::ResultList GetChildResults(const QModelIndexList &indexes) const; + DeezerSearch::ResultList GetChildResults(const QList &items) const; + + QMimeData *mimeData(const QModelIndexList &indexes) const; + + public slots: + void AddResults(const DeezerSearch::ResultList &results); + + private: + QStandardItem *BuildContainers(const Song &metadata, QStandardItem *parent, ContainerKey *key, int level = 0); + void GetChildResults(const QStandardItem *item, DeezerSearch::ResultList *results, QSet *visited) const; + + private: + DeezerSearch *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 DeezerSearchModel::ContainerKey &key) { + return qHash(key.provider_index_) ^ qHash(key.group_[0]) ^ qHash(key.group_[1]) ^ qHash(key.group_[2]); +} + +inline bool operator<(const DeezerSearchModel::ContainerKey &left, const DeezerSearchModel::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 // DEEZERSEARCHMODEL_H diff --git a/src/deezer/deezersearchsortmodel.cpp b/src/deezer/deezersearchsortmodel.cpp new file mode 100644 index 00000000..8a400db0 --- /dev/null +++ b/src/deezer/deezersearchsortmodel.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 "deezersearchmodel.h" +#include "deezersearchsortmodel.h" + +DeezerSearchSortModel::DeezerSearchSortModel(QObject *parent) + : QSortFilterProxyModel(parent) {} + +bool DeezerSearchSortModel::lessThan(const QModelIndex &left, const QModelIndex &right) const { + // Compare the provider sort index first. + const int index_left = left.data(DeezerSearchModel::Role_ProviderIndex).toInt(); + const int index_right = right.data(DeezerSearchModel::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 DeezerSearch::Result r1 = left.data(DeezerSearchModel::Role_Result).value(); + const DeezerSearch::Result r2 = right.data(DeezerSearchModel::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/deezer/deezersearchsortmodel.h b/src/deezer/deezersearchsortmodel.h new file mode 100644 index 00000000..437b7b9c --- /dev/null +++ b/src/deezer/deezersearchsortmodel.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 DEEZERSEARCHSORTMODEL_H +#define DEEZERSEARCHSORTMODEL_H + +#include +#include + +class DeezerSearchSortModel : public QSortFilterProxyModel { + public: + DeezerSearchSortModel(QObject *parent = nullptr); + + protected: + bool lessThan(const QModelIndex &left, const QModelIndex &right) const; +}; + +#endif // DEEZERSEARCHSORTMODEL_H diff --git a/src/deezer/deezersearchview.cpp b/src/deezer/deezersearchview.cpp new file mode 100644 index 00000000..e3027603 --- /dev/null +++ b/src/deezer/deezersearchview.cpp @@ -0,0 +1,574 @@ +/* + * 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 "deezersearch.h" +#include "deezersearchitemdelegate.h" +#include "deezersearchmodel.h" +#include "deezersearchsortmodel.h" +#include "deezersearchview.h" +#include "ui_deezersearchview.h" +#include "settings/deezersettingspage.h" + +using std::placeholders::_1; +using std::placeholders::_2; +using std::swap; + +const int DeezerSearchView::kSwapModelsTimeoutMsec = 250; + +DeezerSearchView::DeezerSearchView(Application *app, QWidget *parent) + : QWidget(parent), + app_(app), + engine_(app_->deezer_search()), + ui_(new Ui_DeezerSearchView), + context_menu_(nullptr), + last_search_id_(0), + front_model_(new DeezerSearchModel(engine_, this)), + back_model_(new DeezerSearchModel(engine_, this)), + current_model_(front_model_), + front_proxy_(new DeezerSearchSortModel(this)), + back_proxy_(new DeezerSearchSortModel(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); + ui_->progressbar->hide(); + ui_->progressbar->reset(); + + 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 DeezerSearch 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 DeezerSearchItemDelegate(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 Deezer..."), 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(UpdateStatus(QString)), SLOT(UpdateStatus(QString))); + connect(engine_, SIGNAL(ProgressSetMaximum(int)), SLOT(ProgressSetMaximum(int)), Qt::QueuedConnection); + connect(engine_, SIGNAL(UpdateProgress(int)), SLOT(UpdateProgress(int)), Qt::QueuedConnection); + + connect(engine_, SIGNAL(AddResults(int, DeezerSearch::ResultList)), SLOT(AddResults(int, DeezerSearch::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(); + +} + +DeezerSearchView::~DeezerSearchView() { delete ui_; } + +void DeezerSearchView::ReloadSettings() { + + QSettings s; + + // Collection settings + + s.beginGroup(DeezerSettingsPage::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(); + + // Deezer search settings + + s.beginGroup(DeezerSettingsPage::kSettingsGroup); + searchby_ = DeezerSettingsPage::SearchBy(s.value("searchby", int(DeezerSettingsPage::SearchBy_Songs)).toInt()); + switch (searchby_) { + case DeezerSettingsPage::SearchBy_Songs: + ui_->radiobutton_searchbysongs->setChecked(true); + break; + case DeezerSettingsPage::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 DeezerSearchView::StartSearch(const QString &query) { + + ui_->search->setText(query); + TextEdited(query); + + // Swap models immediately + swap_models_timer_->stop(); + SwapModels(); + +} + +void DeezerSearchView::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"); + ui_->label_status->clear(); + ui_->progressbar->hide(); + ui_->progressbar->reset(); + } + else { + ui_->progressbar->reset(); + last_search_id_ = engine_->SearchAsync(trimmed, searchby_); + } + +} + +void DeezerSearchView::AddResults(int id, const DeezerSearch::ResultList &results) { + if (id != last_search_id_) return; + if (results.isEmpty()) return; + ui_->label_status->clear(); + ui_->progressbar->reset(); + ui_->progressbar->hide(); + current_model_->AddResults(results); +} + +void DeezerSearchView::SearchError(const int id, const QString error) { + error_ = true; + ui_->label_helptext->setText(error); + ui_->label_status->clear(); + ui_->progressbar->reset(); + ui_->progressbar->hide(); + ui_->results_stack->setCurrentWidget(ui_->help_page); +} + +void DeezerSearchView::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 DeezerSearchView::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(DeezerSearchModel::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, DeezerSearchModel::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 DeezerSearch::Result result = item->data(DeezerSearchModel::Role_Result).value(); + + // Load the art. + int id = engine_->LoadArtAsync(result); + art_requests_[id] = source_index; + +} + +void DeezerSearchView::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 *DeezerSearchView::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 DeezerSearchView::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 DeezerSearchView::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: + TextEdited(ui_->search->text()); + break; + + default: + return false; + } + + event->accept(); + return true; + +} + +bool DeezerSearchView::ResultsContextMenuEvent(QContextMenuEvent *event) { + + context_menu_ = new QMenu(this); + context_actions_ << context_menu_->addAction( IconLoader::Load("media-play"), tr("Append to current playlist"), this, SLOT(AddSelectedToPlaylist())); + context_actions_ << context_menu_->addAction( IconLoader::Load("media-play"), 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("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 Deezer..."), 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 DeezerSearchView::AddSelectedToPlaylist() { + emit AddToPlaylist(SelectedMimeData()); +} + +void DeezerSearchView::LoadSelected() { + MimeData *data = SelectedMimeData(); + if (!data) return; + + data->clear_first_ = true; + emit AddToPlaylist(data); +} + +void DeezerSearchView::AddSelectedToPlaylistEnqueue() { + MimeData *data = SelectedMimeData(); + if (!data) return; + + data->enqueue_now_ = true; + emit AddToPlaylist(data); +} + +void DeezerSearchView::OpenSelectedInNewPlaylist() { + MimeData *data = SelectedMimeData(); + if (!data) return; + + data->open_in_new_playlist_ = true; + emit AddToPlaylist(data); +} + +void DeezerSearchView::SearchForThis() { + StartSearch(ui_->results->selectionModel()->selectedRows().first().data().toString()); +} + +void DeezerSearchView::showEvent(QShowEvent *e) { + QWidget::showEvent(e); + FocusSearchField(); +} + +void DeezerSearchView::FocusSearchField() { + ui_->search->setFocus(); + ui_->search->selectAll(); +} + +void DeezerSearchView::hideEvent(QHideEvent *e) { + QWidget::hideEvent(e); +} + +void DeezerSearchView::FocusOnFilter(QKeyEvent *event) { + ui_->search->setFocus(); + QApplication::sendEvent(ui_->search, event); +} + +void DeezerSearchView::OpenSettingsDialog() { + app_->OpenSettingsDialogAtPage(SettingsDialog::Page_Deezer); +} + +void DeezerSearchView::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 DeezerSearchView::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 DeezerSearchItemDelegate::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(DeezerSettingsPage::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 DeezerSearchView::SearchBySongsClicked(bool checked) { + SetSearchBy(DeezerSettingsPage::SearchBy_Songs); +} + +void DeezerSearchView::SearchByAlbumsClicked(bool checked) { + SetSearchBy(DeezerSettingsPage::SearchBy_Albums); +} + +void DeezerSearchView::SetSearchBy(DeezerSettingsPage::SearchBy searchby) { + searchby_ = searchby; + QSettings s; + s.beginGroup(DeezerSettingsPage::kSettingsGroup); + s.setValue("searchby", int(searchby)); + s.endGroup(); + TextEdited(ui_->search->text()); +} + +void DeezerSearchView::UpdateStatus(QString text) { + ui_->progressbar->show(); + ui_->label_status->setText(text); +} + +void DeezerSearchView::ProgressSetMaximum(int max) { + ui_->progressbar->setMaximum(max); +} + +void DeezerSearchView::UpdateProgress(int progress) { + ui_->progressbar->setValue(progress); +} diff --git a/src/deezer/deezersearchview.h b/src/deezer/deezersearchview.h new file mode 100644 index 00000000..24b3d35f --- /dev/null +++ b/src/deezer/deezersearchview.h @@ -0,0 +1,142 @@ +/* + * 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 DEEZERSEARCHVIEW_H +#define DEEZERSEARCHVIEW_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 "deezersearch.h" +#include "settings/deezersettingspage.h" + +class Application; +class GroupByDialog; +class DeezerSearchModel; +class Ui_DeezerSearchView; + +class DeezerSearchView : public QWidget { + Q_OBJECT + + public: + DeezerSearchView(Application *app, QWidget *parent = nullptr); + ~DeezerSearchView(); + + 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 UpdateStatus(QString text); + void ProgressSetMaximum(int progress); + void UpdateProgress(int max); + void AddResults(int id, const DeezerSearch::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(DeezerSettingsPage::SearchBy searchby); + void SetGroupBy(const CollectionModel::Grouping &g); + + private: + MimeData *SelectedMimeData(); + + bool SearchKeyEvent(QKeyEvent *event); + bool ResultsContextMenuEvent(QContextMenuEvent *event); + + Application *app_; + DeezerSearch *engine_; + Ui_DeezerSearchView *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. + DeezerSearchModel *front_model_; + DeezerSearchModel *back_model_; + DeezerSearchModel *current_model_; + + QSortFilterProxyModel *front_proxy_; + QSortFilterProxyModel *back_proxy_; + QSortFilterProxyModel *current_proxy_; + + QMap art_requests_; + + QTimer *swap_models_timer_; + + QIcon search_icon_; + QIcon warning_icon_; + + DeezerSettingsPage::SearchBy searchby_; + bool error_; + +}; + +#endif // DEEZERSEARCHVIEW_H diff --git a/src/deezer/deezersearchview.ui b/src/deezer/deezersearchview.ui new file mode 100644 index 00000000..35789144 --- /dev/null +++ b/src/deezer/deezersearchview.ui @@ -0,0 +1,283 @@ + + + DeezerSearchView + + + + 0 + 0 + 400 + 660 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + + + + + + + Search for anything + + + + + + + + 20 + 0 + + + + QToolButton::InstantPopup + + + true + + + + + + + + + QLayout::SetFixedSize + + + + + true + + + Search by + + + 10 + + + + + + + a&lbums + + + + + + + son&gs + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + true + + + + + + + 0 + + + + + + + + + + + + + + 1 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QAbstractItemView::NoEditTriggers + + + true + + + QAbstractItemView::DragOnly + + + QAbstractItemView::ExtendedSelection + + + true + + + false + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::ScrollBarAlwaysOff + + + true + + + + + 0 + 0 + 398 + 502 + + + + + + + + 32 + + + 16 + + + 32 + + + 64 + + + + + Enter search terms above to find music + + + Qt::AlignCenter + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + + + + + + + QSearchField + QWidget +
3rdparty/qocoa/qsearchfield.h
+
+ + AutoExpandingTreeView + QTreeView +
widgets/autoexpandingtreeview.h
+
+
+ + +
diff --git a/src/deezer/deezerservice.cpp b/src/deezer/deezerservice.cpp new file mode 100644 index 00000000..558592c3 --- /dev/null +++ b/src/deezer/deezerservice.cpp @@ -0,0 +1,823 @@ +/* + * 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" + +#ifdef HAVE_DZMEDIA +# include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/application.h" +#include "core/player.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 "internet/localredirectserver.h" +#include "deezerservice.h" +#include "deezersearch.h" +#include "deezerurlhandler.h" +#include "settings/deezersettingspage.h" + +const Song::Source DeezerService::kSource = Song::Source_Deezer; +const char *DeezerService::kApiUrl = "https://api.deezer.com"; +const char *DeezerService::kOAuthUrl = "https://connect.deezer.com/oauth/auth.php"; +const char *DeezerService::kOAuthAccessTokenUrl = "https://connect.deezer.com/oauth/access_token.php"; +const char *DeezerService::kOAuthRedirectUrl = "https://oauth.strawbs.net"; +const int DeezerService::kAppID = 303684; +const char *DeezerService::kSecretKey = "06911976010b9ddd7256769adf2b2e56"; + +typedef QPair Param; + +DeezerService::DeezerService(Application *app, InternetModel *parent) + : InternetService(Song::Source_Deezer, "Deezer", "dzmedia", app, parent, parent), + network_(new NetworkAccessManager(this)), + url_handler_(new DeezerUrlHandler(app, this)), +#ifdef HAVE_DZMEDIA + dzmedia_(new DZMedia(this)), +#endif + timer_searchdelay_(new QTimer(this)), + searchdelay_(1500), + albumssearchlimit_(1), + songssearchlimit_(1), + fetchalbums_(false), + preview_(false), + pending_search_id_(0), + next_pending_search_id_(1), + search_id_(0), + albums_requested_(0), + albums_received_(0) + { + + timer_searchdelay_->setSingleShot(true); + connect(timer_searchdelay_, SIGNAL(timeout()), SLOT(StartSearch())); + + connect(this, SIGNAL(Authenticated()), app->player(), SLOT(HandleAuthentication())); + + app->player()->RegisterUrlHandler(url_handler_); + + ReloadSettings(); + LoadAccessToken(); + +#ifdef HAVE_DZMEDIA + connect(dzmedia_, SIGNAL(StreamURLReceived(QUrl, QUrl, DZMedia::FileType)), this, SLOT(GetStreamURLFinished(QUrl, QUrl, DZMedia::FileType))); +#endif + +} + +DeezerService::~DeezerService() {} + +void DeezerService::ShowConfig() { + app_->OpenSettingsDialogAtPage(SettingsDialog::Page_Deezer); +} + +void DeezerService::ReloadSettings() { + + QSettings s; + s.beginGroup(DeezerSettingsPage::kSettingsGroup); + quality_ = s.value("quality", "FLAC").toString(); + searchdelay_ = s.value("searchdelay", 1500).toInt(); + albumssearchlimit_ = s.value("albumssearchlimit", 100).toInt(); + songssearchlimit_ = s.value("songssearchlimit", 100).toInt(); + fetchalbums_ = s.value("fetchalbums", false).toBool(); + coversize_ = s.value("coversize", "cover_big").toString(); + preview_ = s.value("preview", false).toBool(); + s.endGroup(); + +} + +void DeezerService::LoadAccessToken() { + + QSettings s; + s.beginGroup(DeezerSettingsPage::kSettingsGroup); + if (s.contains("access_token") && s.contains("expiry_time")) { + access_token_ = s.value("access_token").toString(); + expiry_time_ = s.value("expiry_time").toDateTime(); + } + s.endGroup(); + +} + +void DeezerService::Logout() { +} + +void DeezerService::StartAuthorisation() { + + LocalRedirectServer *server = new LocalRedirectServer(this); + server->Listen(); + + QUrl url = QUrl(kOAuthUrl); + QUrlQuery url_query; + //url_query.addQueryItem("response_type", "token"); + url_query.addQueryItem("response_type", "code"); + url_query.addQueryItem("app_id", QString::number(kAppID)); + QUrl redirect_url; + QUrlQuery redirect_url_query; + + const QString port = QString::number(server->url().port()); + + redirect_url = QUrl(kOAuthRedirectUrl); + redirect_url_query.addQueryItem("port", port); + redirect_url.setQuery(redirect_url_query); + url_query.addQueryItem("redirect_uri", redirect_url.toString()); + url.setQuery(url_query); + + NewClosure(server, SIGNAL(Finished()), this, &DeezerService::RedirectArrived, server, redirect_url); + QDesktopServices::openUrl(url); + +} + +void DeezerService::RedirectArrived(LocalRedirectServer *server, QUrl url) { + + server->deleteLater(); + QUrl request_url = server->request_url(); + RequestAccessToken(QUrlQuery(request_url).queryItemValue("code").toUtf8()); + +} + +void DeezerService::RequestAccessToken(const QByteArray &code) { + + typedef QPair Arg; + typedef QList ArgList; + + typedef QPair EncodedArg; + typedef QList EncodedArgList; + + ArgList args = ArgList() << Arg("app_id", QString::number(kAppID)) + << Arg("secret", kSecretKey) + << Arg("code", code); + + QUrlQuery url_query; + for (const Arg &arg : args) { + EncodedArg encoded_arg(QUrl::toPercentEncoding(arg.first), QUrl::toPercentEncoding(arg.second)); + url_query.addQueryItem(encoded_arg.first, encoded_arg.second); + } + + QUrl url(kOAuthAccessTokenUrl); + QNetworkRequest request = QNetworkRequest(url); + QNetworkReply *reply = network_->post(request, url_query.toString(QUrl::FullyEncoded).toUtf8()); + NewClosure(reply, SIGNAL(finished()), this, SLOT(FetchAccessTokenFinished(QNetworkReply*)), reply); + +} + +void DeezerService::FetchAccessTokenFinished(QNetworkReply *reply) { + + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + Error(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error())); + return; + } + + forever { + QByteArray line = reply->readLine(); + QString str(line); + QStringList args = str.split("&"); + for (QString arg : args) { + QStringList params = arg.split("="); + if (params.count() < 2) continue; + QString param1 = params.first(); + QString param2 = params[1]; + if (param1 == "access_token") access_token_ = param2; + else if (param1 == "expires") SetExpiryTime(param2.toInt()); + } + if (reply->atEnd()) break; + } + + QSettings s; + s.beginGroup(DeezerSettingsPage::kSettingsGroup); + s.setValue("access_token", access_token_); + s.setValue("expiry_time", expiry_time_); + s.endGroup(); + + emit Authenticated(); + emit LoginSuccess(); + +} + +void DeezerService::SetExpiryTime(int expires_in_seconds) { + + // Set the expiry time with two minutes' grace. + expiry_time_ = QDateTime::currentDateTime().addSecs(expires_in_seconds - 120); + qLog(Debug) << "Current oauth access token expires at:" << expiry_time_; + +} + +QNetworkReply *DeezerService::CreateRequest(const QString &ressource_name, const QList ¶ms) { + + typedef QPair Arg; + typedef QList ArgList; + + typedef QPair EncodedArg; + typedef QList EncodedArgList; + + ArgList args = ArgList() << Arg("access_token", access_token_) + << Arg("output", "json") + << params; + + QUrlQuery url_query; + for (const Arg& arg : args) { + EncodedArg encoded_arg(QUrl::toPercentEncoding(arg.first), QUrl::toPercentEncoding(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) << "Deezer: Sending request" << url; + + return reply; + +} + +QByteArray DeezerService::GetReplyData(QNetworkReply *reply) { + + QByteArray data; + + if (reply->error() == QNetworkReply::NoError && reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 200) { + 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(failure_reason); + } + else { + // See if there is Json data containing "error" - 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.contains("error")) { + QJsonValue json_value_error = json_obj["error"]; + if (json_value_error.isObject()) { + QJsonObject json_error = json_value_error.toObject(); + int code = json_error["code"].toInt(); + if (code == 300) access_token_.clear(); + QString message = json_error["message"].toString(); + QString type = json_error["type"].toString(); + failure_reason = QString("%1 (%2)").arg(message).arg(code); + } + else { failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); } + } + 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 + Logout(); + Error(failure_reason); + } + else if (reply->error() == QNetworkReply::ContentNotFoundError) { // Ignore this error + Error(failure_reason); + } + else { // Fail + Error(failure_reason); + } + } + return QByteArray(); + } + + return data; + +} + +QJsonObject DeezerService::ExtractJsonObj(QByteArray &data) { + + QJsonParseError error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &error); + + //qLog(Debug) << json_doc; + + if (error.error != QJsonParseError::NoError) { + Error("Reply from server missing Json data.", data); + return QJsonObject(); + } + + if (json_doc.isNull() || json_doc.isEmpty()) { + Error("Received empty Json document.", json_doc); + return QJsonObject(); + } + + if (!json_doc.isObject()) { + Error("Json document is not an object.", json_doc); + return QJsonObject(); + } + + QJsonObject json_obj = json_doc.object(); + if (json_obj.isEmpty()) { + Error("Received empty Json object.", json_doc); + return QJsonObject(); + } + + //qLog(Debug) << json_obj; + + return json_obj; + +} + +QJsonValue DeezerService::ExtractData(QByteArray &data) { + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) return QJsonObject(); + + if (json_obj.contains("error")) { + QJsonValue json_value_error = json_obj["error"]; + if (!json_value_error.isObject()) { + Error("Error missing object", json_obj); + return QJsonValue(); + } + QJsonObject json_error = json_value_error.toObject(); + int code = json_error["code"].toInt(); + if (code == 300) access_token_.clear(); + QString message = json_error["message"].toString(); + QString type = json_error["type"].toString(); + Error(QString("%1 (%2)").arg(message).arg(code)); + return QJsonValue(); + } + + if (!json_obj.contains("data") && !json_obj.contains("DATA")) { + Error("Json reply is missing data.", json_obj); + return QJsonValue(); + } + + QJsonValue json_data; + if (json_obj.contains("data")) json_data = json_obj["data"]; + else json_data = json_obj["DATA"]; + + return json_data; + +} + +int DeezerService::Search(const QString &text, DeezerSettingsPage::SearchBy searchby) { + + pending_search_id_ = next_pending_search_id_; + pending_search_text_ = text; + pending_searchby_ = searchby; + + next_pending_search_id_++; + + if (text.isEmpty()) { + timer_searchdelay_->stop(); + return pending_search_id_; + } + timer_searchdelay_->setInterval(searchdelay_); + timer_searchdelay_->start(); + + return pending_search_id_; + +} + +void DeezerService::StartSearch() { + + if (access_token_.isEmpty()) { + emit SearchError(pending_search_id_, "Not authenticated."); + next_pending_search_id_ = 1; + ShowConfig(); + return; + } + ClearSearch(); + search_id_ = pending_search_id_; + search_text_ = pending_search_text_; + + SendSearch(); + +} + +void DeezerService::CancelSearch() { + ClearSearch(); +} + +void DeezerService::ClearSearch() { + search_id_ = 0; + search_text_.clear(); + search_error_.clear(); + albums_requested_ = 0; + albums_received_ = 0; + requests_album_.clear(); + requests_song_.clear(); + songs_.clear(); +} + +void DeezerService::SendSearch() { + + emit UpdateStatus("Searching..."); + + QList parameters; + parameters << Param("q", search_text_); + QString searchparam; + switch (pending_searchby_) { + case DeezerSettingsPage::SearchBy_Songs: + searchparam = "search/track"; + parameters << Param("limit", QString::number(songssearchlimit_)); + break; + case DeezerSettingsPage::SearchBy_Albums: + default: + searchparam = "search/album"; + parameters << Param("limit", QString::number(albumssearchlimit_)); + break; + } + + QNetworkReply *reply = CreateRequest(searchparam, parameters); + NewClosure(reply, SIGNAL(finished()), this, SLOT(SearchFinished(QNetworkReply*, int)), reply, search_id_); + +} + +void DeezerService::SearchFinished(QNetworkReply *reply, int id) { + + reply->deleteLater(); + + if (id != search_id_) return; + + QByteArray data = GetReplyData(reply); + if (data.isEmpty()) { + CheckFinish(); + return; + } + + QJsonValue json_value = ExtractData(data); + if (!json_value.isArray()) { + CheckFinish(); + return; + } + + QJsonArray json_data = json_value.toArray(); + if (json_data.isEmpty()) { + Error("No match."); + CheckFinish(); + return; + } + + //qLog(Debug) << json_data; + + for (const QJsonValue &value : json_data) { + //qLog(Debug) << value; + if (!value.isObject()) { + Error("Invalid Json reply, data is not an object.", value); + continue; + } + QJsonObject json_obj = value.toObject(); + //qLog(Debug) << json_obj; + + if (!json_obj.contains("id") || !json_obj.contains("type")) { + Error("Invalid Json reply, item is missing ID or type.", json_obj); + continue; + } + + //int id = json_obj["id"].toInt(); + QString type = json_obj["type"].toString(); + + if (!json_obj.contains("artist")) { + Error("Invalid Json reply, item missing artist.", json_obj); + continue; + } + QJsonValue json_value_artist = json_obj["artist"]; + if (!json_value_artist.isObject()) { + Error("Invalid Json reply, item artist is not a object.", json_value_artist); + continue; + } + QJsonObject json_artist = json_value_artist.toObject(); + + if (!json_artist.contains("name")) { + Error("Invalid Json reply, artist data missing name.", json_artist); + continue; + } + QString artist = json_artist["name"].toString(); + int album_id(0); + QString album; + QString cover; + + if (type == "album") { + album_id = json_obj["id"].toInt(); + album = json_obj["title"].toString(); + cover = json_obj[coversize_].toString(); + } + else if (type == "track") { + + if (!json_obj.contains("album")) { + Error("Invalid Json reply, missing album data.", json_obj); + continue; + } + QJsonValue json_value_album = json_obj["album"]; + if (!json_value_album.isObject()) { + Error("Invalid Json reply, album data is not an object.", json_value_album); + continue; + } + QJsonObject json_album = json_value_album.toObject(); + if (!json_album.contains("id") || !json_album.contains("title")) { + Error("Invalid Json reply, album data is missing ID or title.", json_album); + continue; + } + album_id = json_album["id"].toInt(); + album = json_album["title"].toString(); + cover = json_album[coversize_].toString(); + if (!fetchalbums_) { + Song song = ParseSong(album_id, album, cover, value); + songs_ << song; + continue; + } + } + + DeezerAlbumContext *album_ctx; + if (requests_album_.contains(album_id)) { + album_ctx = requests_album_.value(album_id); + album_ctx->search_id = search_id_; + continue; + } + album_ctx = CreateAlbum(album_id, artist, album, cover); + GetAlbum(album_ctx); + albums_requested_++; + if (albums_requested_ >= albumssearchlimit_) break; + + } + + if (albums_requested_ > 0) { + emit UpdateStatus(QString("Retriving %1 album%2...").arg(albums_requested_).arg(albums_requested_ == 1 ? "" : "s")); + emit ProgressSetMaximum(albums_requested_); + emit UpdateProgress(0); + } + + CheckFinish(); + +} + +DeezerAlbumContext *DeezerService::CreateAlbum(const int album_id, const QString &artist, const QString &album, const QString &cover) { + + DeezerAlbumContext *album_ctx = new DeezerAlbumContext; + album_ctx->id = album_id; + album_ctx->artist = artist; + album_ctx->album = album; + album_ctx->cover = cover; + album_ctx->cover_url.setUrl(cover); + requests_album_.insert(album_id, album_ctx); + + return album_ctx; + + } + +void DeezerService::GetAlbum(const DeezerAlbumContext *album_ctx) { + + QList parameters; + QNetworkReply *reply = CreateRequest(QString("album/%1/tracks").arg(album_ctx->id), parameters); + NewClosure(reply, SIGNAL(finished()), this, SLOT(GetAlbumFinished(QNetworkReply*, int, int)), reply, search_id_, album_ctx->id); + +} + +void DeezerService::GetAlbumFinished(QNetworkReply *reply, int search_id, int album_id) { + + reply->deleteLater(); + + if (!requests_album_.contains(album_id)) { + qLog(Error) << "Deezer: Got reply for cancelled album request: " << album_id; + CheckFinish(); + return; + } + DeezerAlbumContext *album_ctx = requests_album_.value(album_id); + + if (search_id != search_id_) { + if (album_ctx->search_id == search_id) delete requests_album_.take(album_ctx->id); + return; + } + + albums_received_++; + emit UpdateProgress(albums_received_); + + QByteArray data = GetReplyData(reply); + if (data.isEmpty()) { + CheckFinish(); + return; + } + + QJsonValue json_value = ExtractData(data); + if (!json_value.isArray()) { + delete requests_album_.take(album_ctx->id); + CheckFinish(); + return; + } + + QJsonArray json_data = json_value.toArray(); + if (json_data.isEmpty()) { + delete requests_album_.take(album_ctx->id); + CheckFinish(); + return; + } + + bool compilation = false; + bool multidisc = false; + Song first_song; + SongList songs; + for (const QJsonValue &value : json_data) { + Song song = ParseSong(album_ctx->id, album_ctx->album, album_ctx->cover, value); + if (!song.is_valid()) continue; + if (song.disc() >= 2) multidisc = true; + if (song.is_compilation() || (first_song.is_valid() && song.artist() != first_song.artist())) compilation = true; + if (!first_song.is_valid()) first_song = song; + songs << song; + } + 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); + } + songs_ << song; + } + + delete requests_album_.take(album_ctx->id); + CheckFinish(); + +} + +Song DeezerService::ParseSong(const int album_id, const QString &album, const QString &album_cover, const QJsonValue &value) { + + if (!value.isObject()) { + Error("Invalid Json reply, track is not an object.", value); + return Song(); + } + QJsonObject json_obj = value.toObject(); + + //qLog(Debug) << json_obj; + + if ( + !json_obj.contains("id") || + !json_obj.contains("title") || + !json_obj.contains("artist") || + !json_obj.contains("duration") || + !json_obj.contains("preview") + ) { + Error("Invalid Json reply, track is missing one or more values.", json_obj); + return Song(); + } + + int song_id = json_obj["id"].toInt(); + QString title = json_obj["title"].toString(); + QJsonValue json_value_artist = json_obj["artist"]; + QVariant q_duration = json_obj["duration"].toVariant(); + int track(0); + if (json_obj.contains("track_position")) track = json_obj["track_position"].toInt(); + int disc(0); + if (json_obj.contains("disk_number")) disc = json_obj["disk_number"].toInt(); + QString preview = json_obj["preview"].toString(); + + if (!json_value_artist.isObject()) { + Error("Invalid Json reply, track artist is not an object.", json_value_artist); + return Song(); + } + QJsonObject json_artist = json_value_artist.toObject(); + if (!json_artist.contains("name")) { + Error("Invalid Json reply, track artist is missing name.", json_artist); + return Song(); + } + QString artist = json_artist["name"].toString(); + + Song song; + song.set_source(Song::Source_Deezer); + song.set_id(song_id); + song.set_album_id(album_id); + song.set_artist(artist); + song.set_album(album); + song.set_title(title); + song.set_disc(disc); + song.set_track(track); + song.set_art_automatic(album_cover); + + QUrl url; + if (preview_) { + url.setUrl(preview); + quint64 duration = (30 * kNsecPerSec); + song.set_length_nanosec(duration); + } + else { + url.setScheme(url_handler_->scheme()); + url.setPath(QString("track/%1").arg(QString::number(song_id))); + if (q_duration.isValid()) { + quint64 duration = q_duration.toULongLong() * kNsecPerSec; + song.set_length_nanosec(duration); + } + } + song.set_url(url); + + song.set_valid(true); + + return song; + +} + +void DeezerService::GetStreamURL(const QUrl &original_url) { + +#ifdef HAVE_DZMEDIA + stream_request_url_ = original_url; + dzmedia_->GetStreamURL(original_url); +#else + stream_request_url_ = QUrl(); + emit StreamURLReceived(original_url, original_url, Song::FileType_Stream); +#endif + +} + +#ifdef HAVE_DZMEDIA +void DeezerService::GetStreamURLFinished(const QUrl original_url, const QUrl media_url, const DZMedia::FileType dzmedia_filetype) { + + Song::FileType filetype(Song::FileType_Unknown); + + switch (dzmedia_filetype) { + case DZMedia::FileType_FLAC: + filetype = Song::FileType_FLAC; + break; + case DZMedia::FileType_MPEG: + filetype = Song::FileType_MPEG; + break; + case DZMedia::FileType_Stream: + filetype = Song::FileType_Stream; + break; + default: + filetype = Song::FileType_Unknown; + break; + } + stream_request_url_ = QUrl(); + emit StreamURLReceived(original_url, media_url, filetype); + +} +#endif + +void DeezerService::CheckFinish() { + + if (search_id_ == 0) return; + + if (albums_requested_ <= albums_received_) { + if (songs_.isEmpty()) { + if (search_error_.isEmpty()) emit SearchError(search_id_, "Unknown error"); + else emit SearchError(search_id_, search_error_); + } + else emit SearchResults(search_id_, songs_); + ClearSearch(); + } + +} + +void DeezerService::Error(QString error, QVariant debug) { + qLog(Error) << "Deezer:" << error; + if (!debug.isValid()) qLog(Debug) << debug; + if (search_id_ != 0) { + if (!error.isEmpty()) { + search_error_ += error; + search_error_ += "
"; + } + CheckFinish(); + } + if (!stream_request_url_.isEmpty()) { + emit StreamURLReceived(stream_request_url_, stream_request_url_, Song::FileType_Stream); + stream_request_url_ = QUrl(); + } +} diff --git a/src/deezer/deezerservice.h b/src/deezer/deezerservice.h new file mode 100644 index 00000000..10e047db --- /dev/null +++ b/src/deezer/deezerservice.h @@ -0,0 +1,163 @@ +/* + * 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 DEEZERSERVICE_H +#define DEEZERSERVICE_H + +#include "config.h" + +#ifdef HAVE_DZMEDIA +# include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/song.h" +#include "internet/internetmodel.h" +#include "internet/internetservice.h" +#include "settings/deezersettingspage.h" + +class NetworkAccessManager; +class LocalRedirectServer; +class DeezerUrlHandler; + +struct DeezerAlbumContext { + int id; + int search_id; + QString artist; + QString album; + QString cover; + QUrl cover_url; +}; +Q_DECLARE_METATYPE(DeezerAlbumContext); + +class DeezerService : public InternetService { + Q_OBJECT + + public: + DeezerService(Application *app, InternetModel *parent); + ~DeezerService(); + + static const Song::Source kSource; + static const int kAppID; + + void ReloadSettings(); + + void Logout(); + int Search(const QString &query, DeezerSettingsPage::SearchBy searchby); + void CancelSearch(); + + const bool app_id() { return kAppID; } + const bool authenticated() { return !access_token_.isEmpty(); } + + void GetStreamURL(const QUrl &url); + + signals: + void Login(); + void LoginSuccess(); + void LoginFailure(QString failure_reason); + void Authenticated(); + void SearchResults(int id, SongList songs); + void SearchError(int id, QString message); + void UpdateStatus(QString text); + void ProgressSetMaximum(int max); + void UpdateProgress(int max); + void StreamURLReceived(const QUrl original_url, const QUrl media_url, const Song::FileType filetype); + + public slots: + void ShowConfig(); + + private slots: + void StartAuthorisation(); + void FetchAccessTokenFinished(QNetworkReply *reply); + void StartSearch(); + void SearchFinished(QNetworkReply *reply, int search_id); + void GetAlbumFinished(QNetworkReply *reply, int search_id, int album_id); +#ifdef HAVE_DZMEDIA + void GetStreamURLFinished(const QUrl original_url, const QUrl media_url, const DZMedia::FileType dzmedia_filetype); +#endif + + private: + void LoadAccessToken(); + void RedirectArrived(LocalRedirectServer *server, QUrl url); + void RequestAccessToken(const QByteArray &code); + void SetExpiryTime(int expires_in_seconds); + void ClearSearch(); + QNetworkReply *CreateRequest(const QString &ressource_name, const QList> ¶ms); + QByteArray GetReplyData(QNetworkReply *reply); + QJsonObject ExtractJsonObj(QByteArray &data); + QJsonValue ExtractData(QByteArray &data); + void SendSearch(); + DeezerAlbumContext *CreateAlbum(const int album_id, const QString &artist, const QString &album, const QString &cover); + void GetAlbum(const DeezerAlbumContext *album_ctx); + Song ParseSong(const int album_id, const QString &album, const QString &album_cover, const QJsonValue &value); + void CheckFinish(); + void Error(QString error, QVariant debug = QString()); + + static const char *kApiUrl; + static const char *kOAuthUrl; + static const char *kOAuthAccessTokenUrl; + static const char *kOAuthRedirectUrl; + static const char *kSecretKey; + + NetworkAccessManager *network_; + DeezerUrlHandler *url_handler_; +#ifdef HAVE_DZMEDIA + DZMedia *dzmedia_; +#endif + QTimer *timer_searchdelay_; + + QString quality_; + int searchdelay_; + int albumssearchlimit_; + int songssearchlimit_; + bool fetchalbums_; + QString coversize_; + bool preview_; + QString access_token_; + QDateTime expiry_time_; + + int pending_search_id_; + int next_pending_search_id_; + QString pending_search_text_; + DeezerSettingsPage::SearchBy pending_searchby_; + + int search_id_; + QString search_text_; + QHash requests_album_; + QHash requests_song_; + int albums_requested_; + int albums_received_; + SongList songs_; + QString search_error_; + QUrl stream_request_url_; + +}; + +#endif // DEEZERSERVICE_H diff --git a/src/deezer/deezerurlhandler.cpp b/src/deezer/deezerurlhandler.cpp new file mode 100644 index 00000000..5dd2d53e --- /dev/null +++ b/src/deezer/deezerurlhandler.cpp @@ -0,0 +1,65 @@ +/* + * 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 "core/application.h" +#include "core/taskmanager.h" +#include "core/iconloader.h" +#include "core/logging.h" +#include "core/song.h" +#include "deezer/deezerservice.h" +#include "deezerurlhandler.h" + +DeezerUrlHandler::DeezerUrlHandler( + Application *app, DeezerService *service) + : UrlHandler(service), app_(app), service_(service), task_id_(-1) { + + connect(service, SIGNAL(StreamURLReceived(QUrl, QUrl, Song::FileType)), this, SLOT(GetStreamURLFinished(QUrl, QUrl, Song::FileType))); + +} + +UrlHandler::LoadResult DeezerUrlHandler::StartLoading(const QUrl &url) { + + LoadResult ret(url); + if (task_id_ != -1) return ret; + last_original_url_ = url; + task_id_ = app_->task_manager()->StartTask(QString("Loading %1 stream...").arg(url.scheme())); + service_->GetStreamURL(url); + ret.type_ = LoadResult::WillLoadAsynchronously; + return ret; + +} + +void DeezerUrlHandler::GetStreamURLFinished(QUrl original_url, QUrl media_url, Song::FileType filetype) { + + if (task_id_ == -1) return; + CancelTask(); + emit AsyncLoadComplete(LoadResult(original_url, LoadResult::TrackAvailable, media_url, filetype)); + +} + +void DeezerUrlHandler::CancelTask() { + + app_->task_manager()->SetTaskFinished(task_id_); + task_id_ = -1; + +} diff --git a/src/deezer/deezerurlhandler.h b/src/deezer/deezerurlhandler.h new file mode 100644 index 00000000..e17f9dc9 --- /dev/null +++ b/src/deezer/deezerurlhandler.h @@ -0,0 +1,56 @@ +/* + * 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 DEEZERURLHANDLER_H +#define DEEZERURLHANDLER_H + +#include +#include +#include + +#include "core/urlhandler.h" +#include "core/song.h" +#include "deezer/deezerservice.h" + +class Application; +class DeezerService; + +class DeezerUrlHandler : public UrlHandler { + Q_OBJECT + + public: + DeezerUrlHandler(Application *app, DeezerService *service); + + QString scheme() const { return service_->url_scheme(); } + LoadResult StartLoading(const QUrl &url); + + void CancelTask(); + + private slots: + void GetStreamURLFinished(QUrl original_url, QUrl media_url, Song::FileType filetype); + + private: + Application *app_; + DeezerService *service_; + int task_id_; + QUrl last_original_url_; + +}; + +#endif diff --git a/src/device/deviceproperties.ui b/src/device/deviceproperties.ui index 98d9ad5a..72a128b5 100644 --- a/src/device/deviceproperties.ui +++ b/src/device/deviceproperties.ui @@ -14,7 +14,7 @@ Device Properties - + :/icons/64x64/strawberry.png:/icons/64x64/strawberry.png @@ -453,6 +453,7 @@ + diff --git a/src/dialogs/about.ui b/src/dialogs/about.ui index 372176f4..158b678d 100644 --- a/src/dialogs/about.ui +++ b/src/dialogs/about.ui @@ -12,7 +12,7 @@ Qt::StrongFocus - + :/icons/64x64/strawberry.png:/icons/64x64/strawberry.png @@ -32,7 +32,7 @@ - :/icons/64x64/strawberry.png + :/icons/64x64/strawberry.png false @@ -145,6 +145,7 @@ + diff --git a/src/dialogs/edittagdialog.ui b/src/dialogs/edittagdialog.ui index 171a8133..b382f772 100644 --- a/src/dialogs/edittagdialog.ui +++ b/src/dialogs/edittagdialog.ui @@ -14,7 +14,7 @@ Edit track information - + :/icons/64x64/strawberry.png:/icons/64x64/strawberry.png @@ -861,6 +861,7 @@ + diff --git a/src/dialogs/organisedialog.ui b/src/dialogs/organisedialog.ui index 68045fc3..d090395f 100644 --- a/src/dialogs/organisedialog.ui +++ b/src/dialogs/organisedialog.ui @@ -14,7 +14,7 @@ Organise Files - + :/icons/64x64/strawberry.png:/icons/64x64/strawberry.png @@ -271,6 +271,7 @@ + diff --git a/src/dialogs/trackselectiondialog.ui b/src/dialogs/trackselectiondialog.ui index acb1aded..49504cc4 100644 --- a/src/dialogs/trackselectiondialog.ui +++ b/src/dialogs/trackselectiondialog.ui @@ -14,7 +14,7 @@ Tag fetcher - + :/icons/64x64/strawberry.png:/icons/64x64/strawberry.png @@ -261,6 +261,7 @@ + diff --git a/src/engine/deezerengine.cpp b/src/engine/deezerengine.cpp new file mode 100644 index 00000000..db1941f0 --- /dev/null +++ b/src/engine/deezerengine.cpp @@ -0,0 +1,487 @@ +/* + * 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 "core/timeconstants.h" +#include "core/taskmanager.h" +#include "core/logging.h" +#include "engine_fwd.h" +#include "enginebase.h" +#include "enginetype.h" +#include "deezerengine.h" +#include "deezer/deezerservice.h" +#include "settings/deezersettingspage.h" + +DeezerEngine::DeezerEngine(TaskManager *task_manager) + : EngineBase(), + state_(Engine::Empty), + position_(0) { + + type_ = Engine::Deezer; + ReloadSettings(); + +} + +DeezerEngine::~DeezerEngine() { + + if (player_) { + dz_object_release((dz_object_handle) player_); + player_ = nullptr; + } + + if (connect_) { + dz_object_release((dz_object_handle) connect_); + connect_ = nullptr; + } + +} + +bool DeezerEngine::Init() { + + qLog(Debug) << "Deezer native SDK Version:" << dz_connect_get_build_id(); + + struct dz_connect_configuration config; + memset(&config, 0, sizeof(struct dz_connect_configuration)); + config.app_id = QString::number(DeezerService::kAppID).toUtf8(); + config.product_id = QCoreApplication::applicationName().toUtf8(); + config.product_build_id = QCoreApplication::applicationVersion().toUtf8().constData(); + config.user_profile_path = QStandardPaths::writableLocation(QStandardPaths::CacheLocation).toUtf8().constData(); + config.connect_event_cb = ConnectEventCallback; + + connect_ = dz_connect_new(&config); + if (!connect_) { + qLog(Error) << "Deezer: Failed to create connect."; + return false; + } + + qLog(Debug) << "Device ID:" << dz_connect_get_device_id(connect_); + + dz_error_t dzerr(DZ_ERROR_NO_ERROR); + + dzerr = dz_connect_debug_log_disable(connect_); + if (dzerr != DZ_ERROR_NO_ERROR) { + qLog(Error) << "Deezer: Failed to disable debug log."; + return false; + } + + dzerr = dz_connect_activate(connect_, this); + if (dzerr != DZ_ERROR_NO_ERROR) { + qLog(Error) << "Deezer: Failed to activate connect."; + return false; + } + + dz_connect_cache_path_set(connect_, nullptr, nullptr, QStandardPaths::writableLocation(QStandardPaths::CacheLocation).toUtf8().constData()); + + player_ = dz_player_new(connect_); + if (!player_) { + qLog(Error) << "Deezer: Failed to create player."; + return false; + } + + dzerr = dz_player_activate(player_, this); + if (dzerr != DZ_ERROR_NO_ERROR) { + qLog(Error) << "Deezer: Failed to activate player."; + return false; + } + + dzerr = dz_player_set_event_cb(player_, PlayerEventCallback); + if (dzerr != DZ_ERROR_NO_ERROR) { + qLog(Error) << "Deezer: Failed to set event callback."; + return false; + } + + dzerr = dz_player_set_metadata_cb(player_, PlayerMetaDataCallback); + if (dzerr != DZ_ERROR_NO_ERROR) { + qLog(Error) << "Deezer: Failed to set metadata callback."; + return false; + } + + dzerr = dz_player_set_render_progress_cb(player_, PlayerProgressCallback, 1000); + if (dzerr != DZ_ERROR_NO_ERROR) { + qLog(Error) << "Deezer: Failed to set progress callback."; + return false; + } + + dzerr = dz_player_set_crossfading_duration(player_, nullptr, nullptr, 3000); + if (dzerr != DZ_ERROR_NO_ERROR) { + qLog(Error) << "Deezer: Failed to set crossfade duration."; + return false; + } + + dzerr = dz_connect_offline_mode(connect_, nullptr, nullptr, false); + if (dzerr != DZ_ERROR_NO_ERROR) { + qLog(Error) << "Deezer: Failed to set offline mode."; + return false; + } + + LoadAccessToken(); + + return true; + +} + +bool DeezerEngine::Initialised() const { + + if (connect_ && player_) return true; + return false; + +} + +void DeezerEngine::LoadAccessToken() { + + QSettings s; + s.beginGroup(DeezerSettingsPage::kSettingsGroup); + if (!s.contains("access_token") || !s.contains("expiry_time")) return; + access_token_ = s.value("access_token").toString(); + expiry_time_ = s.value("expiry_time").toDateTime(); + s.endGroup(); + + dz_error_t dzerr = dz_connect_set_access_token(connect_, nullptr, nullptr, access_token_.toUtf8().constData()); + if (dzerr != DZ_ERROR_NO_ERROR) { + qLog(Error) << "Deezer: Failed to set access token."; + } + +} + +bool DeezerEngine::Load(const QUrl &media_url, const QUrl &original_url, Engine::TrackChangeFlags change, bool force_stop_at_end, quint64 beginning_nanosec, qint64 end_nanosec) { + + if (!Initialised()) return false; + + Engine::Base::Load(media_url, original_url, change, force_stop_at_end, beginning_nanosec, end_nanosec); + dz_error_t dzerr = dz_player_load(player_, nullptr, nullptr, media_url.toString().toUtf8().constData()); + if (dzerr != DZ_ERROR_NO_ERROR) return false; + + return true; + +} + +bool DeezerEngine::Play(quint64 offset_nanosec) { + + if (!Initialised()) return false; + + dz_error_t dzerr(DZ_ERROR_NO_ERROR); + if (state() == Engine::Paused) dzerr = dz_player_resume(player_, nullptr, nullptr); + else dzerr = dz_player_play(player_, nullptr, nullptr, DZ_PLAYER_PLAY_CMD_START_TRACKLIST, DZ_INDEX_IN_QUEUELIST_CURRENT); + if (dzerr != DZ_ERROR_NO_ERROR) return false; + + Seek(offset_nanosec); + + return true; + +} + +void DeezerEngine::Stop(bool stop_after) { + + if (!Initialised()) return; + + dz_error_t dzerr = dz_player_stop(player_, nullptr, nullptr); + if (dzerr != DZ_ERROR_NO_ERROR) return; + + state_ = Engine::Empty; + emit TrackEnded(); + +} + +void DeezerEngine::Pause() { + + if (!Initialised()) return; + + dz_error_t dzerr = dz_player_pause(player_, nullptr, nullptr); + if (dzerr != DZ_ERROR_NO_ERROR) return; + +} + +void DeezerEngine::Unpause() { + + if (!Initialised()) return; + dz_error_t dzerr = dz_player_resume(player_, nullptr, nullptr); + if (dzerr != DZ_ERROR_NO_ERROR) return; + +} + +void DeezerEngine::Seek(quint64 offset_nanosec) { + + if (!Initialised()) return; + + int offset = (offset_nanosec / kNsecPerMsec); + + uint len = (length_nanosec() / kNsecPerMsec); + if (len == 0) return; + + float pos = float(offset) / len; + + dz_error_t dzerr = dz_player_seek(player_, nullptr, nullptr, pos); + if (dzerr != DZ_ERROR_NO_ERROR) return; + +} + +void DeezerEngine::SetVolumeSW(uint percent) { + + if (!Initialised()) return; + + dz_error_t dzerr = dz_player_set_output_volume(player_, nullptr, nullptr, percent); + if (dzerr != DZ_ERROR_NO_ERROR) qLog(Error) << "Deezer: Failed to set volume."; + +} + +qint64 DeezerEngine::position_nanosec() const { + + if (state() == Engine::Empty) return 0; + const qint64 result = (position_ * kNsecPerUsec); + return qint64(qMax(0ll, result)); + +} + +qint64 DeezerEngine::length_nanosec() const { + + if (state() == Engine::Empty) return 0; + + const qint64 result = (end_nanosec_ - beginning_nanosec_); + return result; + +} + +EngineBase::OutputDetailsList DeezerEngine::GetOutputsList() const { + OutputDetailsList ret; + OutputDetails output; + output.name = "default"; + output.description = "Default"; + output.iconname = "soundcard"; + ret << output; + return ret; +} + +bool DeezerEngine::ValidOutput(const QString &output) { + return(true); +} + +bool DeezerEngine::CustomDeviceSupport(const QString &output) { + return false; +} + +bool DeezerEngine::ALSADeviceSupport(const QString &output) { + return false; +} + +bool DeezerEngine::CanDecode(const QUrl &url) { + if (url.scheme() == "dzmedia") return true; + else return false; +} + +void DeezerEngine::ConnectEventCallback(dz_connect_handle handle, dz_connect_event_handle event, void *delegate) { + + dz_connect_event_t type = dz_connect_event_get_type(event); + //DeezerEngine *engine = reinterpret_cast(delegate); + + switch (type) { + case DZ_CONNECT_EVENT_USER_OFFLINE_AVAILABLE: + qLog(Debug) << "CONNECT_EVENT USER_OFFLINE_AVAILABLE"; + break; + + case DZ_CONNECT_EVENT_USER_ACCESS_TOKEN_OK: { + const char* szAccessToken; + szAccessToken = dz_connect_event_get_access_token(event); + qLog(Debug) << "CONNECT_EVENT USER_ACCESS_TOKEN_OK Access_token :" << szAccessToken; + } + break; + + case DZ_CONNECT_EVENT_USER_ACCESS_TOKEN_FAILED: + qLog(Debug) << "CONNECT_EVENT USER_ACCESS_TOKEN_FAILED"; + break; + + case DZ_CONNECT_EVENT_USER_LOGIN_OK: + qLog(Debug) << "Deezer CONNECT_EVENT USER_LOGIN_OK"; + break; + + case DZ_CONNECT_EVENT_USER_NEW_OPTIONS: + qLog(Debug) << "Deezer: CONNECT_EVENT USER_NEW_OPTIONS"; + break; + + case DZ_CONNECT_EVENT_USER_LOGIN_FAIL_NETWORK_ERROR: + qLog(Debug) << "Deezer: CONNECT_EVENT USER_LOGIN_FAIL_NETWORK_ERROR"; + break; + + case DZ_CONNECT_EVENT_USER_LOGIN_FAIL_BAD_CREDENTIALS: + qLog(Debug) << "Deezer: CONNECT_EVENT USER_LOGIN_FAIL_BAD_CREDENTIALS"; + break; + + case DZ_CONNECT_EVENT_USER_LOGIN_FAIL_USER_INFO: + qLog(Debug) << "Deezer: CONNECT_EVENT USER_LOGIN_FAIL_USER_INFO"; + break; + + case DZ_CONNECT_EVENT_USER_LOGIN_FAIL_OFFLINE_MODE: + qLog(Debug) << "Deezer: CONNECT_EVENT USER_LOGIN_FAIL_OFFLINE_MODE"; + break; + + case DZ_CONNECT_EVENT_ADVERTISEMENT_START: + qLog(Debug) << "Deezer: CONNECT_EVENTADVERTISEMENT_START"; + break; + + case DZ_CONNECT_EVENT_ADVERTISEMENT_STOP: + qLog(Debug) << "Deezer: CONNECT_EVENTADVERTISEMENT_STOP"; + break; + + case DZ_CONNECT_EVENT_UNKNOWN: + default: + qLog(Debug) << "Deezer: CONNECT_EVENTUNKNOWN or default (type =" << type; + break; + } + +} + + +void DeezerEngine::PlayerEventCallback(dz_player_handle handle, dz_player_event_handle event, void *supervisor) { + + DeezerEngine *engine = reinterpret_cast(supervisor); + dz_streaming_mode_t streaming_mode; + dz_index_in_queuelist idx; + dz_player_event_t type = dz_player_event_get_type(event); + + if (!dz_player_event_get_queuelist_context(event, &streaming_mode, &idx)) { + streaming_mode = DZ_STREAMING_MODE_ONDEMAND; + idx = DZ_INDEX_IN_QUEUELIST_INVALID; + } + + switch (type) { + + case DZ_PLAYER_EVENT_LIMITATION_FORCED_PAUSE: + qLog(Debug) << "Deezer: PLAYER_EVENT_LIMITATION_FORCED_PAUSE"; + break; + + case DZ_PLAYER_EVENT_QUEUELIST_LOADED: + qLog(Debug) << "Deezer: PLAYER_EVENT_QUEUELIST_LOADED"; + break; + + case DZ_PLAYER_EVENT_QUEUELIST_NO_RIGHT: + qLog(Debug) << "Deezer: PLAYER_EVENT_QUEUELIST_NO_RIGHT"; + break; + + case DZ_PLAYER_EVENT_QUEUELIST_NEED_NATURAL_NEXT: + qLog(Debug) << "Deezer: PLAYER_EVENT_QUEUELIST_NEED_NATURAL_NEXT"; + break; + + case DZ_PLAYER_EVENT_QUEUELIST_TRACK_NOT_AVAILABLE_OFFLINE: + qLog(Debug) << "Deezer: PLAYER_EVENT_QUEUELIST_TRACK_NOT_AVAILABLE_OFFLINE"; + engine->state_ = Engine::Error; + emit engine->StateChanged(engine->state_); + emit engine->Error("Track not available offline."); + break; + + case DZ_PLAYER_EVENT_QUEUELIST_TRACK_RIGHTS_AFTER_AUDIOADS: + qLog(Debug) << "Deezer: PLAYER_EVENT_QUEUELIST_TRACK_RIGHTS_AFTER_AUDIOADS"; + break; + + case DZ_PLAYER_EVENT_QUEUELIST_SKIP_NO_RIGHT: + qLog(Debug) << "Deezer: PLAYER_EVENT_QUEUELIST_SKIP_NO_RIGHT"; + break; + + case DZ_PLAYER_EVENT_QUEUELIST_TRACK_SELECTED: + break; + + case DZ_PLAYER_EVENT_MEDIASTREAM_DATA_READY: + qLog(Debug) << "Deezer: PLAYER_EVENT_MEDIASTREAM_DATA_READY"; + break; + + case DZ_PLAYER_EVENT_MEDIASTREAM_DATA_READY_AFTER_SEEK: + qLog(Debug) << "Deezer: PLAYER_EVENT_MEDIASTREAM_DATA_READY_AFTER_SEEK"; + break; + + case DZ_PLAYER_EVENT_RENDER_TRACK_START_FAILURE: + qLog(Debug) << "Deezer: PLAYER_EVENT_RENDER_TRACK_START_FAILURE"; + engine->state_ = Engine::Error; + emit engine->StateChanged(engine->state_); + emit engine->Error("Track start failure."); + break; + + case DZ_PLAYER_EVENT_RENDER_TRACK_START: + qLog(Debug) << "Deezer: PLAYER_EVENT_RENDER_TRACK_START"; + engine->state_ = Engine::Playing; + engine->position_ = 0; + emit engine->StateChanged(engine->state_); + break; + + case DZ_PLAYER_EVENT_RENDER_TRACK_END: + qLog(Debug) << "Deezer: PLAYER_EVENT_RENDER_TRACK_END"; + engine->state_ = Engine::Idle; + engine->position_ = 0; + emit engine->TrackEnded(); + break; + + case DZ_PLAYER_EVENT_RENDER_TRACK_PAUSED: + qLog(Debug) << "Deezer: PLAYER_EVENT_RENDER_TRACK_PAUSED"; + engine->state_ = Engine::Paused; + emit engine->StateChanged(engine->state_); + break; + + case DZ_PLAYER_EVENT_RENDER_TRACK_UNDERFLOW: + qLog(Debug) << "Deezer: PLAYER_EVENT_RENDER_TRACK_UNDERFLOW"; + break; + + case DZ_PLAYER_EVENT_RENDER_TRACK_RESUMED: + qLog(Debug) << "Deezer: PLAYER_EVENT_RENDER_TRACK_RESUMED"; + engine->state_ = Engine::Playing; + emit engine->StateChanged(engine->state_); + break; + + case DZ_PLAYER_EVENT_RENDER_TRACK_SEEKING: + qLog(Debug) << "Deezer: PLAYER_EVENT_RENDER_TRACK_SEEKING"; + break; + + case DZ_PLAYER_EVENT_RENDER_TRACK_REMOVED: + qLog(Debug) << "Deezer: PLAYER_EVENT_RENDER_TRACK_REMOVED"; + engine->state_ = Engine::Empty; + engine->position_ = 0; + emit engine->TrackEnded(); + break; + + case DZ_PLAYER_EVENT_UNKNOWN: + default: + qLog(Error) << "Deezer: Unknown player event" << type; + break; + } + //emit engine->StateChanged(engine->state_); + +} + +void DeezerEngine::PlayerProgressCallback(dz_player_handle handle, dz_useconds_t progress, void *userdata) { + DeezerEngine *engine = reinterpret_cast(userdata); + engine->position_ = progress; +} + +void DeezerEngine::PlayerMetaDataCallback(dz_player_handle handle, dz_track_metadata_handle metadata, void *userdata) { + //DeezerEngine *engine = reinterpret_cast(userdata); +} diff --git a/src/engine/deezerengine.h b/src/engine/deezerengine.h new file mode 100644 index 00000000..3d425ff0 --- /dev/null +++ b/src/engine/deezerengine.h @@ -0,0 +1,88 @@ +/* + * 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 DEEZERENGINE_H +#define DEEZERENGINE_H + +#include "config.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "engine_fwd.h" +#include "enginebase.h" + +class TaskManager; + +class DeezerEngine : public Engine::Base { + Q_OBJECT + + public: + DeezerEngine(TaskManager *task_manager); + ~DeezerEngine(); + + bool Init(); + Engine::State state() const { return state_; } + bool Load(const QUrl &media_url, const QUrl &original_url, Engine::TrackChangeFlags change, bool force_stop_at_end, quint64 beginning_nanosec, qint64 end_nanosec); + bool Play(quint64 offset_nanosec); + void Stop(bool stop_after = false); + void Pause(); + void Unpause(); + void Seek(quint64 offset_nanosec); + protected: + void SetVolumeSW(uint percent); + public: + virtual qint64 position_nanosec() const; + virtual qint64 length_nanosec() const; + + OutputDetailsList GetOutputsList() const; + bool ValidOutput(const QString &output); + QString DefaultOutput() { return ""; } + bool CustomDeviceSupport(const QString &output); + bool ALSADeviceSupport(const QString &output); + + private: + Engine::State state_; + dz_connect_handle connect_; + dz_player_handle player_; + QString access_token_; + QDateTime expiry_time_; + qint64 position_; + + bool Initialised() const; + bool CanDecode(const QUrl &url); + + static void ConnectEventCallback(dz_connect_handle handle, dz_connect_event_handle event, void *delegate); + static void PlayerEventCallback(dz_player_handle handle, dz_player_event_handle event, void *supervisor); + static void PlayerMetaDataCallback(dz_player_handle handle, dz_track_metadata_handle metadata, void *userdata); + static void PlayerProgressCallback(dz_player_handle handle, dz_useconds_t progress, void *userdata); + + public slots: + void LoadAccessToken(); + +}; + +#endif diff --git a/src/engine/enginebase.h b/src/engine/enginebase.h index ef7aedc1..be4d9db5 100644 --- a/src/engine/enginebase.h +++ b/src/engine/enginebase.h @@ -147,8 +147,7 @@ signals: void MetaData(const Engine::SimpleMetaBundle&); // Signals that the engine's state has changed (a stream was stopped for example). - // Always use the state from event, because it's not guaranteed that immediate - // subsequent call to state() won't return a stale value. + // Always use the state from event, because it's not guaranteed that immediate subsequent call to state() won't return a stale value. void StateChanged(Engine::State); protected: diff --git a/src/engine/enginedevice.cpp b/src/engine/enginedevice.cpp index 37af9d31..fd414fa3 100644 --- a/src/engine/enginedevice.cpp +++ b/src/engine/enginedevice.cpp @@ -1,7 +1,6 @@ /* * Strawberry Music Player - * This file was part of Clementine. - * Copyright 2014, David Sansome + * Copyright 2014, 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 diff --git a/src/engine/enginedevice.h b/src/engine/enginedevice.h index 3cc5312a..795b265d 100644 --- a/src/engine/enginedevice.h +++ b/src/engine/enginedevice.h @@ -1,7 +1,6 @@ /* * Strawberry Music Player - * This file was part of Clementine. - * Copyright 2014, David Sansome + * Copyright 2014, 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 diff --git a/src/engine/enginetype.cpp b/src/engine/enginetype.cpp index 718a77ba..4096639f 100644 --- a/src/engine/enginetype.cpp +++ b/src/engine/enginetype.cpp @@ -28,19 +28,21 @@ namespace Engine { Engine::EngineType EngineTypeFromName(QString enginename) { QString lower = enginename.toLower(); - if (lower == "xine") return Engine::Xine; - else if (lower == "gstreamer") return Engine::GStreamer; - else if (lower == "phonon") return Engine::Phonon; - else if (lower == "vlc") return Engine::VLC; - else return Engine::None; + if (lower == "gstreamer") return Engine::GStreamer; + else if (lower == "xine") return Engine::Xine; + else if (lower == "vlc") return Engine::VLC; + else if (lower == "phonon") return Engine::Phonon; + else if (lower == "deezer") return Engine::Deezer; + else return Engine::None; } QString EngineName(Engine::EngineType enginetype) { switch (enginetype) { - case Engine::Xine: return QString("xine"); case Engine::GStreamer: return QString("gstreamer"); - case Engine::Phonon: return QString("phonon"); + case Engine::Xine: return QString("xine"); case Engine::VLC: return QString("vlc"); + case Engine::Phonon: return QString("phonon"); + case Engine::Deezer: return QString("deezer"); case Engine::None: default: return QString("None"); } @@ -48,10 +50,11 @@ QString EngineName(Engine::EngineType enginetype) { QString EngineDescription(Engine::EngineType enginetype) { switch (enginetype) { - case Engine::Xine: return QString("Xine"); case Engine::GStreamer: return QString("GStreamer"); - case Engine::Phonon: return QString("Phonon"); + case Engine::Xine: return QString("Xine"); case Engine::VLC: return QString("VLC"); + case Engine::Phonon: return QString("Phonon"); + case Engine::Deezer: return QString("Deezer"); case Engine::None: default: return QString("None"); diff --git a/src/engine/enginetype.h b/src/engine/enginetype.h index 71435eea..4039a4ad 100644 --- a/src/engine/enginetype.h +++ b/src/engine/enginetype.h @@ -32,7 +32,8 @@ enum EngineType { GStreamer, VLC, Xine, - Phonon + Phonon, + Deezer }; Engine::EngineType EngineTypeFromName(QString enginename); diff --git a/src/equalizer/equalizer.ui b/src/equalizer/equalizer.ui index d7e2a4fe..f9d57d19 100644 --- a/src/equalizer/equalizer.ui +++ b/src/equalizer/equalizer.ui @@ -14,7 +14,7 @@ Equalizer - + :/icons/64x64/strawberry.png:/icons/64x64/strawberry.png @@ -158,6 +158,7 @@ + diff --git a/src/globalshortcuts/globalshortcutgrabber.ui b/src/globalshortcuts/globalshortcutgrabber.ui index adc63413..7ee8195a 100644 --- a/src/globalshortcuts/globalshortcutgrabber.ui +++ b/src/globalshortcuts/globalshortcutgrabber.ui @@ -14,7 +14,7 @@ Press a key - + :/icons/64x64/strawberry.png:/icons/64x64/strawberry.png @@ -52,6 +52,7 @@
+ diff --git a/src/internet/internetmodel.cpp b/src/internet/internetmodel.cpp index 7891f4bc..e54b80ce 100644 --- a/src/internet/internetmodel.cpp +++ b/src/internet/internetmodel.cpp @@ -31,6 +31,7 @@ #include "internetmodel.h" #include "internetservice.h" #include "tidal/tidalservice.h" +#include "deezer/deezerservice.h" QMap* InternetModel::sServices = nullptr; @@ -41,6 +42,7 @@ InternetModel::InternetModel(Application *app, QObject *parent) if (!sServices) sServices = new QMap; Q_ASSERT(sServices->isEmpty()); AddService(new TidalService(app, this)); + AddService(new DeezerService(app, this)); } diff --git a/src/internet/internetmodel.h b/src/internet/internetmodel.h index 0bf045fb..2cd83258 100644 --- a/src/internet/internetmodel.h +++ b/src/internet/internetmodel.h @@ -106,7 +106,6 @@ class InternetModel : public QStandardItemModel { // Needs to be static for InternetPlaylistItem::restore static InternetService *ServiceBySource(const Song::Source &source); - //static InternetService *ServiceByName(const QString &name); template static T *Service() { diff --git a/src/internet/localredirectserver.cpp b/src/internet/localredirectserver.cpp new file mode 100644 index 00000000..ae1cb8ad --- /dev/null +++ b/src/internet/localredirectserver.cpp @@ -0,0 +1,111 @@ +/* + * This file was part of Clementine. + * Copyright 2012, 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * + * 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 "localredirectserver.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "core/closure.h" + +LocalRedirectServer::LocalRedirectServer(QObject* parent) + : QObject(parent), server_(new QTcpServer(this)) {} + +void LocalRedirectServer::Listen() { + + server_->listen(QHostAddress::LocalHost); + // We have to calculate this and store it now as the server port is cleared once we close the socket. + url_.setScheme("http"); + url_.setHost("localhost"); + url_.setPort(server_->serverPort()); + url_.setPath("/"); + connect(server_, SIGNAL(newConnection()), SLOT(NewConnection())); + +} + +void LocalRedirectServer::NewConnection() { + QTcpSocket* socket = server_->nextPendingConnection(); + server_->close(); + + QByteArray buffer; + NewClosure(socket, SIGNAL(readyRead()), this, SLOT(ReadyRead(QTcpSocket*, QByteArray)), socket, buffer); +} + +void LocalRedirectServer::ReadyRead(QTcpSocket* socket, QByteArray buffer) { + buffer.append(socket->readAll()); + if (socket->atEnd() || buffer.endsWith("\r\n\r\n")) { + WriteTemplate(socket); + socket->deleteLater(); + request_url_ = ParseUrlFromRequest(buffer); + emit Finished(); + } + else { + NewClosure(socket, SIGNAL(readyRead()), this, SLOT(ReadyReady(QTcpSocket*, QByteArray)), socket, buffer); + } +} + +void LocalRedirectServer::WriteTemplate(QTcpSocket* socket) const { + + QFile page_file(":/misc/oauthsuccess.html"); + page_file.open(QIODevice::ReadOnly); + QString page_data = QString::fromUtf8(page_file.readAll()); + + QRegExp tr_regexp("tr\\(\"([^\"]+)\"\\)"); + int offset = 0; + forever { + offset = tr_regexp.indexIn(page_data, offset); + if (offset == -1) { + break; + } + + page_data.replace(offset, tr_regexp.matchedLength(), tr(tr_regexp.cap(1).toUtf8())); + offset += tr_regexp.matchedLength(); + } + + QBuffer image_buffer; + image_buffer.open(QIODevice::ReadWrite); + QApplication::style() + ->standardIcon(QStyle::SP_DialogOkButton) + .pixmap(16) + .toImage() + .save(&image_buffer, "PNG"); + page_data.replace("@IMAGE_DATA@", image_buffer.data().toBase64()); + + socket->write("HTTP/1.0 200 OK\r\n"); + socket->write("Content-type: text/html;charset=UTF-8\r\n"); + socket->write("\r\n\r\n"); + socket->write(page_data.toUtf8()); + socket->flush(); + +} + +QUrl LocalRedirectServer::ParseUrlFromRequest(const QByteArray& request) const { + QList lines = request.split('\r'); + const QByteArray& request_line = lines[0]; + QByteArray path = request_line.split(' ')[1]; + QUrl base_url = url(); + QUrl request_url(base_url.toString() + path.mid(1), QUrl::StrictMode); + return request_url; +} diff --git a/src/internet/localredirectserver.h b/src/internet/localredirectserver.h new file mode 100644 index 00000000..e62086ba --- /dev/null +++ b/src/internet/localredirectserver.h @@ -0,0 +1,63 @@ +/* + * This file was part of Clementine. + * Copyright 2012, 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * + * 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 LOCALREDIRECTSERVER_H +#define LOCALREDIRECTSERVER_H + +#include +#include +#include + +class QTcpServer; +class QTcpSocket; + +class LocalRedirectServer : public QObject { + Q_OBJECT + + public: + explicit LocalRedirectServer(QObject* parent = nullptr); + + // Causes the server to listen for _one_ request. + void Listen(); + + // Returns the HTTP URL of this server. + const QUrl& url() const { return url_; } + + // Returns the URL requested by the OAuth redirect. + const QUrl& request_url() const { return request_url_; } + + signals: + void Finished(); + + private slots: + void NewConnection(); + void ReadyRead(QTcpSocket* socket, QByteArray buffer); + + private: + void WriteTemplate(QTcpSocket* socket) const; + QUrl ParseUrlFromRequest(const QByteArray& request) const; + + private: + QTcpServer* server_; + QUrl url_; + QUrl request_url_; +}; + +#endif diff --git a/src/playlist/playlistview.h b/src/playlist/playlistview.h index 100724ef..cf890657 100644 --- a/src/playlist/playlistview.h +++ b/src/playlist/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 tidal search view as well. +// This class is used by tidal and deezer search view as well. class PlaylistProxyStyle : public QProxyStyle { public: PlaylistProxyStyle(QStyle *base); diff --git a/src/playlist/queuemanager.ui b/src/playlist/queuemanager.ui index d6d88b7f..807e31f2 100644 --- a/src/playlist/queuemanager.ui +++ b/src/playlist/queuemanager.ui @@ -14,7 +14,7 @@ Queue Manager - + :/icons/64x64/strawberry.png:/icons/64x64/strawberry.png @@ -157,6 +157,7 @@ + diff --git a/src/settings/backendsettingspage.cpp b/src/settings/backendsettingspage.cpp index 9f0918b7..4562e9b1 100644 --- a/src/settings/backendsettingspage.cpp +++ b/src/settings/backendsettingspage.cpp @@ -96,6 +96,9 @@ void BackendSettingsPage::Load() { #ifdef HAVE_PHONON ui_->combobox_engine->addItem(IconLoader::Load("speaker"), EngineDescription(Engine::Phonon), Engine::Phonon); #endif +#ifdef HAVE_DEEZER + ui_->combobox_engine->addItem(IconLoader::Load("deezer"), EngineDescription(Engine::Deezer), Engine::Deezer); +#endif enginetype_current_ = enginetype; output_current_ = s_.value("output", "").toString(); diff --git a/src/settings/deezersettingspage.cpp b/src/settings/deezersettingspage.cpp new file mode 100644 index 00000000..c073174c --- /dev/null +++ b/src/settings/deezersettingspage.cpp @@ -0,0 +1,142 @@ +/* + * 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 "deezersettingspage.h" +#include "ui_deezersettingspage.h" +#include "core/application.h" +#include "core/iconloader.h" +#include "internet/internetmodel.h" +#include "deezer/deezerservice.h" + +const char *DeezerSettingsPage::kSettingsGroup = "Deezer"; + +DeezerSettingsPage::DeezerSettingsPage(SettingsDialog *parent) + : SettingsPage(parent), + ui_(new Ui::DeezerSettingsPage), + service_(dialog()->app()->internet_model()->Service()) { + + ui_->setupUi(this); + setWindowIcon(IconLoader::Load("deezer")); + + connect(ui_->button_login, SIGNAL(clicked()), SLOT(LoginClicked())); + connect(ui_->login_state, SIGNAL(LogoutClicked()), SLOT(LogoutClicked())); + + connect(this, SIGNAL(Login()), service_, SLOT(StartAuthorisation())); + + connect(service_, SIGNAL(LoginFailure(QString)), SLOT(LoginFailure(QString))); + connect(service_, SIGNAL(LoginSuccess()), SLOT(LoginSuccess())); + + dialog()->installEventFilter(this); + + ui_->combobox_quality->addItem("AAC (64)", "AAC_64"); + ui_->combobox_quality->addItem("MP3 (64)", "MP3_64"); + ui_->combobox_quality->addItem("MP3 (128)", "MP3_128"); + ui_->combobox_quality->addItem("MP3 (256)", "MP3_256"); + ui_->combobox_quality->addItem("MP3 (320)", "MP3_320"); + ui_->combobox_quality->addItem("FLAC", "FLAC"); + + ui_->combobox_coversize->addItem("Small", "cover_small"); + ui_->combobox_coversize->addItem("Medium", "cover_medium"); + ui_->combobox_coversize->addItem("Big", "cover_big"); + ui_->combobox_coversize->addItem("XL", "cover_xl"); + +} + +DeezerSettingsPage::~DeezerSettingsPage() { delete ui_; } + +void DeezerSettingsPage::Load() { + + QSettings s; + + s.beginGroup(kSettingsGroup); + ui_->username->setText(s.value("username").toString()); + QByteArray password = s.value("password").toByteArray(); + if (password.isEmpty()) ui_->password->clear(); + else ui_->password->setText(QString::fromUtf8(QByteArray::fromBase64(password))); + dialog()->ComboBoxLoadFromSettings(s, ui_->combobox_quality, "quality", "FLAC"); + ui_->spinbox_searchdelay->setValue(s.value("searchdelay", 1500).toInt()); + ui_->spinbox_albumssearchlimit->setValue(s.value("albumssearchlimit", 100).toInt()); + ui_->spinbox_songssearchlimit->setValue(s.value("songssearchlimit", 100).toInt()); + ui_->checkbox_fetchalbums->setChecked(s.value("fetchalbums", false).toBool()); + dialog()->ComboBoxLoadFromSettings(s, ui_->combobox_coversize, "coversize", "cover_big"); + ui_->checkbox_preview->setChecked(s.value("preview", false).toBool()); + s.endGroup(); + + if (service_->authenticated()) ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedIn); + +} + +void DeezerSettingsPage::Save() { + + QSettings s; + s.beginGroup(kSettingsGroup); + s.setValue("username", ui_->username->text()); + s.setValue("password", QString::fromUtf8(ui_->password->text().toUtf8().toBase64())); + s.setValue("quality", ui_->combobox_quality->itemData(ui_->combobox_quality->currentIndex())); + s.setValue("searchdelay", ui_->spinbox_searchdelay->value()); + s.setValue("albumssearchlimit", ui_->spinbox_albumssearchlimit->value()); + s.setValue("songssearchlimit", ui_->spinbox_songssearchlimit->value()); + s.setValue("fetchalbums", ui_->checkbox_fetchalbums->isChecked()); + s.setValue("coversize", ui_->combobox_coversize->itemData(ui_->combobox_coversize->currentIndex())); + s.setValue("preview", ui_->checkbox_preview->isChecked()); + s.endGroup(); + + service_->ReloadSettings(); + +} + +void DeezerSettingsPage::LoginClicked() { + emit Login(); + ui_->button_login->setEnabled(false); +} + +bool DeezerSettingsPage::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 DeezerSettingsPage::LogoutClicked() { + service_->Logout(); + ui_->button_login->setEnabled(true); + ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedOut); +} + +void DeezerSettingsPage::LoginSuccess() { + if (!this->isVisible()) return; + ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedIn); + ui_->button_login->setEnabled(false); +} + +void DeezerSettingsPage::LoginFailure(QString failure_reason) { + if (!this->isVisible()) return; + QMessageBox::warning(this, tr("Authentication failed"), failure_reason); +} diff --git a/src/settings/deezersettingspage.h b/src/settings/deezersettingspage.h new file mode 100644 index 00000000..ae161857 --- /dev/null +++ b/src/settings/deezersettingspage.h @@ -0,0 +1,66 @@ +/* + * 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 DEEZERSETTINGSPAGE_H +#define DEEZERSETTINGSPAGE_H + +#include +#include +#include + +#include "settings/settingspage.h" + +class DeezerService; +class Ui_DeezerSettingsPage; + +class DeezerSettingsPage : public SettingsPage { + Q_OBJECT + + public: + explicit DeezerSettingsPage(SettingsDialog* parent = nullptr); + ~DeezerSettingsPage(); + + enum SearchBy { + SearchBy_Songs = 1, + SearchBy_Albums = 2, + }; + + static const char *kSettingsGroup; + + void Load(); + void Save(); + + bool eventFilter(QObject *object, QEvent *event); + +signals: + void Login(); + void Login(const QString &username, const QString &password); + + private slots: + void LoginClicked(); + void LogoutClicked(); + void LoginSuccess(); + void LoginFailure(QString failure_reason); + + private: + Ui_DeezerSettingsPage* ui_; + DeezerService *service_; +}; + +#endif diff --git a/src/settings/deezersettingspage.ui b/src/settings/deezersettingspage.ui new file mode 100644 index 00000000..d2938d82 --- /dev/null +++ b/src/settings/deezersettingspage.ui @@ -0,0 +1,417 @@ + + + DeezerSettingsPage + + + + 0 + 0 + 715 + 547 + + + + Deezer + + + + + + + 0 + 0 + + + + Authentication + + + + + + + + Login + + + + + + + Qt::Vertical + + + + 0 + 0 + + + + + + + + + + + 70 + 0 + + + + Username + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + 70 + 0 + + + + Password + + + + + + + QLineEdit::Password + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + + + Preferences + + + + + + + + + 150 + 0 + + + + Audio quality + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + 150 + 0 + + + + Search delay + + + + + + + ms + + + 0 + + + 10000 + + + 50 + + + 1500 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + 150 + 0 + + + + Albums search limit + + + + + + + 1 + + + 1000 + + + 50 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + 150 + 0 + + + + Songs search limit + + + + + + + 1 + + + 1000 + + + 50 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Fetch entire albums when searching songs + + + + + + + + + + 150 + 0 + + + + Album cover size + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Use 30 seconds preview streams + + + + + + + + + + Qt::Vertical + + + + 20 + 30 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 200 + 62 + + + + + 200 + 62 + + + + :/pictures/deezer.png + + + + + + + + + + LoginStateWidget + QWidget +
widgets/loginstatewidget.h
+ 1 +
+
+ + + + + +
diff --git a/src/settings/settingsdialog.cpp b/src/settings/settingsdialog.cpp index 09817676..b5efa624 100644 --- a/src/settings/settingsdialog.cpp +++ b/src/settings/settingsdialog.cpp @@ -63,6 +63,7 @@ #include "shortcutssettingspage.h" #include "transcodersettingspage.h" #include "tidalsettingspage.h" +#include "deezersettingspage.h" #include "ui_settingsdialog.h" @@ -114,7 +115,6 @@ SettingsDialog::SettingsDialog(Application *app, QWidget *parent) ui_->list->setItemDelegate(new SettingsItemDelegate(this)); QTreeWidgetItem *general = AddCategory(tr("General")); - AddPage(Page_Behaviour, new BehaviourSettingsPage(this), general); AddPage(Page_Collection, new CollectionSettingsPage(this), general); AddPage(Page_Backend, new BackendSettingsPage(this), general); @@ -124,7 +124,10 @@ SettingsDialog::SettingsDialog(Application *app, QWidget *parent) #ifdef HAVE_GSTREAMER AddPage(Page_Transcoding, new TranscoderSettingsPage(this), general); #endif - AddPage(Page_Tidal, new TidalSettingsPage(this), general); + + QTreeWidgetItem *internet = AddCategory(tr("Internet")); + AddPage(Page_Tidal, new TidalSettingsPage(this), internet); + AddPage(Page_Deezer, new DeezerSettingsPage(this), internet); // User interface QTreeWidgetItem *iface = AddCategory(tr("User interface")); diff --git a/src/settings/settingsdialog.h b/src/settings/settingsdialog.h index 5f529e19..a152c017 100644 --- a/src/settings/settingsdialog.h +++ b/src/settings/settingsdialog.h @@ -82,6 +82,7 @@ public: Page_Proxy, Page_Transcoding, Page_Tidal, + Page_Deezer, }; enum Role { diff --git a/src/settings/settingsdialog.ui b/src/settings/settingsdialog.ui index c1d8a824..f2b3913f 100644 --- a/src/settings/settingsdialog.ui +++ b/src/settings/settingsdialog.ui @@ -14,7 +14,7 @@ Settings - + :/icons/64x64/strawberry.png:/icons/64x64/strawberry.png @@ -97,6 +97,7 @@ + diff --git a/src/settings/shortcutssettingspage.ui b/src/settings/shortcutssettingspage.ui index 5ac13826..05599b69 100644 --- a/src/settings/shortcutssettingspage.ui +++ b/src/settings/shortcutssettingspage.ui @@ -14,7 +14,7 @@ Shortcuts - + :/icons/64x64/strawberry.png:/icons/64x64/strawberry.png @@ -212,6 +212,7 @@ + diff --git a/src/settings/tidalsettingspage.cpp b/src/settings/tidalsettingspage.cpp index 9a2b9596..98429f8a 100644 --- a/src/settings/tidalsettingspage.cpp +++ b/src/settings/tidalsettingspage.cpp @@ -76,7 +76,7 @@ void TidalSettingsPage::Load() { s.beginGroup(kSettingsGroup); ui_->username->setText(s.value("username").toString()); QByteArray password = s.value("password").toByteArray(); - if (password.isEmpty()) ui_->password->setText(""); + if (password.isEmpty()) ui_->password->clear(); else ui_->password->setText(QString::fromUtf8(QByteArray::fromBase64(password))); dialog()->ComboBoxLoadFromSettings(s, ui_->combobox_quality, "quality", "HIGH"); ui_->spinbox_searchdelay->setValue(s.value("searchdelay", 1500).toInt()); diff --git a/src/settings/tidalsettingspage.ui b/src/settings/tidalsettingspage.ui index 7013741e..dc5c3f1f 100644 --- a/src/settings/tidalsettingspage.ui +++ b/src/settings/tidalsettingspage.ui @@ -360,7 +360,7 @@ - :/icons/64x64/tidal.png + :/icons/64x64/tidal.png @@ -383,6 +383,7 @@ + diff --git a/src/transcoder/transcodelogdialog.ui b/src/transcoder/transcodelogdialog.ui index b9c3315c..c416f2b0 100644 --- a/src/transcoder/transcodelogdialog.ui +++ b/src/transcoder/transcodelogdialog.ui @@ -14,7 +14,7 @@ Transcoder Log - + :/icons/64x64/strawberry.png:/icons/64x64/strawberry.png @@ -36,6 +36,7 @@ +