diff --git a/CMakeLists.txt b/CMakeLists.txt index 55d8b73231..73fb66f069 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -708,6 +708,7 @@ set(SOURCES src/settings/coverssettingspage.cpp src/settings/lyricssettingspage.cpp src/settings/networkproxysettingspage.cpp + src/settings/radiosettingspage.cpp src/settings/appearancesettingspage.cpp src/settings/contextsettingspage.cpp src/settings/notificationssettingspage.cpp @@ -780,6 +781,9 @@ set(SOURCES src/radios/radiochannel.cpp src/radios/somafmservice.cpp src/radios/radioparadiseservice.cpp + src/radios/radiobrowserservice.cpp + src/radios/radiobrowsersearchview.cpp + src/radios/radiobrowsersearchmodel.cpp src/radios/radiomimedata.cpp src/scrobbler/audioscrobbler.cpp @@ -1007,6 +1011,7 @@ set(HEADERS src/settings/coverssettingspage.h src/settings/lyricssettingspage.h src/settings/networkproxysettingspage.h + src/settings/radiosettingspage.h src/settings/appearancesettingspage.h src/settings/contextsettingspage.h src/settings/notificationssettingspage.h @@ -1077,6 +1082,9 @@ set(HEADERS src/radios/radiomimedata.h src/radios/somafmservice.h src/radios/radioparadiseservice.h + src/radios/radiobrowserservice.h + src/radios/radiobrowsersearchview.h + src/radios/radiobrowsersearchmodel.h src/scrobbler/audioscrobbler.h src/scrobbler/scrobblersettingsservice.h @@ -1161,6 +1169,7 @@ set(UI src/settings/coverssettingspage.ui src/settings/lyricssettingspage.ui src/settings/networkproxysettingspage.ui + src/settings/radiosettingspage.ui src/settings/appearancesettingspage.ui src/settings/notificationssettingspage.ui src/settings/transcodersettingspage.ui @@ -1189,6 +1198,7 @@ set(UI src/streaming/streamingsearchview.ui src/radios/radioviewcontainer.ui + src/radios/radiobrowsersearchview.ui src/organize/organizedialog.ui src/organize/organizeerrordialog.ui diff --git a/data/icons.qrc b/data/icons.qrc index ebbfe2f3b2..798e0e2e94 100644 --- a/data/icons.qrc +++ b/data/icons.qrc @@ -97,6 +97,7 @@ icons/128x128/radio.png icons/128x128/somafm.png icons/128x128/radioparadise.png + icons/128x128/radiobrowser.png icons/128x128/musicbrainz.png icons/64x64/albums.png icons/64x64/alsa.png @@ -196,6 +197,7 @@ icons/64x64/radio.png icons/64x64/somafm.png icons/64x64/radioparadise.png + icons/64x64/radiobrowser.png icons/64x64/musicbrainz.png icons/48x48/albums.png icons/48x48/alsa.png @@ -299,6 +301,7 @@ icons/48x48/radio.png icons/48x48/somafm.png icons/48x48/radioparadise.png + icons/48x48/radiobrowser.png icons/48x48/musicbrainz.png icons/32x32/albums.png icons/32x32/alsa.png @@ -402,6 +405,7 @@ icons/32x32/radio.png icons/32x32/somafm.png icons/32x32/radioparadise.png + icons/32x32/radiobrowser.png icons/32x32/musicbrainz.png icons/22x22/albums.png icons/22x22/alsa.png @@ -505,6 +509,7 @@ icons/22x22/radio.png icons/22x22/somafm.png icons/22x22/radioparadise.png + icons/22x22/radiobrowser.png icons/22x22/musicbrainz.png diff --git a/data/icons/128x128/radiobrowser.png b/data/icons/128x128/radiobrowser.png new file mode 100644 index 0000000000..eaa960e3c0 Binary files /dev/null and b/data/icons/128x128/radiobrowser.png differ diff --git a/data/icons/22x22/radiobrowser.png b/data/icons/22x22/radiobrowser.png new file mode 100644 index 0000000000..89bf292559 Binary files /dev/null and b/data/icons/22x22/radiobrowser.png differ diff --git a/data/icons/32x32/radiobrowser.png b/data/icons/32x32/radiobrowser.png new file mode 100644 index 0000000000..8c9a0f27e8 Binary files /dev/null and b/data/icons/32x32/radiobrowser.png differ diff --git a/data/icons/48x48/radiobrowser.png b/data/icons/48x48/radiobrowser.png new file mode 100644 index 0000000000..cbf432dd8b Binary files /dev/null and b/data/icons/48x48/radiobrowser.png differ diff --git a/data/icons/64x64/radiobrowser.png b/data/icons/64x64/radiobrowser.png new file mode 100644 index 0000000000..8c542b390a Binary files /dev/null and b/data/icons/64x64/radiobrowser.png differ diff --git a/data/icons/full/radiobrowser.png b/data/icons/full/radiobrowser.png new file mode 100644 index 0000000000..eaa960e3c0 Binary files /dev/null and b/data/icons/full/radiobrowser.png differ diff --git a/src/constants/radiobrowsersettings.h b/src/constants/radiobrowsersettings.h new file mode 100644 index 0000000000..813aa3e5a4 --- /dev/null +++ b/src/constants/radiobrowsersettings.h @@ -0,0 +1,34 @@ +/* + * Strawberry Music Player + * Copyright 2026, Malte Zilinski + * + * 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 RADIOBROWSERSETTINGS_H +#define RADIOBROWSERSETTINGS_H + +namespace RadioBrowserSettings { + +constexpr char kSettingsGroup[] = "RadioBrowser"; +constexpr char kServerUrl[] = "server_url"; +constexpr char kSearchLimit[] = "search_limit"; +constexpr int kSearchLimitDefault = 100; +constexpr char kHideBroken[] = "hide_broken"; +constexpr bool kHideBrokenDefault = true; + +} // namespace RadioBrowserSettings + +#endif // RADIOBROWSERSETTINGS_H diff --git a/src/constants/radioparadisesettings.h b/src/constants/radioparadisesettings.h new file mode 100644 index 0000000000..c71dc82ed7 --- /dev/null +++ b/src/constants/radioparadisesettings.h @@ -0,0 +1,29 @@ +/* + * Strawberry Music Player + * Copyright 2026, Malte Zilinski + * + * 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 RADIOPARADISESETTINGS_H +#define RADIOPARADISESETTINGS_H + +namespace RadioParadiseSettings { + +constexpr char kSettingsGroup[] = "RadioParadise"; + +} // namespace RadioParadiseSettings + +#endif // RADIOPARADISESETTINGS_H diff --git a/src/constants/somafmsettings.h b/src/constants/somafmsettings.h new file mode 100644 index 0000000000..69b32117e5 --- /dev/null +++ b/src/constants/somafmsettings.h @@ -0,0 +1,31 @@ +/* + * Strawberry Music Player + * Copyright 2026, Malte Zilinski + * + * 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 SOMAFMSETTINGS_H +#define SOMAFMSETTINGS_H + +namespace SomaFMSettings { + +constexpr char kSettingsGroup[] = "SomaFM"; +constexpr char kQuality[] = "quality"; +constexpr char kQualityDefault[] = "highest"; + +} // namespace SomaFMSettings + +#endif // SOMAFMSETTINGS_H diff --git a/src/core/mainwindow.cpp b/src/core/mainwindow.cpp index 2103d413a3..ae0210ef6b 100644 --- a/src/core/mainwindow.cpp +++ b/src/core/mainwindow.cpp @@ -191,6 +191,8 @@ #include "radios/radioservices.h" #include "radios/radioviewcontainer.h" +#include "radios/radiobrowserservice.h" +#include "radios/radiobrowsersearchview.h" #include "scrobbler/audioscrobbler.h" #include "scrobbler/lastfmimport.h" @@ -492,6 +494,11 @@ MainWindow::MainWindow(Application *app, radio_view_->view()->setModel(app_->radio_services()->sort_model()); + RadioBrowserService *radio_browser_service = qobject_cast(app_->radio_services()->ServiceBySource(Song::Source::RadioBrowser)); + if (radio_browser_service) { + radio_view_->search_view()->Init(radio_browser_service); + } + // Icons qLog(Debug) << "Creating UI"; @@ -794,6 +801,7 @@ MainWindow::MainWindow(Application *app, QObject::connect(radio_view_, &RadioViewContainer::Refresh, &*app_->radio_services(), &RadioServices::RefreshChannels); QObject::connect(radio_view_->view(), &RadioView::GetChannels, &*app_->radio_services(), &RadioServices::GetChannels); QObject::connect(radio_view_->view(), &RadioView::AddToPlaylistSignal, this, &MainWindow::AddToPlaylist); + QObject::connect(radio_view_->search_view(), &RadioBrowserSearchView::AddToPlaylist, this, &MainWindow::AddToPlaylist); // Playlist menu QObject::connect(playlist_menu_, &QMenu::aboutToHide, this, &MainWindow::PlaylistMenuHidden); diff --git a/src/core/song.cpp b/src/core/song.cpp index 5f0652ad26..1e2fa5ffe2 100644 --- a/src/core/song.cpp +++ b/src/core/song.cpp @@ -686,7 +686,7 @@ const QString &Song::playlist_effective_albumartistsort() const { return is_comp bool Song::is_metadata_good() const { return !d->url_.isEmpty() && !d->artist_.isEmpty() && !d->title_.isEmpty(); } bool Song::is_local_collection_song() const { return d->source_ == Source::Collection; } bool Song::is_linked_collection_song() const { return IsLinkedCollectionSource(d->source_); } -bool Song::is_radio() const { return d->source_ == Source::Stream || d->source_ == Source::SomaFM || d->source_ == Source::RadioParadise; } +bool Song::is_radio() const { return d->source_ == Source::Stream || d->source_ == Source::SomaFM || d->source_ == Source::RadioParadise || d->source_ == Source::RadioBrowser; } bool Song::is_stream_service() const { return d->source_ == Source::Subsonic || d->source_ == Source::Tidal || d->source_ == Source::Qobuz || d->source_ == Source::Spotify; } bool Song::is_stream() const { return is_radio() || is_stream_service(); } bool Song::is_cdda() const { return d->source_ == Source::CDDA; } @@ -1164,6 +1164,7 @@ QString Song::TextForSource(const Source source) { case Source::Qobuz: return u"qobuz"_s; case Source::SomaFM: return u"somafm"_s; case Source::RadioParadise: return u"radioparadise"_s; + case Source::RadioBrowser: return u"radiobrowser"_s; case Source::Unknown: return u"unknown"_s; } return u"unknown"_s; @@ -1184,6 +1185,7 @@ QString Song::DescriptionForSource(const Source source) { case Source::Qobuz: return u"Qobuz"_s; case Source::SomaFM: return u"SomaFM"_s; case Source::RadioParadise: return u"Radio Paradise"_s; + case Source::RadioBrowser: return u"Radio Browser"_s; case Source::Unknown: return u"Unknown"_s; } return u"unknown"_s; @@ -1203,7 +1205,7 @@ Song::Source Song::SourceFromText(const QString &source) { if (source.compare("qobuz"_L1, Qt::CaseInsensitive) == 0) return Source::Qobuz; if (source.compare("somafm"_L1, Qt::CaseInsensitive) == 0) return Source::SomaFM; if (source.compare("radioparadise"_L1, Qt::CaseInsensitive) == 0) return Source::RadioParadise; - + if (source.compare("radiobrowser"_L1, Qt::CaseInsensitive) == 0) return Source::RadioBrowser; return Source::Unknown; } @@ -1222,6 +1224,7 @@ QIcon Song::IconForSource(const Source source) { case Source::Qobuz: return IconLoader::Load(u"qobuz"_s); case Source::SomaFM: return IconLoader::Load(u"somafm"_s); case Source::RadioParadise: return IconLoader::Load(u"radioparadise"_s); + case Source::RadioBrowser: return IconLoader::Load(u"radiobrowser"_s); case Source::Unknown: return IconLoader::Load(u"edit-delete"_s); } return IconLoader::Load(u"edit-delete"_s); @@ -1238,6 +1241,7 @@ QString Song::DomainForSource(const Source source) { case Song::Source::Qobuz: return u"qobuz.com"_s; case Song::Source::SomaFM: return u"somafm.com"_s; case Song::Source::RadioParadise: return u"radioparadise.com"_s; + case Song::Source::RadioBrowser: return u"radio-browser.info"_s; case Song::Source::Spotify: return u"spotify.com"_s; default: return QString(); } @@ -1352,7 +1356,8 @@ QString Song::ShareURL() const { switch (source()) { case Song::Source::Stream: - case Song::Source::SomaFM: return url().toString(); + case Song::Source::SomaFM: + case Song::Source::RadioBrowser: return url().toString(); case Song::Source::Tidal: return "https://tidal.com/track/%1"_L1.arg(song_id()); case Song::Source::Qobuz: return "https://open.qobuz.com/track/%1"_L1.arg(song_id()); case Song::Source::Spotify: return "https://open.spotify.com/track/%1"_L1.arg(song_id()); @@ -1495,6 +1500,7 @@ QString Song::ImageCacheDir(const Source source) { case Source::Stream: case Source::SomaFM: case Source::RadioParadise: + case Source::RadioBrowser: case Source::Unknown: return StandardPaths::WritableLocation(StandardPaths::StandardLocation::AppLocalDataLocation) + u"/albumcovers"_s; } diff --git a/src/core/song.h b/src/core/song.h index d028952ae8..4c7f82fff0 100644 --- a/src/core/song.h +++ b/src/core/song.h @@ -76,7 +76,8 @@ class Song { Qobuz = 8, SomaFM = 9, RadioParadise = 10, - Spotify = 11 + Spotify = 11, + RadioBrowser = 12 }; static const int kSourceCount = 16; diff --git a/src/covermanager/albumcoverchoicecontroller.cpp b/src/covermanager/albumcoverchoicecontroller.cpp index a61ec9bc08..0db89bf6af 100644 --- a/src/covermanager/albumcoverchoicecontroller.cpp +++ b/src/covermanager/albumcoverchoicecontroller.cpp @@ -583,6 +583,7 @@ void AlbumCoverChoiceController::SaveArtManualToSong(Song *song, const QUrl &art case Song::Source::Stream: case Song::Source::RadioParadise: case Song::Source::SomaFM: + case Song::Source::RadioBrowser: case Song::Source::Unknown: break; case Song::Source::Subsonic: diff --git a/src/playlist/playlistitem.cpp b/src/playlist/playlistitem.cpp index fffb69f67b..8e18cda0b1 100644 --- a/src/playlist/playlistitem.cpp +++ b/src/playlist/playlistitem.cpp @@ -57,6 +57,7 @@ PlaylistItemPtr PlaylistItem::NewFromSource(const Song::Source source, const QUu case Song::Source::Stream: case Song::Source::RadioParadise: case Song::Source::SomaFM: + case Song::Source::RadioBrowser: return make_shared(source, uuid); case Song::Source::LocalFile: case Song::Source::CDDA: @@ -82,6 +83,7 @@ PlaylistItemPtr PlaylistItem::NewFromSong(const Song &song) { case Song::Source::Stream: case Song::Source::RadioParadise: case Song::Source::SomaFM: + case Song::Source::RadioBrowser: return make_shared(song); case Song::Source::LocalFile: case Song::Source::CDDA: diff --git a/src/radios/radiobrowsersearchmodel.cpp b/src/radios/radiobrowsersearchmodel.cpp new file mode 100644 index 0000000000..c95d33ba9e --- /dev/null +++ b/src/radios/radiobrowsersearchmodel.cpp @@ -0,0 +1,136 @@ +/* + * Strawberry Music Player + * Copyright 2026, Malte Zilinski + * + * 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 "radiobrowsersearchmodel.h" +#include "radiochannel.h" +#include "radiomimedata.h" + +using namespace Qt::Literals::StringLiterals; + +RadioBrowserSearchModel::RadioBrowserSearchModel(QObject *parent) : QAbstractTableModel(parent) {} + +int RadioBrowserSearchModel::rowCount(const QModelIndex &parent) const { + return parent.isValid() ? 0 : static_cast(channels_.size()); +} + +int RadioBrowserSearchModel::columnCount(const QModelIndex &parent) const { + return parent.isValid() ? 0 : static_cast(Column::ColumnCount); +} + +QVariant RadioBrowserSearchModel::data(const QModelIndex &idx, const int role) const { + + if (!idx.isValid() || idx.row() >= channels_.size()) return QVariant(); + + const Column column = static_cast(idx.column()); + const RadioChannel &channel = channels_.at(idx.row()); + + if (role == Qt::DisplayRole) { + switch (column) { + case Column::Name: return channel.name; + case Column::Country: return channel.country; + case Column::Tags: return channel.tags; + case Column::Codec: return channel.codec; + default: return QVariant(); + } + } + else if (role == Qt::ToolTipRole && column == Column::Name) { + return channel.name; + } + + return QVariant(); + +} + +QVariant RadioBrowserSearchModel::headerData(const int section, const Qt::Orientation orientation, const int role) const { + + if (orientation != Qt::Horizontal || role != Qt::DisplayRole) return QVariant(); + + const Column column = static_cast(section); + switch (column) { + case Column::Name: return tr("Name"); + case Column::Country: return tr("Country"); + case Column::Tags: return tr("Tags"); + case Column::Codec: return tr("Codec"); + default: return QVariant(); + } + +} + +Qt::ItemFlags RadioBrowserSearchModel::flags(const QModelIndex &idx) const { + + if (!idx.isValid()) return Qt::NoItemFlags; + return Qt::ItemIsSelectable | Qt::ItemIsEnabled | Qt::ItemIsDragEnabled; + +} + +QMimeData *RadioBrowserSearchModel::mimeData(const QModelIndexList &indexes) const { + + RadioMimeData *mimedata = new RadioMimeData; + QList urls; + for (const QModelIndex &idx : indexes) { + if (idx.column() != 0) continue; + const RadioChannel &channel = channels_.at(idx.row()); + if (!channel.url.isEmpty()) { + Song song = channel.ToSong(); + urls << song.url(); + mimedata->songs << song; + } + } + if (mimedata->songs.isEmpty()) { + delete mimedata; + return nullptr; + } + mimedata->setUrls(urls); + return mimedata; + +} + +QStringList RadioBrowserSearchModel::mimeTypes() const { + return QStringList() << u"text/uri-list"_s; +} + +void RadioBrowserSearchModel::AddChannels(const RadioChannelList &channels) { + + if (channels.isEmpty()) return; + + const int first = static_cast(channels_.size()); + const int last = first + static_cast(channels.size()) - 1; + beginInsertRows(QModelIndex(), first, last); + channels_.append(channels); + endInsertRows(); + +} + +RadioChannel RadioBrowserSearchModel::ChannelForRow(const int row) const { + if (row < 0 || row >= channels_.size()) return RadioChannel(); + return channels_.at(row); +} + +void RadioBrowserSearchModel::Clear() { + + if (channels_.isEmpty()) return; + + beginResetModel(); + channels_.clear(); + endResetModel(); + +} diff --git a/src/radios/radiobrowsersearchmodel.h b/src/radios/radiobrowsersearchmodel.h new file mode 100644 index 0000000000..e289f10097 --- /dev/null +++ b/src/radios/radiobrowsersearchmodel.h @@ -0,0 +1,61 @@ +/* + * Strawberry Music Player + * Copyright 2026, Malte Zilinski + * + * 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 RADIOBROWSERSEARCHMODEL_H +#define RADIOBROWSERSEARCHMODEL_H + +#include +#include +#include +#include + +#include "radiochannel.h" + +class RadioBrowserSearchModel : public QAbstractTableModel { + Q_OBJECT + + public: + explicit RadioBrowserSearchModel(QObject *parent = nullptr); + + enum class Column { + Name = 0, + Country, + Tags, + Codec, + ColumnCount + }; + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + int columnCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &idx, const int role = Qt::DisplayRole) const override; + QVariant headerData(const int section, const Qt::Orientation orientation, const int role = Qt::DisplayRole) const override; + Qt::ItemFlags flags(const QModelIndex &idx) const override; + + QMimeData *mimeData(const QModelIndexList &indexes) const override; + QStringList mimeTypes() const override; + + void AddChannels(const RadioChannelList &channels); + RadioChannel ChannelForRow(const int row) const; + void Clear(); + + private: + RadioChannelList channels_; +}; + +#endif // RADIOBROWSERSEARCHMODEL_H diff --git a/src/radios/radiobrowsersearchview.cpp b/src/radios/radiobrowsersearchview.cpp new file mode 100644 index 0000000000..e69ca71897 --- /dev/null +++ b/src/radios/radiobrowsersearchview.cpp @@ -0,0 +1,280 @@ +/* + * Strawberry Music Player + * Copyright 2026, Malte Zilinski + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "core/iconloader.h" +#include "core/settings.h" +#include "constants/radiobrowsersettings.h" +#include "widgets/stretchheaderview.h" +#include "radiobrowserservice.h" +#include "radiobrowsersearchview.h" +#include "radiobrowsersearchmodel.h" +#include "radiomimedata.h" +#include "ui_radiobrowsersearchview.h" + +using namespace Qt::Literals::StringLiterals; + +RadioBrowserSearchView::RadioBrowserSearchView(QWidget *parent) + : QWidget(parent), + ui_(new Ui_RadioBrowserSearchView), + service_(nullptr), + model_(new RadioBrowserSearchModel(this)), + search_timer_(new QTimer(this)), + context_menu_(nullptr), + action_add_to_playlist_(nullptr), + current_offset_(0), + search_limit_(100), + has_more_(false), + initialized_(false) { + + ui_->setupUi(this); + + ui_->results->setModel(model_); + + StretchHeaderView *header = new StretchHeaderView(Qt::Horizontal, this); + ui_->results->setHeader(header); + header->SetStretchEnabled(true); + header->SetColumnWidth(static_cast(RadioBrowserSearchModel::Column::Name), 0.5); + header->SetColumnWidth(static_cast(RadioBrowserSearchModel::Column::Country), 0.2); + header->SetColumnWidth(static_cast(RadioBrowserSearchModel::Column::Tags), 0.2); + header->SetColumnWidth(static_cast(RadioBrowserSearchModel::Column::Codec), 0.1); + + ui_->search->setPlaceholderText(tr("Search radio stations...")); + + // Country filter - starts with "All countries", populated dynamically after API fetch + ui_->combo_country->addItem(tr("All countries"), QString()); + + // Sort order + ui_->combo_sort->addItem(tr("By votes"), u"votes"_s); + ui_->combo_sort->addItem(tr("By clicks"), u"clickcount"_s); + ui_->combo_sort->addItem(tr("By name"), u"name"_s); + ui_->combo_sort->addItem(tr("By bitrate"), u"bitrate"_s); + + search_timer_->setSingleShot(true); + search_timer_->setInterval(300); + + QObject::connect(ui_->search, &SearchField::textChanged, this, &RadioBrowserSearchView::TextChanged); + QObject::connect(search_timer_, &QTimer::timeout, this, &RadioBrowserSearchView::SearchTriggered); + QObject::connect(ui_->button_loadmore, &QPushButton::clicked, this, &RadioBrowserSearchView::LoadMore); + QObject::connect(ui_->combo_country, QOverload::of(&QComboBox::currentIndexChanged), this, &RadioBrowserSearchView::CountryChanged); + QObject::connect(ui_->combo_sort, QOverload::of(&QComboBox::currentIndexChanged), this, &RadioBrowserSearchView::SortChanged); + QObject::connect(ui_->results, &QTreeView::doubleClicked, this, &RadioBrowserSearchView::ItemDoubleClicked); + ui_->results->setContextMenuPolicy(Qt::CustomContextMenu); + QObject::connect(ui_->results, &QTreeView::customContextMenuRequested, this, &RadioBrowserSearchView::ShowContextMenu); + +} + +RadioBrowserSearchView::~RadioBrowserSearchView() { + + if (service_) { + QObject::disconnect(service_, nullptr, this, nullptr); + } + delete ui_; + +} + +void RadioBrowserSearchView::showEvent(QShowEvent *e) { + + Q_UNUSED(e) + + if (!initialized_ && service_) { + service_->FetchCountries(); + initialized_ = true; + } + +} + +void RadioBrowserSearchView::Init(RadioBrowserService *service) { + + service_ = service; + QObject::connect(service_, &RadioBrowserService::SearchFinished, this, &RadioBrowserSearchView::SearchFinished); + QObject::connect(service_, &RadioBrowserService::SearchError, this, &RadioBrowserSearchView::SearchError); + QObject::connect(service_, &RadioBrowserService::CountriesLoaded, this, &RadioBrowserSearchView::CountriesLoaded); + + // Load defaults from settings + Settings s; + s.beginGroup(QLatin1String(RadioBrowserSettings::kSettingsGroup)); + search_limit_ = s.value(QLatin1String(RadioBrowserSettings::kSearchLimit), RadioBrowserSettings::kSearchLimitDefault).toInt(); + + const QString default_sort = s.value(u"default_sort"_s, u"votes"_s).toString(); + for (int i = 0; i < ui_->combo_sort->count(); ++i) { + if (ui_->combo_sort->itemData(i).toString() == default_sort) { + ui_->combo_sort->setCurrentIndex(i); + break; + } + } + + default_country_ = s.value(u"default_country"_s).toString(); + s.endGroup(); + +} + +void RadioBrowserSearchView::TextChanged(const QString &text) { + + Q_UNUSED(text) + search_timer_->start(); + +} + +void RadioBrowserSearchView::SearchTriggered() { + + current_offset_ = 0; + model_->Clear(); + DoSearch(); + +} + +void RadioBrowserSearchView::DoSearch() { + + if (!service_) return; + + const QString query = ui_->search->text().trimmed(); + const QString country = ui_->combo_country->currentData().toString(); + const QString order = ui_->combo_sort->currentData().toString(); + + ui_->label_status->setText(tr("Searching...")); + ui_->stacked->setCurrentWidget(ui_->page_results); + + service_->Search(query, country, QString(), QString(), order, search_limit_, current_offset_); + +} + +void RadioBrowserSearchView::SearchFinished(const RadioChannelList &channels, const bool has_more) { + + has_more_ = has_more; + ui_->button_loadmore->setVisible(has_more); + + if (channels.isEmpty() && current_offset_ == 0) { + ui_->label_status->setText(tr("No stations found.")); + return; + } + + ui_->label_status->setText(tr("%1 stations found").arg(model_->rowCount() + channels.size())); + + model_->AddChannels(channels); + +} + +void RadioBrowserSearchView::SearchError(const QString &error) { + + ui_->label_status->setText(error); + +} + +void RadioBrowserSearchView::CountriesLoaded(const QList> &countries) { + + ui_->combo_country->clear(); + ui_->combo_country->addItem(tr("All countries"), QString()); + + for (const QPair &entry : countries) { + ui_->combo_country->addItem(entry.first, entry.second); + } + + // Restore saved default country + if (!default_country_.isEmpty()) { + for (int i = 0; i < ui_->combo_country->count(); ++i) { + if (ui_->combo_country->itemData(i).toString() == default_country_) { + ui_->combo_country->setCurrentIndex(i); + break; + } + } + } + +} + +void RadioBrowserSearchView::LoadMore() { + + current_offset_ += search_limit_; + DoSearch(); + +} + +void RadioBrowserSearchView::CountryChanged(const int index) { + + Q_UNUSED(index) + + if (service_) SearchTriggered(); + +} + +void RadioBrowserSearchView::SortChanged(const int index) { + + Q_UNUSED(index) + + if (service_) SearchTriggered(); + +} + +void RadioBrowserSearchView::ItemDoubleClicked(const QModelIndex &index) { + + if (!index.isValid()) return; + + const RadioChannel channel = model_->ChannelForRow(index.row()); + if (channel.url.isEmpty()) return; + + RadioMimeData *mimedata = new RadioMimeData; + mimedata->songs << channel.ToSong(); + Q_EMIT AddToPlaylist(mimedata); + +} + +void RadioBrowserSearchView::AddSelectedToPlaylist() { + + const QModelIndexList selected = ui_->results->selectionModel()->selectedRows(static_cast(RadioBrowserSearchModel::Column::Name)); + if (selected.isEmpty()) return; + + RadioMimeData *mimedata = new RadioMimeData; + for (const QModelIndex &idx : selected) { + const RadioChannel channel = model_->ChannelForRow(idx.row()); + if (!channel.url.isEmpty()) { + mimedata->songs << channel.ToSong(); + } + } + if (!mimedata->songs.isEmpty()) { + Q_EMIT AddToPlaylist(mimedata); + } + else { + delete mimedata; + } + +} + +void RadioBrowserSearchView::ShowContextMenu(const QPoint &pos) { + + if (!context_menu_) { + context_menu_ = new QMenu(this); + + action_add_to_playlist_ = new QAction(IconLoader::Load(u"media-playback-start"_s), tr("Append to current playlist"), this); + QObject::connect(action_add_to_playlist_, &QAction::triggered, this, &RadioBrowserSearchView::AddSelectedToPlaylist); + context_menu_->addAction(action_add_to_playlist_); + } + + const bool has_selection = !ui_->results->selectionModel()->selectedRows().isEmpty(); + action_add_to_playlist_->setEnabled(has_selection); + + context_menu_->popup(ui_->results->viewport()->mapToGlobal(pos)); + +} diff --git a/src/radios/radiobrowsersearchview.h b/src/radios/radiobrowsersearchview.h new file mode 100644 index 0000000000..ca85b3b2e9 --- /dev/null +++ b/src/radios/radiobrowsersearchview.h @@ -0,0 +1,86 @@ +/* + * Strawberry Music Player + * Copyright 2026, Malte Zilinski + * + * 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 RADIOBROWSERSEARCHVIEW_H +#define RADIOBROWSERSEARCHVIEW_H + +#include +#include +#include + +#include "radiochannel.h" + +class QTimer; +class QMimeData; +class QMenu; +class QAction; +class QShowEvent; +class QContextMenuEvent; + +class RadioBrowserSearchModel; +class RadioBrowserService; + +class Ui_RadioBrowserSearchView; + +class RadioBrowserSearchView : public QWidget { + Q_OBJECT + + public: + explicit RadioBrowserSearchView(QWidget *parent = nullptr); + ~RadioBrowserSearchView(); + + void Init(RadioBrowserService *service); + + protected: + void showEvent(QShowEvent *e) override; + + Q_SIGNALS: + void AddToPlaylist(QMimeData *mimedata); + + private Q_SLOTS: + void TextChanged(const QString &text); + void SearchTriggered(); + void SearchFinished(const RadioChannelList &channels, const bool has_more); + void SearchError(const QString &error); + void LoadMore(); + void CountryChanged(const int index); + void SortChanged(const int index); + void CountriesLoaded(const QList> &countries); + void AddSelectedToPlaylist(); + void ItemDoubleClicked(const QModelIndex &index); + void ShowContextMenu(const QPoint &pos); + + private: + void DoSearch(); + + Ui_RadioBrowserSearchView *ui_; + RadioBrowserService *service_; + RadioBrowserSearchModel *model_; + QTimer *search_timer_; + QMenu *context_menu_; + QAction *action_add_to_playlist_; + + QString default_country_; + int current_offset_; + int search_limit_; + bool has_more_; + bool initialized_; +}; + +#endif // RADIOBROWSERSEARCHVIEW_H diff --git a/src/radios/radiobrowsersearchview.ui b/src/radios/radiobrowsersearchview.ui new file mode 100644 index 0000000000..3accce60ca --- /dev/null +++ b/src/radios/radiobrowsersearchview.ui @@ -0,0 +1,181 @@ + + + RadioBrowserSearchView + + + + 0 + 0 + 400 + 500 + + + + + 2 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + 4 + + + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + + + + + + + + + + Qt::AlignCenter + + + + + + + 1 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QAbstractItemView::NoEditTriggers + + + true + + + QAbstractItemView::DragOnly + + + QAbstractItemView::ExtendedSelection + + + true + + + false + + + true + + + true + + + true + + + + + + + Load more... + + + false + + + + + + + + + + + Search for radio stations using radio-browser.info + + + Qt::AlignCenter + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + SearchField + QWidget +
widgets/searchfield.h
+
+
+ + +
diff --git a/src/radios/radiobrowserservice.cpp b/src/radios/radiobrowserservice.cpp new file mode 100644 index 0000000000..78a7c13d24 --- /dev/null +++ b/src/radios/radiobrowserservice.cpp @@ -0,0 +1,339 @@ +/* + * Strawberry Music Player + * Copyright 2026, Malte Zilinski + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/networkaccessmanager.h" +#include "core/taskmanager.h" +#include "core/iconloader.h" +#include "settings/radiosettingspage.h" +#include "radiobrowserservice.h" +#include "radiochannel.h" + +using namespace Qt::Literals::StringLiterals; + +const QStringList RadioBrowserService::kFallbackServers = { + u"de1.api.radio-browser.info"_s, + u"de2.api.radio-browser.info"_s, + u"nl1.api.radio-browser.info"_s, + u"at1.api.radio-browser.info"_s +}; + +RadioBrowserService::RadioBrowserService(const SharedPtr task_manager, const SharedPtr network, QObject *parent) + : RadioService(Song::Source::RadioBrowser, u"Radio Browser"_s, IconLoader::Load(u"radiobrowser"_s), task_manager, network, parent), + dns_lookup_(nullptr), + server_discovered_(false), + has_pending_search_(false), + has_pending_countries_(false), + fallback_index_(0) {} + +RadioBrowserService::~RadioBrowserService() { + Abort(); +} + +QUrl RadioBrowserService::Homepage() { return QUrl(u"https://www.radio-browser.info/"_s); } +QUrl RadioBrowserService::Donate() { return QUrl(u"https://www.radio-browser.info/"_s); } + +void RadioBrowserService::Abort() { + + while (!replies_.isEmpty()) { + QNetworkReply *reply = replies_.takeFirst(); + QObject::disconnect(reply, nullptr, this, nullptr); + if (reply->isRunning()) reply->abort(); + reply->deleteLater(); + } + + if (dns_lookup_) { + dns_lookup_->abort(); + dns_lookup_->deleteLater(); + dns_lookup_ = nullptr; + } + + has_pending_search_ = false; + has_pending_countries_ = false; + +} + +void RadioBrowserService::GetChannels() { + // RadioBrowser has 50,000+ stations. We don't load them all. + // Only emit empty list so the model doesn't break on refresh. + Q_EMIT NewChannels(); +} + +void RadioBrowserService::DiscoverServer() { + + if (dns_lookup_) { + dns_lookup_->abort(); + dns_lookup_->deleteLater(); + dns_lookup_ = nullptr; + } + + fallback_index_ = 0; + + dns_lookup_ = new QDnsLookup(QDnsLookup::A, u"all.api.radio-browser.info"_s, this); + QObject::connect(dns_lookup_, &QDnsLookup::finished, this, &RadioBrowserService::DnsLookupFinished); + dns_lookup_->lookup(); + +} + +void RadioBrowserService::DnsLookupFinished() { + + if (!dns_lookup_) return; + + if (dns_lookup_->error() != QDnsLookup::NoError || dns_lookup_->hostAddressRecords().isEmpty()) { + // DNS failed, try fallback servers + dns_lookup_->deleteLater(); + dns_lookup_ = nullptr; + + if (fallback_index_ < kFallbackServers.size()) { + TestServer(kFallbackServers.at(fallback_index_)); + ++fallback_index_; + } + else { + Q_EMIT SearchError(tr("Failed to discover Radio Browser server.")); + } + return; + } + + // Use first resolved hostname + const auto records = dns_lookup_->hostAddressRecords(); + dns_lookup_->deleteLater(); + dns_lookup_ = nullptr; + + // Try fallback servers since we can't easily do reverse DNS in Qt + // The DNS lookup confirms the service exists, now use a known hostname + fallback_index_ = 0; + TestServer(kFallbackServers.at(fallback_index_)); + +} + +void RadioBrowserService::TestServer(const QString &hostname) { + + QUrl url; + url.setScheme(u"https"_s); + url.setHost(hostname); + url.setPath(u"/json/stats"_s); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, u"application/json"_s); + QNetworkReply *reply = network_->get(request); + replies_ << reply; + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { ServerTestReply(reply); }); + +} + +void RadioBrowserService::ServerTestReply(QNetworkReply *reply) { + + if (replies_.contains(reply)) replies_.removeAll(reply); + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + // Try next fallback + ++fallback_index_; + if (fallback_index_ < kFallbackServers.size()) { + TestServer(kFallbackServers.at(fallback_index_)); + } + else { + Q_EMIT SearchError(tr("No Radio Browser server available.")); + } + return; + } + + // Server works + QUrl url; + url.setScheme(u"https"_s); + url.setHost(reply->url().host()); + server_url_ = url; + server_discovered_ = true; + + // Execute pending search if any + if (has_pending_search_) { + has_pending_search_ = false; + Search(pending_search_.query, pending_search_.country, pending_search_.tag, pending_search_.language, pending_search_.order, pending_search_.limit, pending_search_.offset); + } + + // Execute pending countries fetch if any + if (has_pending_countries_) { + has_pending_countries_ = false; + FetchCountries(); + } + +} + +void RadioBrowserService::Search(const QString &query, + const QString &country, + const QString &tag, + const QString &language, + const QString &order, + const int limit, + const int offset) { + + if (!server_discovered_) { + // Save search and discover server first + pending_search_ = {query, country, tag, language, order, limit, offset}; + has_pending_search_ = true; + DiscoverServer(); + return; + } + + QUrl url(server_url_); + url.setPath(u"/json/stations/search"_s); + + QUrlQuery url_query; + if (!query.isEmpty()) url_query.addQueryItem(u"name"_s, query); + if (!country.isEmpty()) url_query.addQueryItem(u"countrycode"_s, country); + if (!tag.isEmpty()) url_query.addQueryItem(u"tag"_s, tag); + if (!language.isEmpty()) url_query.addQueryItem(u"language"_s, language); + url_query.addQueryItem(u"limit"_s, QString::number(limit)); + url_query.addQueryItem(u"offset"_s, QString::number(offset)); + url_query.addQueryItem(u"hidebroken"_s, u"true"_s); + + if (!order.isEmpty()) { + url_query.addQueryItem(u"order"_s, order); + if (order != u"name"_s) { + url_query.addQueryItem(u"reverse"_s, u"true"_s); + } + } + else { + url_query.addQueryItem(u"order"_s, u"votes"_s); + url_query.addQueryItem(u"reverse"_s, u"true"_s); + } + + url.setQuery(url_query); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, u"application/json"_s); + QNetworkReply *reply = network_->get(request); + replies_ << reply; + const int task_id = task_manager_->StartTask(tr("Searching Radio Browser")); + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, task_id, limit]() { SearchReply(reply, task_id, limit); }); + +} + +void RadioBrowserService::SearchReply(QNetworkReply *reply, const int task_id, const int limit) { + + if (replies_.contains(reply)) replies_.removeAll(reply); + reply->deleteLater(); + + const QJsonArray array = ExtractJsonArray(reply); + task_manager_->SetTaskFinished(task_id); + + if (array.isEmpty()) { + Q_EMIT SearchFinished(RadioChannelList(), false); + return; + } + + RadioChannelList channels; + for (const QJsonValue &value : array) { + if (!value.isObject()) continue; + const QJsonObject obj = value.toObject(); + + const QString name = obj["name"_L1].toString().trimmed(); + if (name.isEmpty()) continue; + + // Prefer url_resolved over url + QString stream_url = obj["url_resolved"_L1].toString(); + if (stream_url.isEmpty()) stream_url = obj["url"_L1].toString(); + if (stream_url.isEmpty()) continue; + + RadioChannel channel; + channel.source = source_; + channel.name = name; + channel.url.setUrl(stream_url); + channel.country = obj["country"_L1].toString().trimmed(); + channel.tags = obj["tags"_L1].toString().trimmed(); + channel.codec = obj["codec"_L1].toString().trimmed(); + + const QString favicon = obj["favicon"_L1].toString(); + if (!favicon.isEmpty()) { + channel.thumbnail_url.setUrl(favicon); + } + + channels << channel; + } + + const bool has_more = (array.size() == limit); + Q_EMIT SearchFinished(channels, has_more); + +} + +void RadioBrowserService::FetchCountries() { + + if (!server_discovered_) { + has_pending_countries_ = true; + if (!has_pending_search_) { + DiscoverServer(); + } + return; + } + + QUrl url(server_url_); + url.setPath(u"/json/countrycodes"_s); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, u"application/json"_s); + QNetworkReply *reply = network_->get(request); + replies_ << reply; + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { CountriesReply(reply); }); + +} + +void RadioBrowserService::CountriesReply(QNetworkReply *reply) { + + if (replies_.contains(reply)) replies_.removeAll(reply); + reply->deleteLater(); + + const QJsonArray array = ExtractJsonArray(reply); + + // Collect country codes that have stations + QSet codes_with_stations; + for (const QJsonValue &value : array) { + if (!value.isObject()) continue; + const QJsonObject obj = value.toObject(); + const QString code = obj["name"_L1].toString().toUpper(); + const int count = obj["stationcount"_L1].toInt(); + if (!code.isEmpty() && count > 0) { + codes_with_stations.insert(code); + } + } + + // Filter the full country list to only include countries with stations + const QList> all_countries = RadioSettingsPage::CountryList(); + QList> countries; + for (const QPair &entry : all_countries) { + if (codes_with_stations.contains(entry.second)) { + countries << entry; + } + } + + Q_EMIT CountriesLoaded(countries); + +} diff --git a/src/radios/radiobrowserservice.h b/src/radios/radiobrowserservice.h new file mode 100644 index 0000000000..77c02845a1 --- /dev/null +++ b/src/radios/radiobrowserservice.h @@ -0,0 +1,101 @@ +/* + * Strawberry Music Player + * Copyright 2026, Malte Zilinski + * + * 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 RADIOBROWSERSERVICE_H +#define RADIOBROWSERSERVICE_H + +#include +#include +#include +#include +#include + +#include "radioservice.h" +#include "radiochannel.h" + +class QNetworkReply; +class QDnsLookup; + +class TaskManager; +class NetworkAccessManager; + +class RadioBrowserService : public RadioService { + Q_OBJECT + + public: + explicit RadioBrowserService(const SharedPtr task_manager, const SharedPtr network, QObject *parent = nullptr); + ~RadioBrowserService(); + + QUrl Homepage() override; + QUrl Donate() override; + + void Abort(); + + void Search(const QString &query, + const QString &country = QString(), + const QString &tag = QString(), + const QString &language = QString(), + const QString &order = QString(), + const int limit = 100, + const int offset = 0); + + void FetchCountries(); + + Q_SIGNALS: + void SearchFinished(const RadioChannelList &channels, const bool has_more); + void SearchError(const QString &error); + void CountriesLoaded(const QList> &countries); + + public Q_SLOTS: + void GetChannels() override; + + private Q_SLOTS: + void DnsLookupFinished(); + void ServerTestReply(QNetworkReply *reply); + void SearchReply(QNetworkReply *reply, const int task_id, const int limit); + void CountriesReply(QNetworkReply *reply); + + private: + void DiscoverServer(); + void TestServer(const QString &hostname); + + QList replies_; + QDnsLookup *dns_lookup_; + QUrl server_url_; + bool server_discovered_; + + // Pending search to execute after server discovery + struct PendingSearch { + QString query; + QString country; + QString tag; + QString language; + QString order; + int limit; + int offset; + }; + PendingSearch pending_search_; + bool has_pending_search_; + bool has_pending_countries_; + + static const QStringList kFallbackServers; + int fallback_index_; +}; + +#endif // RADIOBROWSERSERVICE_H diff --git a/src/radios/radiochannel.h b/src/radios/radiochannel.h index 55ad45a776..c0e146332e 100644 --- a/src/radios/radiochannel.h +++ b/src/radios/radiochannel.h @@ -21,6 +21,7 @@ #define RADIOCHANNEL_H #include +#include #include #include #include @@ -34,11 +35,26 @@ struct RadioChannel { QString name; QUrl url; QUrl thumbnail_url; + QString country; + QString tags; + QString codec; Song ToSong() const; }; using RadioChannelList = QList; +inline QDataStream &operator<<(QDataStream &stream, const RadioChannel &channel) { + stream << static_cast(channel.source) << channel.name << channel.url << channel.thumbnail_url << channel.country << channel.tags << channel.codec; + return stream; +} + +inline QDataStream &operator>>(QDataStream &stream, RadioChannel &channel) { + int source = 0; + stream >> source >> channel.name >> channel.url >> channel.thumbnail_url >> channel.country >> channel.tags >> channel.codec; + channel.source = static_cast(source); + return stream; +} + Q_DECLARE_METATYPE(RadioChannel) Q_DECLARE_METATYPE(RadioChannelList) diff --git a/src/radios/radiomodel.h b/src/radios/radiomodel.h index 68b4ec52f6..129bc2ae39 100644 --- a/src/radios/radiomodel.h +++ b/src/radios/radiomodel.h @@ -70,6 +70,7 @@ class RadioModel : public SimpleTreeModel { void Reset(); void AddChannels(const RadioChannelList &channels); + QList sources() const { return container_nodes_.keys(); } private: bool IsPlayable(const QModelIndex &idx) const; diff --git a/src/radios/radioservice.cpp b/src/radios/radioservice.cpp index 3195d85c95..6b6f7429d4 100644 --- a/src/radios/radioservice.cpp +++ b/src/radios/radioservice.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include "includes/shared_ptr.h" #include "core/logging.h" @@ -99,6 +100,40 @@ QJsonObject RadioService::ExtractJsonObj(QNetworkReply *reply) { } +QJsonArray RadioService::ExtractJsonArray(const QByteArray &data) { + + if (data.isEmpty()) { + return QJsonArray(); + } + + QJsonParseError json_error; + const QJsonDocument json_document = QJsonDocument::fromJson(data, &json_error); + + if (json_error.error != QJsonParseError::NoError) { + Error(QStringLiteral("Failed to parse Json data from %1: %2").arg(name_, json_error.errorString())); + return QJsonArray(); + } + + if (json_document.isEmpty()) { + Error(QStringLiteral("%1: Received empty Json document.").arg(name_), data); + return QJsonArray(); + } + + if (!json_document.isArray()) { + Error(QStringLiteral("%1: Json document is not an array.").arg(name_), json_document); + return QJsonArray(); + } + + return json_document.array(); + +} + +QJsonArray RadioService::ExtractJsonArray(QNetworkReply *reply) { + + return ExtractJsonArray(ExtractData(reply)); + +} + void RadioService::Error(const QString &error, const QVariant &debug) { qLog(Error) << name_ << error; diff --git a/src/radios/radioservice.h b/src/radios/radioservice.h index 34eba9d85d..87592854dc 100644 --- a/src/radios/radioservice.h +++ b/src/radios/radioservice.h @@ -29,6 +29,7 @@ #include #include #include +#include #include "includes/shared_ptr.h" #include "core/song.h" @@ -68,6 +69,8 @@ class RadioService : public QObject { QByteArray ExtractData(QNetworkReply *reply); QJsonObject ExtractJsonObj(const QByteArray &data); QJsonObject ExtractJsonObj(QNetworkReply *reply); + QJsonArray ExtractJsonArray(const QByteArray &data); + QJsonArray ExtractJsonArray(QNetworkReply *reply); void Error(const QString &error, const QVariant &debug = QVariant()); protected: diff --git a/src/radios/radioservices.cpp b/src/radios/radioservices.cpp index a86481db71..dd9f634407 100644 --- a/src/radios/radioservices.cpp +++ b/src/radios/radioservices.cpp @@ -33,6 +33,7 @@ #include "radiochannel.h" #include "somafmservice.h" #include "radioparadiseservice.h" +#include "radiobrowserservice.h" using std::make_shared; @@ -61,6 +62,7 @@ RadioServices::RadioServices(const SharedPtr task_manager, AddService(new SomaFMService(task_manager, network_, this)); AddService(new RadioParadiseService(task_manager, network_, this)); + AddService(new RadioBrowserService(task_manager, network_, this)); } @@ -135,6 +137,7 @@ void RadioServices::GotChannelsFromBackend(const RadioChannelList &channels) { } else { model_->AddChannels(channels); + channels_refresh_ = false; } } diff --git a/src/radios/radioview.cpp b/src/radios/radioview.cpp index aaa0285ece..e39f8fb139 100644 --- a/src/radios/radioview.cpp +++ b/src/radios/radioview.cpp @@ -25,11 +25,13 @@ #include #include #include +#include #include #include "core/mimedata.h" #include "core/iconloader.h" #include "radiomodel.h" +#include "radioitem.h" #include "radioview.h" #include "radioservice.h" #include "radiomimedata.h" @@ -107,6 +109,23 @@ void RadioView::contextMenuEvent(QContextMenuEvent *e) { } +void RadioView::mouseDoubleClickEvent(QMouseEvent *event) { + + const QModelIndex idx = indexAt(event->pos()); + if (idx.isValid()) { + const RadioItem::Type type = idx.data(RadioModel::Role_Type).value(); + if (type == RadioItem::Type::Service) { + // Service node: only expand/collapse, don't add all channels to playlist + setExpanded(idx, !isExpanded(idx)); + event->accept(); + return; + } + } + + AutoExpandingTreeView::mouseDoubleClickEvent(event); + +} + void RadioView::AddToPlaylist() { const QModelIndexList selected_indexes = selectedIndexes(); diff --git a/src/radios/radioview.h b/src/radios/radioview.h index 6e8b9513a7..1187276ff7 100644 --- a/src/radios/radioview.h +++ b/src/radios/radioview.h @@ -28,6 +28,7 @@ class QMimeData; class QMenu; class QAction; class QShowEvent; +class QMouseEvent; class QContextMenuEvent; class RadioModel; @@ -41,6 +42,7 @@ class RadioView : public AutoExpandingTreeView { void showEvent(QShowEvent *e) override; void contextMenuEvent(QContextMenuEvent *e) override; + void mouseDoubleClickEvent(QMouseEvent *event) override; Q_SIGNALS: void GetChannels(); diff --git a/src/radios/radioviewcontainer.h b/src/radios/radioviewcontainer.h index 3f2a4c7b84..b4069619c3 100644 --- a/src/radios/radioviewcontainer.h +++ b/src/radios/radioviewcontainer.h @@ -25,6 +25,7 @@ #include "ui_radioviewcontainer.h" class RadioView; +class RadioBrowserSearchView; class RadioViewContainer : public QWidget { Q_OBJECT @@ -36,6 +37,7 @@ class RadioViewContainer : public QWidget { void ReloadSettings(); RadioView *view() const { return ui_->view; } + RadioBrowserSearchView *search_view() const { return ui_->search_view; } Q_SIGNALS: void Refresh(); diff --git a/src/radios/radioviewcontainer.ui b/src/radios/radioviewcontainer.ui index de107c201d..b7131a1cbb 100644 --- a/src/radios/radioviewcontainer.ui +++ b/src/radios/radioviewcontainer.ui @@ -30,70 +30,104 @@ 0 - - - - - true - - - + + + 0 + + + + Channels + + + + 0 - - - 22 - 22 - + + 0 - - true + + 0 - - - - - - Qt::Horizontal + + 0 - - - 40 - 20 - + + 0 - - - - - - - - false - - - true - - - QAbstractItemView::DragDrop - - - true - - - - 16 - 16 - - - - true - - - true - - - true - + + + + + + true + + + + + + + 22 + 22 + + + + true + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + false + + + true + + + QAbstractItemView::DragDrop + + + true + + + + 16 + 16 + + + + true + + + true + + + true + + + + + + + + Radio Browser + + @@ -104,6 +138,11 @@ QTreeView
radios/radioview.h
+ + RadioBrowserSearchView + QWidget +
radios/radiobrowsersearchview.h
+
diff --git a/src/radios/somafmservice.cpp b/src/radios/somafmservice.cpp index 8c63081ee9..a8ce1bce23 100644 --- a/src/radios/somafmservice.cpp +++ b/src/radios/somafmservice.cpp @@ -31,6 +31,8 @@ #include "core/networkaccessmanager.h" #include "core/taskmanager.h" #include "core/iconloader.h" +#include "core/settings.h" +#include "constants/somafmsettings.h" #include "playlistparsers/playlistparser.h" #include "somafmservice.h" #include "radiochannel.h" @@ -97,6 +99,11 @@ void SomaFMService::GetChannelsReply(QNetworkReply *reply, const int task_id) { } const QJsonArray array_channels = object["channels"_L1].toArray(); + Settings s; + s.beginGroup(QLatin1String(SomaFMSettings::kSettingsGroup)); + const QString preferred_quality = s.value(QLatin1String(SomaFMSettings::kQuality), QLatin1String(SomaFMSettings::kQualityDefault)).toString(); + s.endGroup(); + RadioChannelList channels; for (const QJsonValue &value_channel : array_channels) { if (!value_channel.isObject()) continue; @@ -115,7 +122,7 @@ void SomaFMService::GetChannelsReply(QNetworkReply *reply, const int task_id) { } RadioChannel channel; QString quality = obj_playlist["quality"_L1].toString(); - if (quality != "highest"_L1) continue; + if (quality != preferred_quality) continue; channel.source = source_; channel.name = name; channel.url.setUrl(obj_playlist["url"_L1].toString()); diff --git a/src/settings/radiosettingspage.cpp b/src/settings/radiosettingspage.cpp new file mode 100644 index 0000000000..9916400a3e --- /dev/null +++ b/src/settings/radiosettingspage.cpp @@ -0,0 +1,152 @@ +/* + * Strawberry Music Player + * Copyright 2026, Malte Zilinski + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "settingsdialog.h" +#include "radiosettingspage.h" +#include "ui_radiosettingspage.h" +#include "core/iconloader.h" +#include "core/settings.h" +#include "constants/somafmsettings.h" +#include "constants/radiobrowsersettings.h" + +using namespace Qt::Literals::StringLiterals; + +QList> RadioSettingsPage::CountryList() { + + QSet seen; + QList> countries; + + const QList locales = QLocale::matchingLocales(QLocale::AnyLanguage, QLocale::AnyScript, QLocale::AnyTerritory); + for (const QLocale &locale : locales) { + const QLocale::Territory territory = locale.territory(); + if (territory == QLocale::AnyTerritory || seen.contains(territory)) continue; + seen.insert(territory); + + const QString locale_name = locale.name(); + const int underscore = locale_name.lastIndexOf(u'_'); + if (underscore < 0) continue; + const QString code = locale_name.mid(underscore + 1); + if (code.length() != 2) continue; + + countries << qMakePair(QLocale::territoryToString(territory), code); + } + + std::sort(countries.begin(), countries.end(), [](const QPair &a, const QPair &b) { + return a.first.compare(b.first, Qt::CaseInsensitive) < 0; + }); + + return countries; + +} + +void RadioSettingsPage::PopulateCountries(QComboBox *combo) { + + combo->addItem(QCoreApplication::translate("RadioSettingsPage", "All countries"), QString()); + + const QList> countries = CountryList(); + for (const QPair &entry : countries) { + combo->addItem(entry.first, entry.second); + } + +} + +RadioSettingsPage::RadioSettingsPage(SettingsDialog *dialog, QWidget *parent) + : SettingsPage(dialog, parent), + ui_(new Ui_RadioSettingsPage) { + + ui_->setupUi(this); + setWindowIcon(IconLoader::Load(u"radio"_s, true, 0, 32)); + + // SomaFM quality options + ui_->combo_somafm_quality->addItem(tr("Highest"), u"highest"_s); + ui_->combo_somafm_quality->addItem(tr("High"), u"high"_s); + ui_->combo_somafm_quality->addItem(tr("Low"), u"low"_s); + + // Radio Browser sort options + ui_->combo_default_sort->addItem(tr("By votes"), u"votes"_s); + ui_->combo_default_sort->addItem(tr("By clicks"), u"clickcount"_s); + ui_->combo_default_sort->addItem(tr("By name"), u"name"_s); + ui_->combo_default_sort->addItem(tr("By bitrate"), u"bitrate"_s); + + // Radio Browser country options + PopulateCountries(ui_->combo_default_country); + +} + +RadioSettingsPage::~RadioSettingsPage() { delete ui_; } + +void RadioSettingsPage::Load() { + + // SomaFM + { + Settings s; + s.beginGroup(QLatin1String(SomaFMSettings::kSettingsGroup)); + ComboBoxLoadFromSettings(s, ui_->combo_somafm_quality, QLatin1String(SomaFMSettings::kQuality), QLatin1String(SomaFMSettings::kQualityDefault)); + s.endGroup(); + } + + // Radio Browser + { + Settings s; + s.beginGroup(QLatin1String(RadioBrowserSettings::kSettingsGroup)); + ui_->spin_search_limit->setValue(s.value(QLatin1String(RadioBrowserSettings::kSearchLimit), RadioBrowserSettings::kSearchLimitDefault).toInt()); + ui_->check_hide_broken->setChecked(s.value(QLatin1String(RadioBrowserSettings::kHideBroken), RadioBrowserSettings::kHideBrokenDefault).toBool()); + ComboBoxLoadFromSettings(s, ui_->combo_default_sort, u"default_sort"_s, u"votes"_s); + ComboBoxLoadFromSettings(s, ui_->combo_default_country, u"default_country"_s, QString()); + s.endGroup(); + } + + Init(ui_->layout_radiosettingspage->parentWidget()); + +} + +void RadioSettingsPage::Save() { + + // SomaFM + { + Settings s; + s.beginGroup(QLatin1String(SomaFMSettings::kSettingsGroup)); + s.setValue(QLatin1String(SomaFMSettings::kQuality), ui_->combo_somafm_quality->currentData().toString()); + s.endGroup(); + } + + // Radio Browser + { + Settings s; + s.beginGroup(QLatin1String(RadioBrowserSettings::kSettingsGroup)); + s.setValue(QLatin1String(RadioBrowserSettings::kSearchLimit), ui_->spin_search_limit->value()); + s.setValue(QLatin1String(RadioBrowserSettings::kHideBroken), ui_->check_hide_broken->isChecked()); + s.setValue(u"default_sort"_s, ui_->combo_default_sort->currentData().toString()); + s.setValue(u"default_country"_s, ui_->combo_default_country->currentData().toString()); + s.endGroup(); + } + +} diff --git a/src/settings/radiosettingspage.h b/src/settings/radiosettingspage.h new file mode 100644 index 0000000000..2e835192d1 --- /dev/null +++ b/src/settings/radiosettingspage.h @@ -0,0 +1,51 @@ +/* + * Strawberry Music Player + * Copyright 2026, Malte Zilinski + * + * 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 RADIOSETTINGSPAGE_H +#define RADIOSETTINGSPAGE_H + +#include +#include +#include +#include + +#include "settings/settingspage.h" + +class QComboBox; +class SettingsDialog; +class Ui_RadioSettingsPage; + +class RadioSettingsPage : public SettingsPage { + Q_OBJECT + + public: + explicit RadioSettingsPage(SettingsDialog *dialog, QWidget *parent = nullptr); + ~RadioSettingsPage() override; + + static QList> CountryList(); + static void PopulateCountries(QComboBox *combo); + + void Load() override; + void Save() override; + + private: + Ui_RadioSettingsPage *ui_; +}; + +#endif // RADIOSETTINGSPAGE_H diff --git a/src/settings/radiosettingspage.ui b/src/settings/radiosettingspage.ui new file mode 100644 index 0000000000..5e36635386 --- /dev/null +++ b/src/settings/radiosettingspage.ui @@ -0,0 +1,115 @@ + + + RadioSettingsPage + + + + 0 + 0 + 450 + 500 + + + + Radios + + + + + + SomaFM + + + + + + Stream quality: + + + + + + + + + + + + + Radio Browser + + + + + + Search results limit: + + + + + + + 10 + + + 500 + + + 10 + + + 100 + + + + + + + Hide broken stations + + + true + + + + + + + Default sort order: + + + + + + + + + + Default country: + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/src/settings/settingsdialog.cpp b/src/settings/settingsdialog.cpp index 6eaf5587ad..6ef23d6ad2 100644 --- a/src/settings/settingsdialog.cpp +++ b/src/settings/settingsdialog.cpp @@ -91,6 +91,8 @@ # include "qobuzsettingspage.h" #endif +#include "radiosettingspage.h" + #include "ui_settingsdialog.h" using namespace Qt::Literals::StringLiterals; @@ -162,6 +164,8 @@ SettingsDialog::SettingsDialog(const SharedPtr player, AddPage(Page::Qobuz, new QobuzSettingsPage(this, streaming_services->Service(), this), streaming); #endif + AddPage(Page::Radio, new RadioSettingsPage(this, this), streaming); + // List box QObject::connect(ui_->list, &QTreeWidget::currentItemChanged, this, &SettingsDialog::CurrentItemChanged); ui_->list->setCurrentItem(pages_[Page::Behaviour].item_); diff --git a/src/settings/settingsdialog.h b/src/settings/settingsdialog.h index 8723ca193c..9adfcf6ed5 100644 --- a/src/settings/settingsdialog.h +++ b/src/settings/settingsdialog.h @@ -93,6 +93,7 @@ class SettingsDialog : public QDialog { Tidal, Qobuz, Spotify, + Radio, }; enum Role { diff --git a/src/utilities/coverutils.cpp b/src/utilities/coverutils.cpp index 4b3deaf4e9..d44088dbcf 100644 --- a/src/utilities/coverutils.cpp +++ b/src/utilities/coverutils.cpp @@ -142,6 +142,7 @@ QString CoverUtils::CoverFilenameFromSource(const Song::Source source, const QUr case Song::Source::Stream: case Song::Source::SomaFM: case Song::Source::RadioParadise: + case Song::Source::RadioBrowser: case Song::Source::Unknown: filename = QString::fromLatin1(Sha1CoverHash(artist, album).toHex()); break;