Prepare BearWave for public repository

This commit is contained in:
Sebastian Palencsar
2026-05-20 10:47:56 +02:00
commit 51992dd437
38 changed files with 3842 additions and 0 deletions

36
src/CMakeLists.txt Normal file
View File

@@ -0,0 +1,36 @@
# BearWave Source Files
# Core application
set(SOURCES
main.cpp
radiostation.cpp
radiobrowser.cpp
radiobackend.cpp
bearplayer.cpp
coverartfetcher.cpp
mprisadaptor.cpp
bearwavecontroladaptor.cpp
)
# Create executable
add_executable(bearwave ${SOURCES})
# Qt Resources
target_sources(bearwave PRIVATE qml.qrc)
# Link libraries
target_link_libraries(bearwave PRIVATE
Qt6::Core
Qt6::DBus
Qt6::Network
Qt6::Quick
Qt6::QuickControls2
Qt6::Widgets
KF6::Kirigami
KF6::I18n
KF6::CoreAddons
Phonon::phonon4qt6
)
# Install
install(TARGETS bearwave DESTINATION bin)

191
src/bearplayer.cpp Normal file
View File

@@ -0,0 +1,191 @@
#include "bearplayer.h"
#include <QUrl>
#include <QDebug>
namespace {
QString firstMetaValue(const QMultiMap<QString, QString> &meta, const QStringList &keys)
{
for (const QString &key : keys) {
if (meta.contains(key)) {
const QString value = meta.value(key).trimmed();
if (!value.isEmpty()) {
return value;
}
}
}
return QString();
}
}
BearPlayer::BearPlayer(QObject *parent)
: QObject(parent)
{
m_mediaObject = new Phonon::MediaObject(this);
m_audioOutput = new Phonon::AudioOutput(Phonon::MusicCategory, this);
m_coverArtFetcher = new CoverArtFetcher(this);
connect(m_coverArtFetcher, &CoverArtFetcher::coverUrlReady, this, &BearPlayer::onCoverUrlReady);
Phonon::createPath(m_mediaObject, m_audioOutput);
connect(m_mediaObject, &Phonon::MediaObject::stateChanged,
this, &BearPlayer::onStateChanged);
connect(m_mediaObject, &Phonon::MediaObject::metaDataChanged,
this, &BearPlayer::onMetaDataChanged);
m_retryTimer.setSingleShot(true);
m_retryTimer.setInterval(1200);
connect(&m_retryTimer, &QTimer::timeout, this, [this]() {
if (m_lastUrl.isEmpty() || m_playing || m_retryAttempts >= 2) {
return;
}
++m_retryAttempts;
m_mediaObject->setCurrentSource(QUrl(m_lastUrl));
m_mediaObject->play();
qDebug() << "Retry stream" << m_retryAttempts << m_lastName;
});
}
BearPlayer::~BearPlayer()
{
stop();
}
void BearPlayer::playUrl(const QString &url, const QString &name)
{
if (url.isEmpty()) {
return;
}
m_currentStationName = name;
m_lastName = name;
m_lastUrl = url;
m_retryAttempts = 0;
emit currentStationChanged(m_currentStationName);
clearTrackInfo();
m_mediaObject->setCurrentSource(QUrl(url));
m_mediaObject->play();
qDebug() << "Playing:" << name << url;
}
void BearPlayer::stop()
{
m_mediaObject->stop();
m_currentStationName.clear();
m_lastName.clear();
m_lastUrl.clear();
m_retryAttempts = 0;
emit currentStationChanged(QString());
clearTrackInfo();
}
void BearPlayer::togglePlayPause()
{
if (m_playing) {
m_mediaObject->pause();
} else {
m_mediaObject->play();
}
}
void BearPlayer::setVolume(qreal vol)
{
m_audioOutput->setVolume(qBound(0.0, vol, 1.0));
emit volumeChanged(m_audioOutput->volume());
}
void BearPlayer::onStateChanged(int state)
{
switch (state) {
case 2:
m_playing = true;
break;
default:
m_playing = false;
scheduleRetry();
break;
}
emit playingChanged(m_playing);
}
void BearPlayer::scheduleRetry()
{
if (m_lastUrl.isEmpty() || m_retryAttempts >= 2 || m_playing) {
return;
}
if (!m_retryTimer.isActive()) {
m_retryTimer.start();
}
}
void BearPlayer::onMetaDataChanged()
{
const QMultiMap<QString, QString> meta = m_mediaObject->metaData();
const QString artist = firstMetaValue(meta, {
QStringLiteral("ARTIST"),
QStringLiteral("artist"),
QStringLiteral("Artist")
});
QString title = firstMetaValue(meta, {
QStringLiteral("TITLE"),
QStringLiteral("title"),
QStringLiteral("Title")
});
if (title.isEmpty()) {
title = firstMetaValue(meta, {
QStringLiteral("icy-title"),
QStringLiteral("Icy-Title"),
QStringLiteral("StreamTitle")
});
}
if (m_currentTrackArtist == artist && m_currentTrackTitle == title) {
return;
}
m_currentTrackArtist = artist;
m_currentTrackTitle = title;
m_currentCoverArtUrl.clear();
m_coverArtFetcher->fetch(artist, title);
emit trackInfoChanged();
}
void BearPlayer::onCoverUrlReady(const QString &url)
{
if (m_currentCoverArtUrl != url) {
m_currentCoverArtUrl = url;
emit trackInfoChanged();
}
}
QString BearPlayer::currentNowPlaying() const
{
if (!m_currentTrackArtist.isEmpty() && !m_currentTrackTitle.isEmpty()) {
return m_currentTrackArtist + QStringLiteral(" - ") + m_currentTrackTitle;
}
if (!m_currentTrackTitle.isEmpty()) {
return m_currentTrackTitle;
}
if (!m_currentTrackArtist.isEmpty()) {
return m_currentTrackArtist;
}
return QString();
}
void BearPlayer::clearTrackInfo()
{
if (m_currentTrackArtist.isEmpty() && m_currentTrackTitle.isEmpty()) {
return;
}
m_currentTrackArtist.clear();
m_currentTrackTitle.clear();
m_currentCoverArtUrl.clear();
m_coverArtFetcher->fetch(QString(), QString());
emit trackInfoChanged();
}

69
src/bearplayer.h Normal file
View File

@@ -0,0 +1,69 @@
#ifndef BEARPLAYER_H
#define BEARPLAYER_H
#include <QObject>
#include <QTimer>
#include <phonon4qt6/phonon/MediaObject>
#include <phonon4qt6/phonon/AudioOutput>
#include "coverartfetcher.h"
class BearPlayer : public QObject
{
Q_OBJECT
Q_PROPERTY(bool playing READ playing NOTIFY playingChanged)
Q_PROPERTY(qreal volume READ volume WRITE setVolume NOTIFY volumeChanged)
Q_PROPERTY(QString currentStationName READ currentStationName NOTIFY currentStationChanged)
Q_PROPERTY(QString currentTrackTitle READ currentTrackTitle NOTIFY trackInfoChanged)
Q_PROPERTY(QString currentTrackArtist READ currentTrackArtist NOTIFY trackInfoChanged)
Q_PROPERTY(QString currentNowPlaying READ currentNowPlaying NOTIFY trackInfoChanged)
Q_PROPERTY(QString currentCoverArtUrl READ currentCoverArtUrl NOTIFY trackInfoChanged)
public:
explicit BearPlayer(QObject *parent = nullptr);
~BearPlayer();
bool playing() const { return m_playing; }
qreal volume() const { return m_audioOutput->volume(); }
QString currentStationName() const { return m_currentStationName; }
QString currentTrackTitle() const { return m_currentTrackTitle; }
QString currentTrackArtist() const { return m_currentTrackArtist; }
QString currentCoverArtUrl() const { return m_currentCoverArtUrl; }
QString currentNowPlaying() const;
Q_INVOKABLE void playUrl(const QString &url, const QString &name);
Q_INVOKABLE void stop();
Q_INVOKABLE void togglePlayPause();
Q_INVOKABLE void setVolume(qreal vol);
signals:
void playingChanged(bool playing);
void volumeChanged(qreal volume);
void currentStationChanged(const QString &stationName);
void trackInfoChanged();
private slots:
void onStateChanged(int state);
void onMetaDataChanged();
void onCoverUrlReady(const QString &url);
private:
void clearTrackInfo();
void scheduleRetry();
Phonon::MediaObject *m_mediaObject = nullptr;
Phonon::AudioOutput *m_audioOutput = nullptr;
QTimer m_retryTimer;
QString m_currentStationName;
QString m_currentTrackTitle;
QString m_currentTrackArtist;
QString m_lastUrl;
QString m_lastName;
QString m_currentCoverArtUrl;
CoverArtFetcher *m_coverArtFetcher = nullptr;
int m_retryAttempts = 0;
bool m_playing = false;
};
#endif

View File

@@ -0,0 +1,68 @@
#include "bearwavecontroladaptor.h"
#include "radiobackend.h"
BearWaveControlAdaptor::BearWaveControlAdaptor(RadioBackend *backend)
: QDBusAbstractAdaptor(backend)
, m_backend(backend)
{
connect(m_backend, &RadioBackend::listsChanged, this, &BearWaveControlAdaptor::StationsChanged);
}
QVariantList BearWaveControlAdaptor::GetFavorites() const
{
return m_backend ? m_backend->getFavoriteStations() : QVariantList();
}
QVariantList BearWaveControlAdaptor::GetRecentStations() const
{
return m_backend ? m_backend->getRecentStations() : QVariantList();
}
bool BearWaveControlAdaptor::PlayFavoriteByUuid(const QString &uuid, const QString &urlResolved)
{
return m_backend ? m_backend->playFavoriteByUuid(uuid, urlResolved) : false;
}
bool BearWaveControlAdaptor::PlayRecentByUuid(const QString &uuid, const QString &urlResolved)
{
return m_backend ? m_backend->playRecentByUuid(uuid, urlResolved) : false;
}
bool BearWaveControlAdaptor::HasNext() const
{
return m_backend ? m_backend->hasNextStation() : false;
}
bool BearWaveControlAdaptor::HasPrevious() const
{
return m_backend ? m_backend->hasPreviousStation() : false;
}
void BearWaveControlAdaptor::PlayNext()
{
if (m_backend) {
m_backend->playNextStation();
}
}
void BearWaveControlAdaptor::PlayPrevious()
{
if (m_backend) {
m_backend->playPreviousStation();
}
}
void BearWaveControlAdaptor::PlayPause()
{
if (m_backend && m_backend->player()) {
m_backend->player()->togglePlayPause();
}
}
void BearWaveControlAdaptor::Stop()
{
if (m_backend && m_backend->player()) {
m_backend->player()->stop();
}
}

View File

@@ -0,0 +1,36 @@
#ifndef BEARWAVECONTROLADAPTOR_H
#define BEARWAVECONTROLADAPTOR_H
#include <QDBusAbstractAdaptor>
#include <QVariantList>
class RadioBackend;
class BearWaveControlAdaptor : public QDBusAbstractAdaptor
{
Q_OBJECT
Q_CLASSINFO("D-Bus Interface", "org.kde.BearWave.Control")
public:
explicit BearWaveControlAdaptor(RadioBackend *backend);
public slots:
QVariantList GetFavorites() const;
QVariantList GetRecentStations() const;
bool PlayFavoriteByUuid(const QString &uuid, const QString &urlResolved = QString());
bool PlayRecentByUuid(const QString &uuid, const QString &urlResolved = QString());
bool HasNext() const;
bool HasPrevious() const;
void PlayNext();
void PlayPrevious();
void PlayPause();
void Stop();
signals:
void StationsChanged();
private:
RadioBackend *m_backend;
};
#endif

82
src/coverartfetcher.cpp Normal file
View File

@@ -0,0 +1,82 @@
#include "coverartfetcher.h"
#include <QUrlQuery>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QDebug>
CoverArtFetcher::CoverArtFetcher(QObject *parent)
: QObject(parent)
{
m_networkManager = new QNetworkAccessManager(this);
}
void CoverArtFetcher::fetch(const QString &artist, const QString &title)
{
if (m_currentReply) {
QNetworkReply *reply = m_currentReply;
m_currentReply = nullptr;
reply->abort();
reply->deleteLater();
}
if (artist.isEmpty() && title.isEmpty()) {
emit coverUrlReady(QString());
return;
}
QString term = artist;
if (!title.isEmpty()) {
if (!term.isEmpty()) term += " ";
term += title;
}
QUrl url("https://itunes.apple.com/search");
QUrlQuery query;
query.addQueryItem("term", term);
query.addQueryItem("entity", "song");
query.addQueryItem("limit", "1");
url.setQuery(query);
QNetworkRequest request(url);
m_currentReply = m_networkManager->get(request);
connect(m_currentReply, &QNetworkReply::finished, this, &CoverArtFetcher::onReplyFinished);
}
void CoverArtFetcher::onReplyFinished()
{
QNetworkReply *reply = qobject_cast<QNetworkReply*>(sender());
if (!reply) return;
if (reply == m_currentReply) {
m_currentReply = nullptr;
}
QString coverUrl;
if (reply->error() == QNetworkReply::NoError) {
QByteArray data = reply->readAll();
QJsonDocument doc = QJsonDocument::fromJson(data);
if (doc.isObject()) {
QJsonObject obj = doc.object();
if (obj.value("resultCount").toInt() > 0) {
QJsonArray results = obj.value("results").toArray();
if (!results.isEmpty()) {
QJsonObject firstResult = results.first().toObject();
QString url = firstResult.value("artworkUrl100").toString();
if (!url.isEmpty()) {
// Replace 100x100bb with a higher resolution
url.replace("100x100bb", "600x600bb");
coverUrl = url;
}
}
}
}
} else {
if (reply->error() != QNetworkReply::OperationCanceledError) {
qDebug() << "CoverArtFetcher error:" << reply->errorString();
}
}
reply->deleteLater();
emit coverUrlReady(coverUrl);
}

28
src/coverartfetcher.h Normal file
View File

@@ -0,0 +1,28 @@
#ifndef COVERARTFETCHER_H
#define COVERARTFETCHER_H
#include <QObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
class CoverArtFetcher : public QObject
{
Q_OBJECT
public:
explicit CoverArtFetcher(QObject *parent = nullptr);
void fetch(const QString &artist, const QString &title);
signals:
void coverUrlReady(const QString &url);
private slots:
void onReplyFinished();
private:
QNetworkAccessManager *m_networkManager;
QNetworkReply *m_currentReply = nullptr;
};
#endif // COVERARTFETCHER_H

55
src/main.cpp Normal file
View File

@@ -0,0 +1,55 @@
#include <QApplication>
#include <QDBusConnection>
#include <QIcon>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include "radiobackend.h"
#include "mprisadaptor.h"
#include "bearwavecontroladaptor.h"
int main(int argc, char *argv[])
{
QApplication::setApplicationName(QStringLiteral("BearWave"));
QApplication::setDesktopFileName(QStringLiteral("org.kde.bearwave.desktop"));
QApplication::setOrganizationName(QStringLiteral("BearWave"));
QApplication::setApplicationVersion(QStringLiteral("1.0.0"));
QApplication app(argc, argv);
app.setWindowIcon(QIcon::fromTheme(QStringLiteral("org.kde.bearwave")));
app.setQuitOnLastWindowClosed(false);
QQmlApplicationEngine engine;
RadioBackend backend;
engine.rootContext()->setContextProperty("radioBackend", &backend);
MprisRootAdaptor mprisRoot(&backend, &app);
MprisPlayerAdaptor mprisPlayer(&backend);
BearWaveControlAdaptor controlAdaptor(&backend);
Q_UNUSED(mprisRoot)
Q_UNUSED(mprisPlayer)
Q_UNUSED(controlAdaptor)
QDBusConnection sessionBus = QDBusConnection::sessionBus();
sessionBus.registerObject(QStringLiteral("/org/mpris/MediaPlayer2"), &backend, QDBusConnection::ExportAdaptors);
sessionBus.registerService(QStringLiteral("org.mpris.MediaPlayer2.bearwave"));
const QUrl url(QStringLiteral("qrc:/Main.qml"));
QObject::connect(&engine, &QQmlApplicationEngine::objectCreated,
&app, [&app, url](QObject *obj, const QUrl &objUrl) {
if (!obj && url == objUrl) {
qCritical("Failed to load QML file");
app.exit(EXIT_FAILURE);
}
}, Qt::QueuedConnection);
engine.load(url);
if (engine.rootObjects().isEmpty()) {
return EXIT_FAILURE;
}
return app.exec();
}

320
src/mprisadaptor.cpp Normal file
View File

@@ -0,0 +1,320 @@
#include "mprisadaptor.h"
#include "radiobackend.h"
#include "bearplayer.h"
#include <QApplication>
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDBusObjectPath>
#include <QStandardPaths>
namespace {
void emitPlayerPropertiesChanged(const QStringList &changedProps, const QVariantMap &changedValues)
{
QDBusMessage signal = QDBusMessage::createSignal(
QStringLiteral("/org/mpris/MediaPlayer2"),
QStringLiteral("org.freedesktop.DBus.Properties"),
QStringLiteral("PropertiesChanged"));
signal << QStringLiteral("org.mpris.MediaPlayer2.Player")
<< changedValues
<< QStringList();
QDBusConnection::sessionBus().send(signal);
Q_UNUSED(changedProps)
}
}
MprisRootAdaptor::MprisRootAdaptor(RadioBackend *backend, QApplication *app, QObject *parent)
: QDBusAbstractAdaptor(backend)
, m_backend(backend)
, m_app(app)
{
}
bool MprisRootAdaptor::canQuit() const
{
return true;
}
bool MprisRootAdaptor::fullscreen() const
{
return false;
}
void MprisRootAdaptor::setFullscreen(bool value)
{
Q_UNUSED(value)
}
bool MprisRootAdaptor::canSetFullscreen() const
{
return false;
}
bool MprisRootAdaptor::canRaise() const
{
return false;
}
bool MprisRootAdaptor::hasTrackList() const
{
return false;
}
QString MprisRootAdaptor::identity() const
{
return QStringLiteral("BearWave");
}
QString MprisRootAdaptor::desktopEntry() const
{
return QStringLiteral("org.kde.bearwave");
}
QStringList MprisRootAdaptor::supportedUriSchemes() const
{
return {QStringLiteral("http"), QStringLiteral("https")};
}
QStringList MprisRootAdaptor::supportedMimeTypes() const
{
return {
QStringLiteral("audio/mpeg"),
QStringLiteral("audio/aac"),
QStringLiteral("audio/ogg"),
QStringLiteral("application/x-mpegURL"),
QStringLiteral("application/vnd.apple.mpegurl")
};
}
void MprisRootAdaptor::Raise()
{
}
void MprisRootAdaptor::Quit()
{
if (m_app) {
m_app->quit();
}
}
MprisPlayerAdaptor::MprisPlayerAdaptor(RadioBackend *backend)
: QDBusAbstractAdaptor(backend)
, m_backend(backend)
{
BearPlayer *p = player();
if (!p) {
return;
}
connect(p, &BearPlayer::playingChanged, this, [this]() {
QVariantMap changed;
changed.insert(QStringLiteral("PlaybackStatus"), playbackStatus());
emitPlayerPropertiesChanged({QStringLiteral("PlaybackStatus")}, changed);
});
connect(p, &BearPlayer::volumeChanged, this, [this]() {
QVariantMap changed;
changed.insert(QStringLiteral("Volume"), volume());
emitPlayerPropertiesChanged({QStringLiteral("Volume")}, changed);
});
connect(p, &BearPlayer::currentStationChanged, this, [this]() {
QVariantMap changed;
changed.insert(QStringLiteral("Metadata"), metadata());
changed.insert(QStringLiteral("PlaybackStatus"), playbackStatus());
changed.insert(QStringLiteral("CanGoNext"), canGoNext());
changed.insert(QStringLiteral("CanGoPrevious"), canGoPrevious());
emitPlayerPropertiesChanged({QStringLiteral("Metadata"), QStringLiteral("PlaybackStatus")}, changed);
});
connect(p, &BearPlayer::trackInfoChanged, this, [this]() {
QVariantMap changed;
changed.insert(QStringLiteral("Metadata"), metadata());
emitPlayerPropertiesChanged({QStringLiteral("Metadata")}, changed);
});
}
BearPlayer *MprisPlayerAdaptor::player() const
{
return m_backend ? m_backend->player() : nullptr;
}
QString MprisPlayerAdaptor::playbackStatus() const
{
BearPlayer *p = player();
if (!p) {
return QStringLiteral("Stopped");
}
if (p->currentStationName().isEmpty()) {
return QStringLiteral("Stopped");
}
return p->playing() ? QStringLiteral("Playing") : QStringLiteral("Paused");
}
double MprisPlayerAdaptor::rate() const
{
return 1.0;
}
void MprisPlayerAdaptor::setRate(double value)
{
Q_UNUSED(value)
}
double MprisPlayerAdaptor::minimumRate() const
{
return 1.0;
}
double MprisPlayerAdaptor::maximumRate() const
{
return 1.0;
}
double MprisPlayerAdaptor::volume() const
{
BearPlayer *p = player();
if (!p) {
return 0.0;
}
return p->volume();
}
void MprisPlayerAdaptor::setVolume(double value)
{
BearPlayer *p = player();
if (!p) {
return;
}
p->setVolume(value);
}
QVariantMap MprisPlayerAdaptor::metadata() const
{
QVariantMap map;
BearPlayer *p = player();
if (!p) {
return map;
}
const QString station = p->currentStationName();
const QString title = p->currentTrackTitle();
const QString artist = p->currentTrackArtist();
const QString coverUrl = p->currentCoverArtUrl();
map.insert(QStringLiteral("mpris:trackid"), QVariant::fromValue(QDBusObjectPath(QStringLiteral("/org/kde/bearwave/track/0"))));
if (!station.isEmpty()) {
map.insert(QStringLiteral("xesam:album"), station);
}
if (!title.isEmpty()) {
map.insert(QStringLiteral("xesam:title"), title);
} else if (!station.isEmpty()) {
map.insert(QStringLiteral("xesam:title"), station);
}
if (!artist.isEmpty()) {
map.insert(QStringLiteral("xesam:artist"), QStringList{artist});
}
if (!coverUrl.isEmpty()) {
map.insert(QStringLiteral("mpris:artUrl"), coverUrl);
} else {
QString iconPath = QStandardPaths::locate(QStandardPaths::GenericDataLocation, QStringLiteral("icons/hicolor/256x256/apps/org.kde.bearwave.png"));
if (!iconPath.isEmpty()) {
map.insert(QStringLiteral("mpris:artUrl"), QStringLiteral("file://") + iconPath);
}
}
map.insert(QStringLiteral("mpris:length"), static_cast<qlonglong>(0));
return map;
}
qlonglong MprisPlayerAdaptor::position() const
{
return 0;
}
bool MprisPlayerAdaptor::canSeek() const
{
return false;
}
bool MprisPlayerAdaptor::canControl() const
{
return true;
}
bool MprisPlayerAdaptor::canPlay() const
{
return true;
}
bool MprisPlayerAdaptor::canPause() const
{
return true;
}
bool MprisPlayerAdaptor::canGoNext() const
{
return m_backend && m_backend->hasNextStation();
}
bool MprisPlayerAdaptor::canGoPrevious() const
{
return m_backend && m_backend->hasPreviousStation();
}
void MprisPlayerAdaptor::Play()
{
BearPlayer *p = player();
if (!p) {
return;
}
if (!p->playing()) {
p->togglePlayPause();
}
}
void MprisPlayerAdaptor::Pause()
{
BearPlayer *p = player();
if (!p) {
return;
}
if (p->playing()) {
p->togglePlayPause();
}
}
void MprisPlayerAdaptor::PlayPause()
{
BearPlayer *p = player();
if (!p) {
return;
}
p->togglePlayPause();
}
void MprisPlayerAdaptor::Stop()
{
BearPlayer *p = player();
if (!p) {
return;
}
p->stop();
}
void MprisPlayerAdaptor::Next()
{
if (m_backend) {
m_backend->playNextStation();
}
}
void MprisPlayerAdaptor::Previous()
{
if (m_backend) {
m_backend->playPreviousStation();
}
}

104
src/mprisadaptor.h Normal file
View File

@@ -0,0 +1,104 @@
#ifndef MPRISADAPTOR_H
#define MPRISADAPTOR_H
#include <QDBusAbstractAdaptor>
#include <QVariantMap>
#include <QObject>
#include <QString>
#include <QStringList>
class QApplication;
class RadioBackend;
class BearPlayer;
class MprisRootAdaptor : public QDBusAbstractAdaptor
{
Q_OBJECT
Q_CLASSINFO("D-Bus Interface", "org.mpris.MediaPlayer2")
Q_PROPERTY(bool CanQuit READ canQuit CONSTANT)
Q_PROPERTY(bool Fullscreen READ fullscreen WRITE setFullscreen)
Q_PROPERTY(bool CanSetFullscreen READ canSetFullscreen CONSTANT)
Q_PROPERTY(bool CanRaise READ canRaise CONSTANT)
Q_PROPERTY(bool HasTrackList READ hasTrackList CONSTANT)
Q_PROPERTY(QString Identity READ identity CONSTANT)
Q_PROPERTY(QString DesktopEntry READ desktopEntry CONSTANT)
Q_PROPERTY(QStringList SupportedUriSchemes READ supportedUriSchemes CONSTANT)
Q_PROPERTY(QStringList SupportedMimeTypes READ supportedMimeTypes CONSTANT)
public:
explicit MprisRootAdaptor(RadioBackend *backend, QApplication *app, QObject *parent = nullptr);
bool canQuit() const;
bool fullscreen() const;
void setFullscreen(bool value);
bool canSetFullscreen() const;
bool canRaise() const;
bool hasTrackList() const;
QString identity() const;
QString desktopEntry() const;
QStringList supportedUriSchemes() const;
QStringList supportedMimeTypes() const;
public slots:
void Raise();
void Quit();
private:
RadioBackend *m_backend;
QApplication *m_app;
};
class MprisPlayerAdaptor : public QDBusAbstractAdaptor
{
Q_OBJECT
Q_CLASSINFO("D-Bus Interface", "org.mpris.MediaPlayer2.Player")
Q_PROPERTY(QString PlaybackStatus READ playbackStatus)
Q_PROPERTY(double Rate READ rate WRITE setRate)
Q_PROPERTY(double MinimumRate READ minimumRate CONSTANT)
Q_PROPERTY(double MaximumRate READ maximumRate CONSTANT)
Q_PROPERTY(double Volume READ volume WRITE setVolume)
Q_PROPERTY(QVariantMap Metadata READ metadata)
Q_PROPERTY(qlonglong Position READ position)
Q_PROPERTY(bool CanSeek READ canSeek CONSTANT)
Q_PROPERTY(bool CanControl READ canControl CONSTANT)
Q_PROPERTY(bool CanPlay READ canPlay CONSTANT)
Q_PROPERTY(bool CanPause READ canPause CONSTANT)
Q_PROPERTY(bool CanGoNext READ canGoNext CONSTANT)
Q_PROPERTY(bool CanGoPrevious READ canGoPrevious CONSTANT)
public:
explicit MprisPlayerAdaptor(RadioBackend *backend);
QString playbackStatus() const;
double rate() const;
void setRate(double value);
double minimumRate() const;
double maximumRate() const;
double volume() const;
void setVolume(double value);
QVariantMap metadata() const;
qlonglong position() const;
bool canSeek() const;
bool canControl() const;
bool canPlay() const;
bool canPause() const;
bool canGoNext() const;
bool canGoPrevious() const;
public slots:
void Play();
void Pause();
void PlayPause();
void Stop();
void Next();
void Previous();
private:
BearPlayer *player() const;
RadioBackend *m_backend;
};
#endif

9
src/qml.qrc Normal file
View File

@@ -0,0 +1,9 @@
<RCC>
<qresource prefix="/">
<file alias="Main.qml">qml/Main.qml</file>
<file alias="bearwave.png">../bearwave.png</file>
<file alias="globe.svg">../globe.svg</file>
<file alias="github.svg">../github.svg</file>
<file alias="linkedin.svg">../linkedin.svg</file>
</qresource>
</RCC>

938
src/qml/Main.qml Normal file
View File

@@ -0,0 +1,938 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import Qt.labs.platform 1.1 as Platform
ApplicationWindow {
id: root
title: "BearWave"
width: 980
height: 700
minimumWidth: 520
minimumHeight: 460
visible: true
onClosing: function(close) {
close.accepted = false
root.hide()
}
Platform.SystemTrayIcon {
id: systray
visible: true
icon.name: "multimedia-player"
tooltip: "BearWave"
menu: Platform.Menu {
Platform.MenuItem {
text: backend && backend.player && backend.player.playing ? "Pause" : "Play"
onTriggered: {
if (backend && backend.player) {
backend.player.togglePlayPause()
}
}
}
Platform.MenuItem {
text: root.visible ? "Verstecken" : "Zeigen"
onTriggered: {
if (root.visible) {
root.hide()
} else {
root.show()
root.raise()
root.requestActivate()
}
}
}
Platform.MenuSeparator {}
Platform.MenuItem {
text: "Beenden"
onTriggered: Qt.quit()
}
}
onActivated: function(reason) {
if (reason === Platform.SystemTrayIcon.Trigger) {
if (root.visible) {
root.hide()
} else {
root.show()
root.raise()
root.requestActivate()
}
}
}
}
property var currentPage: "top"
property var backend: (typeof radioBackend !== "undefined" ? radioBackend : null)
property bool compactMode: width < 780
property real contentOpacity: 1.0
property string activeQuickFilter: ""
readonly property color bgA: "#0f141b"
readonly property color bgB: "#131b25"
readonly property color panel: "#182433"
readonly property color card: "#1b2a3d"
readonly property color cardHover: "#223654"
readonly property color cardBorder: "#2d4566"
readonly property color accent: "#2bb0ff"
readonly property color textMain: "#eaf1fb"
readonly property color textMuted: "#9eb1c9"
readonly property color warn: "#ff8b8b"
function toast(message) {
toastLabel.text = message
toastPopup.open()
}
function activeModel() {
if (!backend) {
return []
}
return currentPage === "favorites" ? backend.favoriteStations : backend.stations
}
onCurrentPageChanged: {
contentOpacity = 0.85
contentFadeRestart.restart()
}
Shortcut {
sequence: "Space"
onActivated: {
if (backend && backend.player) {
backend.player.togglePlayPause()
}
}
}
Shortcut {
sequence: "Ctrl+F"
onActivated: searchField.forceActiveFocus()
}
Rectangle {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.0; color: bgA }
GradientStop { position: 1.0; color: bgB }
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 10
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: compactMode ? 208 : 176
radius: 12
color: panel
border.color: cardBorder
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 8
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: "BearWave"
color: textMain
font.pixelSize: compactMode ? 18 : 22
font.bold: true
}
Rectangle {
Layout.preferredWidth: 1
Layout.fillHeight: true
color: cardBorder
opacity: 0.6
}
Button {
text: "Top"
highlighted: currentPage === "top"
onClicked: {
if (!backend) return
currentPage = "top"
activeQuickFilter = ""
backend.loadTopStations()
}
}
Button {
text: "DE"
highlighted: currentPage === "german"
onClicked: {
if (!backend) return
currentPage = "german"
activeQuickFilter = ""
backend.loadGermanStations()
}
}
Button {
text: "NL"
highlighted: currentPage === "dutch"
onClicked: {
if (!backend) return
currentPage = "dutch"
activeQuickFilter = ""
backend.loadDutchStations()
}
}
Button {
text: "Favoriten"
highlighted: currentPage === "favorites"
onClicked: {
currentPage = "favorites"
activeQuickFilter = ""
}
}
Item { Layout.fillWidth: true }
Button {
text: compactMode ? "+" : "Manuell +"
onClicked: addDialog.open()
}
Button {
text: "About"
onClicked: aboutDialog.open()
}
Button {
visible: backend && backend.canResumeLastStation
text: compactMode ? "↺" : "Resume"
onClicked: {
if (backend) {
backend.resumeLastStation()
}
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
TextField {
id: searchField
Layout.fillWidth: true
placeholderText: "Sender suchen (Name, Genre, Land)"
onTextChanged: {
if (backend) {
backend.filterQuery = text
}
}
onAccepted: {
if (text.length < 2 || !backend) return
currentPage = "search"
backend.searchStations(text)
}
}
Button {
text: "Suche"
highlighted: true
onClicked: {
if (searchField.text.length < 2 || !backend) return
currentPage = "search"
backend.searchStations(searchField.text)
}
}
Button {
text: compactMode ? "A-Z" : "Sort A-Z"
onClicked: {
if (backend && currentPage !== "favorites") {
backend.sortStations("name")
}
}
}
Button {
text: compactMode ? "kb" : "Sort Bitrate"
onClicked: {
if (backend && currentPage !== "favorites") {
backend.sortStations("bitrate")
}
}
}
Button {
text: compactMode ? "❤" : "Sort Votes"
onClicked: {
if (backend && currentPage !== "favorites") {
backend.sortStations("votes")
}
}
}
}
Label {
Layout.fillWidth: true
text: "Tipp: Du bist nicht auf DE/NL begrenzt - suche nach Ländern, Genres oder Sendernamen weltweit."
color: textMuted
font.pixelSize: 11
elide: Text.ElideRight
}
RowLayout {
Layout.fillWidth: true
spacing: 6
Label {
text: "Genre:"
color: textMuted
font.pixelSize: 11
}
Button {
text: "Rock"
highlighted: activeQuickFilter === "tag:rock"
onClicked: {
if (!backend) return
currentPage = "genre-rock"
activeQuickFilter = "tag:rock"
backend.loadByTag("rock")
}
}
Button {
text: "News"
highlighted: activeQuickFilter === "tag:news"
onClicked: {
if (!backend) return
currentPage = "genre-news"
activeQuickFilter = "tag:news"
backend.loadByTag("news")
}
}
Button {
text: "Jazz"
highlighted: activeQuickFilter === "tag:jazz"
onClicked: {
if (!backend) return
currentPage = "genre-jazz"
activeQuickFilter = "tag:jazz"
backend.loadByTag("jazz")
}
}
Item { Layout.preferredWidth: 10 }
Label {
text: "Land:"
color: textMuted
font.pixelSize: 11
}
Button {
text: "US"
highlighted: activeQuickFilter === "cc:US"
onClicked: {
if (!backend) return
currentPage = "country-us"
activeQuickFilter = "cc:US"
backend.loadByCountryCode("US")
}
}
Button {
text: "UK"
highlighted: activeQuickFilter === "cc:GB"
onClicked: {
if (!backend) return
currentPage = "country-gb"
activeQuickFilter = "cc:GB"
backend.loadByCountryCode("GB")
}
}
Button {
text: "FR"
highlighted: activeQuickFilter === "cc:FR"
onClicked: {
if (!backend) return
currentPage = "country-fr"
activeQuickFilter = "cc:FR"
backend.loadByCountryCode("FR")
}
}
Button {
text: "WORLD"
highlighted: activeQuickFilter === "world"
onClicked: {
if (!backend) return
currentPage = "world"
activeQuickFilter = "world"
backend.loadWorldStations()
}
}
Item { Layout.fillWidth: true }
}
Label {
Layout.fillWidth: true
visible: backend && backend.lastError.length > 0
text: backend ? ("Fehler: " + backend.lastError) : ""
color: warn
elide: Text.ElideRight
font.pixelSize: 12
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
radius: 12
color: panel
border.color: cardBorder
clip: true
opacity: contentOpacity
Behavior on opacity {
NumberAnimation { duration: 180; easing.type: Easing.OutCubic }
}
ScrollView {
id: stationScrollView
anchors.fill: parent
anchors.margins: 10
clip: true
ScrollBar.vertical.policy: ScrollBar.AlwaysOn
ListView {
id: stationList
width: stationScrollView.availableWidth
spacing: 8
model: activeModel()
visible: count > 0
delegate: Rectangle {
id: stationCard
required property int index
required property var modelData
width: stationScrollView.availableWidth
height: compactMode ? 72 : 78
radius: 10
color: cardMouse.containsMouse ? cardHover : card
border.color: cardBorder
MouseArea {
id: cardMouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton
onClicked: {
if (!backend) return
if (currentPage === "favorites") {
backend.playFavoriteStation(index)
} else {
backend.playStation(index)
}
}
}
RowLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 10
Rectangle {
Layout.preferredWidth: 44
Layout.preferredHeight: 44
radius: 8
color: "#123154"
border.color: accent
Image {
anchors.fill: parent
anchors.margins: 3
source: (stationCard.modelData.favicon && stationCard.modelData.favicon.startsWith("https://"))
? stationCard.modelData.favicon
: ""
fillMode: Image.PreserveAspectFit
asynchronous: true
cache: true
visible: source !== "" && status === Image.Ready
}
Label {
anchors.centerIn: parent
text: "FM"
color: "#d8ecff"
font.bold: true
visible: !parent.children[0].visible
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Label {
Layout.fillWidth: true
text: stationCard.modelData.name
color: textMain
font.bold: true
font.pixelSize: compactMode ? 13 : 14
elide: Text.ElideRight
}
Label {
Layout.fillWidth: true
text: stationCard.modelData.country + " • "
+ (stationCard.modelData.bitrate > 0 ? stationCard.modelData.bitrate + " kbps" : "Stream")
color: textMuted
font.pixelSize: 11
elide: Text.ElideRight
}
}
Button {
Layout.preferredWidth: compactMode ? 34 : 40
Layout.preferredHeight: 40
text: stationCard.modelData.isFavorite ? "★" : "☆"
onClicked: {
if (!backend) return
backend.toggleFavoriteById(stationCard.modelData.uuid, stationCard.modelData.urlResolved)
toast(stationCard.modelData.isFavorite ? "Aus Favoriten entfernt" : "Zu Favoriten hinzugefügt")
}
}
Button {
Layout.preferredWidth: compactMode ? 34 : 40
Layout.preferredHeight: 40
text: "▶"
onClicked: {
if (!backend) return
if (currentPage === "favorites") {
backend.playFavoriteStation(index)
} else {
backend.playStation(index)
}
}
}
}
}
}
}
Column {
anchors.centerIn: parent
spacing: 8
visible: stationList.count === 0
Label {
text: "Noch keine Sender geladen"
color: textMain
font.bold: true
}
Label {
text: "Lade DE/NL oder nutze die Suche"
color: textMuted
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 108
radius: 12
color: panel
border.color: cardBorder
RowLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 12
Rectangle {
Layout.preferredWidth: 88
Layout.preferredHeight: 88
radius: 8
color: "#182637"
clip: true
border.color: cardBorder
Image {
id: coverImage
anchors.fill: parent
source: backend && backend.player && backend.player.currentCoverArtUrl ? backend.player.currentCoverArtUrl : "qrc:/bearwave.png"
fillMode: backend && backend.player && backend.player.currentCoverArtUrl ? Image.PreserveAspectCrop : Image.PreserveAspectFit
smooth: true
asynchronous: true
anchors.margins: backend && backend.player && backend.player.currentCoverArtUrl ? 0 : 16
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 6
Label {
Layout.fillWidth: true
text: backend && backend.player ? (backend.player.currentStationName || "Kein Sender ausgewählt") : "Kein Sender ausgewählt"
color: textMain
font.pixelSize: 16
font.bold: true
elide: Text.ElideRight
}
Label {
Layout.fillWidth: true
text: backend && backend.player && backend.player.currentNowPlaying.length > 0
? ("Jetzt läuft: " + backend.player.currentNowPlaying)
: "Jetzt läuft: Keine Titelinfo"
color: textMuted
font.pixelSize: 12
elide: Text.ElideRight
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Button {
text: "⏮"
Layout.preferredWidth: 44
enabled: backend ? backend.hasPreviousStation() : false
onClicked: {
if (backend) {
backend.playPreviousStation()
}
}
}
Button {
text: backend && backend.player && backend.player.playing ? "⏸" : "▶"
Layout.preferredWidth: 44
onClicked: {
if (backend && backend.player) {
backend.player.togglePlayPause()
}
}
}
Button {
text: "⏹"
Layout.preferredWidth: 44
onClicked: {
if (backend && backend.player) {
backend.player.stop()
}
}
}
Button {
text: "⏭"
Layout.preferredWidth: 44
enabled: backend ? backend.hasNextStation() : false
onClicked: {
if (backend) {
backend.playNextStation()
}
}
}
Label {
text: "Lautstärke"
color: textMuted
}
Slider {
Layout.fillWidth: true
from: 0
to: 1
value: backend && backend.player ? backend.player.volume : 0.5
onMoved: {
if (backend && backend.player) {
backend.player.setVolume(value)
}
}
}
}
}
}
}
}
Rectangle {
visible: backend && backend.loading
anchors.fill: parent
color: "#7f0b121a"
z: 20
Rectangle {
anchors.centerIn: parent
width: 210
height: 110
radius: 12
color: panel
border.color: cardBorder
Column {
anchors.centerIn: parent
spacing: 10
BusyIndicator {
running: true
width: 36
height: 36
anchors.horizontalCenter: parent.horizontalCenter
}
Label {
text: "Lade Sender..."
color: textMain
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
}
Component.onCompleted: {
if (backend) {
backend.loadTopStations()
}
}
Dialog {
id: addDialog
title: "Sender manuell hinzufügen"
modal: true
anchors.centerIn: parent
width: compactMode ? 320 : 420
standardButtons: Dialog.Ok | Dialog.Cancel
contentItem: ColumnLayout {
spacing: 8
TextField { id: manualName; Layout.fillWidth: true; placeholderText: "Name" }
TextField { id: manualUrl; Layout.fillWidth: true; placeholderText: "Stream URL (http/https)" }
TextField { id: manualCountry; Layout.fillWidth: true; placeholderText: "Land (optional)" }
}
onAccepted: {
if (backend) {
backend.addManualStation(manualName.text, manualUrl.text, manualCountry.text)
toast("Sender hinzugefügt")
}
manualName.text = ""
manualUrl.text = ""
manualCountry.text = ""
}
}
Dialog {
id: aboutDialog
modal: true
anchors.centerIn: Overlay.overlay
width: compactMode ? 360 : 520
height: compactMode ? 560 : 680
standardButtons: Dialog.Ok
contentItem: ColumnLayout {
spacing: 16
// --- HEADER ---
ColumnLayout {
Layout.alignment: Qt.AlignHCenter
spacing: 6
Image {
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: 72
Layout.preferredHeight: 72
sourceSize.width: 72
sourceSize.height: 72
source: "qrc:/bearwave.png"
fillMode: Image.PreserveAspectFit
smooth: true
}
Label {
Layout.alignment: Qt.AlignHCenter
text: "BearWave"
color: textMain
font.bold: true
font.pixelSize: 26
}
Label {
Layout.alignment: Qt.AlignHCenter
text: "Internet Radio Player for KDE"
color: textMuted
font.pixelSize: 14
}
}
// --- CREDITS & LINKS ---
ColumnLayout {
Layout.alignment: Qt.AlignHCenter
spacing: 8
Layout.topMargin: 8
Layout.bottomMargin: 8
Label {
Layout.alignment: Qt.AlignHCenter
text: "Autor: Sebastian Palencsár"
color: textMain
font.bold: true
font.pixelSize: 14
}
RowLayout {
Layout.alignment: Qt.AlignHCenter
spacing: 16
ToolButton {
Layout.preferredWidth: 40
Layout.preferredHeight: 40
icon.source: "qrc:/globe.svg"
icon.width: 19
icon.height: 19
onClicked: Qt.openUrlExternally("https://palencsar.pro")
ToolTip.visible: hovered
ToolTip.text: "Website öffnen"
background: Rectangle {
radius: 20
color: parent.hovered ? "#274261" : "#1f3147"
border.color: cardBorder
border.width: 1
}
}
ToolButton {
Layout.preferredWidth: 40
Layout.preferredHeight: 40
icon.source: "qrc:/github.svg"
icon.width: 19
icon.height: 19
onClicked: Qt.openUrlExternally("https://github.com/spalencsar")
ToolTip.visible: hovered
ToolTip.text: "GitHub öffnen"
background: Rectangle {
radius: 20
color: parent.hovered ? "#274261" : "#1f3147"
border.color: cardBorder
border.width: 1
}
}
ToolButton {
Layout.preferredWidth: 40
Layout.preferredHeight: 40
icon.source: "qrc:/linkedin.svg"
icon.width: 19
icon.height: 19
onClicked: Qt.openUrlExternally("https://www.linkedin.com/in/spalencsar/")
ToolTip.visible: hovered
ToolTip.text: "LinkedIn öffnen"
background: Rectangle {
radius: 20
color: parent.hovered ? "#274261" : "#1f3147"
border.color: cardBorder
border.width: 1
}
}
}
}
// --- LICENSE ---
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 8
RowLayout {
Layout.fillWidth: true
Label {
Layout.fillWidth: true
text: "MIT License"
color: textMain
font.bold: true
}
Label {
text: "Copyright (c) 2026"
color: textMuted
font.pixelSize: 11
}
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 140
radius: 8
color: "#101a26"
border.color: cardBorder
ScrollView {
id: licenseScroll
anchors.fill: parent
anchors.margins: 10
clip: true
ScrollBar.vertical.policy: ScrollBar.AlwaysOn
Label {
width: licenseScroll.availableWidth
color: textMain
font.pixelSize: 11
wrapMode: Text.WordWrap
text: "Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
}
}
}
}
}
}
Popup {
id: toastPopup
x: (root.width - width) / 2
y: root.height - height - 20
padding: 10
closePolicy: Popup.NoAutoClose
background: Rectangle {
radius: 10
color: "#24364e"
border.color: accent
}
contentItem: Label {
id: toastLabel
color: textMain
font.pixelSize: 12
}
Timer {
interval: 1400
running: toastPopup.visible
repeat: false
onTriggered: toastPopup.close()
}
}
Timer {
id: contentFadeRestart
interval: 120
repeat: false
onTriggered: contentOpacity = 1.0
}
}

668
src/radiobackend.cpp Normal file
View File

@@ -0,0 +1,668 @@
#include "radiobackend.h"
#include <QDebug>
#include <algorithm>
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QStandardPaths>
namespace {
constexpr int kRecentLimit = 20;
QString appConfigDir()
{
return QDir::homePath() + QStringLiteral("/.config/bearwave");
}
}
RadioBackend::RadioBackend(QObject *parent)
: QObject(parent)
{
m_radioBrowser = new RadioBrowser(this);
m_player = new BearPlayer(this);
setupConnections();
loadFavorites();
loadState();
}
QList<QObject*> RadioBackend::stations() const
{
QList<QObject*> list;
for (RadioStation *s : m_filteredStations) {
list.append(s);
}
return list;
}
QList<QObject*> RadioBackend::favoriteStations() const
{
QList<QObject*> list;
for (RadioStation *s : m_favorites) {
list.append(s);
}
return list;
}
void RadioBackend::setupConnections()
{
connect(m_radioBrowser, &RadioBrowser::stationsLoaded,
this, &RadioBackend::onStationsLoaded);
connect(m_radioBrowser, &RadioBrowser::error,
this, [this](const QString &error) {
qWarning() << "RadioBrowser error:" << error;
setLastError(error);
setLoading(false);
});
connect(m_player, &BearPlayer::currentStationChanged, this, [this](const QString &name) {
if (!name.isEmpty()) {
m_lastStationName = name;
emit resumeStateChanged();
saveState();
}
});
connect(m_player, &BearPlayer::volumeChanged, this, [this]() {
saveState();
});
}
void RadioBackend::onStationsLoaded(const QList<RadioStation*> &stations)
{
qDeleteAll(m_stations);
m_stations.clear();
m_filteredStations.clear();
m_stations = stations;
for (RadioStation *s : m_stations) {
s->setParent(this);
for (RadioStation *f : m_favorites) {
if ((!s->uuid().isEmpty() && s->uuid() == f->uuid()) ||
(s->uuid().isEmpty() && s->urlResolved() == f->urlResolved())) {
s->setIsFavorite(true);
break;
}
}
}
std::sort(m_stations.begin(), m_stations.end(), [](RadioStation *a, RadioStation *b) {
return QString::localeAwareCompare(a->name(), b->name()) < 0;
});
rebuildFilteredStations(false);
setLastError(QString());
setLoading(false);
qDebug() << "Loaded" << stations.size() << "stations";
}
void RadioBackend::loadGermanStations()
{
setLoading(true);
m_radioBrowser->getGermanStations();
}
void RadioBackend::loadDutchStations()
{
setLoading(true);
m_radioBrowser->getDutchStations();
}
void RadioBackend::loadTopStations()
{
setLoading(true);
m_radioBrowser->getTopStations(100);
}
void RadioBackend::loadWorldStations()
{
setLoading(true);
m_radioBrowser->getWorldStations(200);
}
void RadioBackend::loadByTag(const QString &tag)
{
if (tag.trimmed().isEmpty()) {
return;
}
setLoading(true);
m_radioBrowser->getByTag(tag.trimmed());
}
void RadioBackend::loadByCountryCode(const QString &countryCode)
{
if (countryCode.trimmed().isEmpty()) {
return;
}
setLoading(true);
m_radioBrowser->getByCountry(countryCode.trimmed().toUpper());
}
void RadioBackend::searchStations(const QString &query)
{
if (query.isEmpty()) {
return;
}
setLoading(true);
m_radioBrowser->search(query);
}
void RadioBackend::playStation(int index)
{
if (!stationForVisibleIndex(index)) {
return;
}
m_currentFromFavorites = false;
m_currentIndex = index;
playCurrentSelection();
}
void RadioBackend::playFavoriteStation(int index)
{
if (index < 0 || index >= m_favorites.size()) {
return;
}
m_currentFromFavorites = true;
m_currentIndex = index;
playCurrentSelection();
}
void RadioBackend::playNextStation()
{
const QList<RadioStation*> list = currentList();
if (list.isEmpty()) {
return;
}
if (m_currentIndex < 0) {
m_currentIndex = 0;
} else if (m_currentIndex < list.size() - 1) {
++m_currentIndex;
}
playCurrentSelection();
}
void RadioBackend::playPreviousStation()
{
const QList<RadioStation*> list = currentList();
if (list.isEmpty()) {
return;
}
if (m_currentIndex < 0) {
m_currentIndex = 0;
} else if (m_currentIndex > 0) {
--m_currentIndex;
}
playCurrentSelection();
}
bool RadioBackend::hasNextStation() const
{
const QList<RadioStation*> list = currentList();
if (list.isEmpty()) {
return false;
}
if (m_currentIndex < 0) {
return true;
}
return m_currentIndex < list.size() - 1;
}
bool RadioBackend::hasPreviousStation() const
{
const QList<RadioStation*> list = currentList();
if (list.isEmpty()) {
return false;
}
return m_currentIndex > 0;
}
void RadioBackend::toggleFavorite(int index)
{
if (index < 0 || index >= m_stations.size()) {
return;
}
RadioStation *station = m_stations[index];
for (int i = 0; i < m_favorites.size(); ++i) {
if ((!station->uuid().isEmpty() && m_favorites[i]->uuid() == station->uuid()) ||
(station->uuid().isEmpty() && m_favorites[i]->urlResolved() == station->urlResolved())) {
m_favorites.removeAt(i);
station->setIsFavorite(false);
saveFavorites();
emit favoritesChanged();
emit stationsChanged();
return;
}
}
RadioStation *copy = new RadioStation(this);
copy->setUuid(station->uuid());
copy->setName(station->name());
copy->setUrl(station->url());
copy->setUrlResolved(station->urlResolved());
copy->setHomepage(station->homepage());
copy->setFavicon(station->favicon());
copy->setCountry(station->country());
copy->setTags(station->tags());
copy->setCodec(station->codec());
copy->setBitrate(station->bitrate());
copy->setVotes(station->votes());
copy->setIsOnline(station->isOnline());
copy->setIsFavorite(true);
m_favorites.append(copy);
std::sort(m_favorites.begin(), m_favorites.end(), [](RadioStation *a, RadioStation *b) {
return QString::localeAwareCompare(a->name(), b->name()) < 0;
});
station->setIsFavorite(true);
saveFavorites();
emit favoritesChanged();
emit stationsChanged();
emit listsChanged();
}
void RadioBackend::toggleFavoriteById(const QString &uuid, const QString &urlResolved)
{
int stationIndex = -1;
for (int i = 0; i < m_stations.size(); ++i) {
if ((!uuid.isEmpty() && m_stations[i]->uuid() == uuid) ||
(uuid.isEmpty() && !urlResolved.isEmpty() && m_stations[i]->urlResolved() == urlResolved)) {
stationIndex = i;
break;
}
}
if (stationIndex >= 0) {
toggleFavorite(stationIndex);
return;
}
for (int i = 0; i < m_favorites.size(); ++i) {
if ((!uuid.isEmpty() && m_favorites[i]->uuid() == uuid) ||
(uuid.isEmpty() && !urlResolved.isEmpty() && m_favorites[i]->urlResolved() == urlResolved)) {
m_favorites.removeAt(i);
for (RadioStation *s : m_stations) {
if ((!uuid.isEmpty() && s->uuid() == uuid) ||
(uuid.isEmpty() && !urlResolved.isEmpty() && s->urlResolved() == urlResolved)) {
s->setIsFavorite(false);
}
}
saveFavorites();
emit favoritesChanged();
emit stationsChanged();
emit listsChanged();
return;
}
}
}
void RadioBackend::addManualStation(const QString &name, const QString &url, const QString &country)
{
if (name.trimmed().isEmpty() || url.trimmed().isEmpty()) {
return;
}
RadioStation *station = new RadioStation(this);
station->setName(name.trimmed());
station->setUrl(url.trimmed());
station->setUrlResolved(url.trimmed());
station->setCountry(country.trimmed().isEmpty() ? QStringLiteral("Manual") : country.trimmed());
station->setCodec(QStringLiteral("unknown"));
station->setBitrate(0);
station->setVotes(0);
station->setIsOnline(true);
m_stations.prepend(station);
rebuildFilteredStations(false);
}
void RadioBackend::sortStations(const QString &mode)
{
if (mode == QStringLiteral("name")) {
std::sort(m_stations.begin(), m_stations.end(), [](RadioStation *a, RadioStation *b) {
return QString::localeAwareCompare(a->name(), b->name()) < 0;
});
} else if (mode == QStringLiteral("bitrate")) {
std::sort(m_stations.begin(), m_stations.end(), [](RadioStation *a, RadioStation *b) {
return a->bitrate() > b->bitrate();
});
} else if (mode == QStringLiteral("votes")) {
std::sort(m_stations.begin(), m_stations.end(), [](RadioStation *a, RadioStation *b) {
return a->votes() > b->votes();
});
}
rebuildFilteredStations(false);
}
QVariantList RadioBackend::getRecentStations() const
{
return m_recentStations;
}
QVariantList RadioBackend::getFavoriteStations() const
{
QVariantList result;
for (RadioStation *station : m_favorites) {
result.push_back(toVariantMap(station));
}
return result;
}
bool RadioBackend::playRecentByUuid(const QString &uuid, const QString &urlResolved)
{
for (RadioStation *station : m_stations) {
if (matchesStation(station, uuid, urlResolved)) {
const QString url = station->urlResolved().isEmpty() ? station->url() : station->urlResolved();
m_lastStationName = station->name();
m_lastStationUrl = url;
emit resumeStateChanged();
recordRecentStation(toVariantMap(station));
saveState();
m_player->playUrl(url, station->name());
return true;
}
}
for (const QVariant &entry : m_recentStations) {
const QVariantMap station = entry.toMap();
if ((!uuid.isEmpty() && station.value(QStringLiteral("uuid")).toString() == uuid) ||
(uuid.isEmpty() && !urlResolved.isEmpty() && station.value(QStringLiteral("urlResolved")).toString() == urlResolved)) {
const QString url = station.value(QStringLiteral("urlResolved")).toString();
if (url.isEmpty()) {
return false;
}
m_lastStationName = station.value(QStringLiteral("name")).toString();
m_lastStationUrl = url;
emit resumeStateChanged();
recordRecentStation(station);
saveState();
m_player->playUrl(url, m_lastStationName);
return true;
}
}
return false;
}
bool RadioBackend::playFavoriteByUuid(const QString &uuid, const QString &urlResolved)
{
for (int i = 0; i < m_favorites.size(); ++i) {
if (matchesStation(m_favorites[i], uuid, urlResolved)) {
playFavoriteStation(i);
return true;
}
}
return false;
}
void RadioBackend::resumeLastStation()
{
if (m_lastStationUrl.isEmpty()) {
return;
}
QVariantMap station;
station.insert(QStringLiteral("name"), m_lastStationName.isEmpty() ? QStringLiteral("Zuletzt gehoert") : m_lastStationName);
station.insert(QStringLiteral("urlResolved"), m_lastStationUrl);
recordRecentStation(station);
saveState();
m_player->playUrl(m_lastStationUrl, m_lastStationName.isEmpty() ? QStringLiteral("Zuletzt gehört") : m_lastStationName);
}
void RadioBackend::loadFavorites()
{
const QString configDir = appConfigDir();
QDir().mkpath(configDir);
QFile file(configDir + "/favorites.json");
if (!file.open(QIODevice::ReadOnly)) {
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
if (!doc.isArray()) {
return;
}
for (const QJsonValue &v : doc.array()) {
if (!v.isObject()) {
continue;
}
QJsonObject o = v.toObject();
RadioStation *s = new RadioStation(this);
s->setUuid(o.value("uuid").toString());
s->setName(o.value("name").toString());
s->setUrl(o.value("url").toString());
s->setUrlResolved(o.value("urlResolved").toString());
s->setCountry(o.value("country").toString());
s->setIsFavorite(true);
m_favorites.append(s);
}
std::sort(m_favorites.begin(), m_favorites.end(), [](RadioStation *a, RadioStation *b) {
return QString::localeAwareCompare(a->name(), b->name()) < 0;
});
}
void RadioBackend::saveFavorites() const
{
const QString configDir = appConfigDir();
QDir().mkpath(configDir);
QFile file(configDir + "/favorites.json");
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return;
}
QJsonArray arr;
for (RadioStation *s : m_favorites) {
QJsonObject o;
o.insert("uuid", s->uuid());
o.insert("name", s->name());
o.insert("url", s->url());
o.insert("urlResolved", s->urlResolved());
o.insert("country", s->country());
arr.append(o);
}
file.write(QJsonDocument(arr).toJson(QJsonDocument::Indented));
}
void RadioBackend::loadState()
{
const QString configDir = appConfigDir();
QDir().mkpath(configDir);
QFile file(configDir + "/state.json");
if (!file.open(QIODevice::ReadOnly)) {
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
if (!doc.isObject()) {
return;
}
const QJsonObject obj = doc.object();
m_lastStationName = obj.value("lastStationName").toString();
m_lastStationUrl = obj.value("lastStationUrl").toString();
const double volume = obj.value("volume").toDouble(0.5);
const QJsonArray recentArray = obj.value("recentStations").toArray();
m_recentStations.clear();
for (const QJsonValue &value : recentArray) {
if (!value.isObject()) {
continue;
}
const QVariantMap recentStation = value.toObject().toVariantMap();
if (recentStation.value(QStringLiteral("urlResolved")).toString().isEmpty()) {
continue;
}
m_recentStations.append(recentStation);
if (m_recentStations.size() >= kRecentLimit) {
break;
}
}
m_player->setVolume(volume);
emit resumeStateChanged();
}
void RadioBackend::saveState() const
{
const QString configDir = appConfigDir();
QDir().mkpath(configDir);
QFile file(configDir + "/state.json");
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return;
}
QJsonObject obj;
obj.insert("lastStationName", m_lastStationName);
obj.insert("lastStationUrl", m_lastStationUrl);
obj.insert("volume", m_player->volume());
obj.insert("recentStations", QJsonArray::fromVariantList(m_recentStations));
file.write(QJsonDocument(obj).toJson(QJsonDocument::Indented));
}
void RadioBackend::setLoading(bool loading)
{
if (m_loading == loading) {
return;
}
m_loading = loading;
emit loadingChanged();
}
void RadioBackend::setLastError(const QString &error)
{
if (m_lastError == error) {
return;
}
m_lastError = error;
emit lastErrorChanged();
}
QList<RadioStation*> RadioBackend::currentList() const
{
return m_currentFromFavorites ? m_favorites : m_filteredStations;
}
RadioStation *RadioBackend::stationForVisibleIndex(int index) const
{
if (index < 0 || index >= m_filteredStations.size()) {
return nullptr;
}
return m_filteredStations[index];
}
void RadioBackend::setFilterQuery(const QString &query)
{
if (m_filterQuery == query && !m_filteredStations.isEmpty() && !m_stations.isEmpty()) {
return;
}
m_filterQuery = query;
rebuildFilteredStations();
}
void RadioBackend::rebuildFilteredStations(bool emitFilterSignal)
{
m_filteredStations.clear();
if (m_filterQuery.trimmed().isEmpty()) {
m_filteredStations = m_stations;
} else {
const QString lowerQuery = m_filterQuery.toLower();
for (RadioStation *s : m_stations) {
if (s->name().toLower().contains(lowerQuery) ||
s->tags().toLower().contains(lowerQuery) ||
s->country().toLower().contains(lowerQuery)) {
m_filteredStations.append(s);
}
}
}
if (emitFilterSignal) {
emit filterQueryChanged();
}
emit stationsChanged();
emit listsChanged();
}
void RadioBackend::recordRecentStation(const QVariantMap &stationData)
{
const QString url = stationData.value(QStringLiteral("urlResolved")).toString();
if (url.isEmpty()) {
return;
}
QVariantMap normalizedStation = stationData;
if (!normalizedStation.contains(QStringLiteral("name"))) {
normalizedStation.insert(QStringLiteral("name"), QString());
}
for (int i = 0; i < m_recentStations.size(); ++i) {
const QVariantMap existing = m_recentStations[i].toMap();
const QString existingUuid = existing.value(QStringLiteral("uuid")).toString();
const QString newUuid = normalizedStation.value(QStringLiteral("uuid")).toString();
if ((!newUuid.isEmpty() && existingUuid == newUuid) ||
(newUuid.isEmpty() && existing.value(QStringLiteral("urlResolved")).toString() == url)) {
m_recentStations.removeAt(i);
break;
}
}
m_recentStations.prepend(normalizedStation);
while (m_recentStations.size() > kRecentLimit) {
m_recentStations.removeLast();
}
emit listsChanged();
}
void RadioBackend::playCurrentSelection()
{
const QList<RadioStation*> list = currentList();
if (list.isEmpty() || m_currentIndex < 0 || m_currentIndex >= list.size()) {
return;
}
RadioStation *station = list[m_currentIndex];
QString url = station->urlResolved().isEmpty()
? station->url()
: station->urlResolved();
m_lastStationName = station->name();
m_lastStationUrl = url;
emit resumeStateChanged();
recordRecentStation(toVariantMap(station));
saveState();
m_player->playUrl(url, station->name());
}
QVariantMap RadioBackend::toVariantMap(const RadioStation *station)
{
QVariantMap map;
if (!station) {
return map;
}
map.insert(QStringLiteral("uuid"), station->uuid());
map.insert(QStringLiteral("name"), station->name());
map.insert(QStringLiteral("country"), station->country());
map.insert(QStringLiteral("bitrate"), station->bitrate());
map.insert(QStringLiteral("isFavorite"), station->isFavorite());
map.insert(QStringLiteral("favicon"), station->favicon());
map.insert(QStringLiteral("urlResolved"), station->urlResolved());
return map;
}
bool RadioBackend::matchesStation(const RadioStation *station, const QString &uuid, const QString &urlResolved)
{
if (!station) {
return false;
}
if (!uuid.isEmpty() && station->uuid() == uuid) {
return true;
}
if (uuid.isEmpty() && !urlResolved.isEmpty() && station->urlResolved() == urlResolved) {
return true;
}
return false;
}

108
src/radiobackend.h Normal file
View File

@@ -0,0 +1,108 @@
#ifndef RADIOBACKEND_H
#define RADIOBACKEND_H
#include <QObject>
#include <QQmlEngine>
#include <QList>
#include <QVariantMap>
#include <QVariantList>
#include "radiobrowser.h"
#include "bearplayer.h"
class RadioStation;
class RadioBackend : public QObject
{
Q_OBJECT
Q_PROPERTY(QList<QObject*> stations READ stations NOTIFY stationsChanged)
Q_PROPERTY(QList<QObject*> favoriteStations READ favoriteStations NOTIFY favoritesChanged)
Q_PROPERTY(BearPlayer* player READ player CONSTANT)
Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged)
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
Q_PROPERTY(bool canResumeLastStation READ canResumeLastStation NOTIFY resumeStateChanged)
Q_PROPERTY(QString lastStationName READ lastStationName NOTIFY resumeStateChanged)
Q_PROPERTY(QString filterQuery READ filterQuery WRITE setFilterQuery NOTIFY filterQueryChanged)
public:
explicit RadioBackend(QObject *parent = nullptr);
QList<QObject*> stations() const;
QList<QObject*> favoriteStations() const;
BearPlayer* player() const { return m_player; }
bool loading() const { return m_loading; }
QString lastError() const { return m_lastError; }
bool canResumeLastStation() const { return !m_lastStationUrl.isEmpty(); }
QString lastStationName() const { return m_lastStationName; }
QString filterQuery() const { return m_filterQuery; }
Q_INVOKABLE void setFilterQuery(const QString &query);
Q_INVOKABLE void loadGermanStations();
Q_INVOKABLE void loadDutchStations();
Q_INVOKABLE void loadTopStations();
Q_INVOKABLE void loadWorldStations();
Q_INVOKABLE void loadByTag(const QString &tag);
Q_INVOKABLE void loadByCountryCode(const QString &countryCode);
Q_INVOKABLE void searchStations(const QString &query);
Q_INVOKABLE void playStation(int index);
Q_INVOKABLE void playFavoriteStation(int index);
Q_INVOKABLE void playNextStation();
Q_INVOKABLE void playPreviousStation();
Q_INVOKABLE bool hasNextStation() const;
Q_INVOKABLE bool hasPreviousStation() const;
Q_INVOKABLE void toggleFavorite(int index);
Q_INVOKABLE void toggleFavoriteById(const QString &uuid, const QString &urlResolved);
Q_INVOKABLE void addManualStation(const QString &name, const QString &url, const QString &country);
Q_INVOKABLE void sortStations(const QString &mode);
Q_INVOKABLE QVariantList getRecentStations() const;
Q_INVOKABLE QVariantList getFavoriteStations() const;
Q_INVOKABLE bool playRecentByUuid(const QString &uuid, const QString &urlResolved = QString());
Q_INVOKABLE bool playFavoriteByUuid(const QString &uuid, const QString &urlResolved = QString());
Q_INVOKABLE void resumeLastStation();
signals:
void stationsChanged();
void favoritesChanged();
void listsChanged();
void loadingChanged();
void lastErrorChanged();
void resumeStateChanged();
void filterQueryChanged();
private slots:
void onStationsLoaded(const QList<RadioStation*> &stations);
private:
RadioBrowser *m_radioBrowser = nullptr;
BearPlayer *m_player = nullptr;
QList<RadioStation*> m_stations;
QList<RadioStation*> m_filteredStations;
QList<RadioStation*> m_favorites;
int m_currentIndex = -1;
bool m_currentFromFavorites = false;
bool m_loading = false;
QString m_lastError;
QString m_lastStationName;
QString m_lastStationUrl;
QString m_filterQuery;
QVariantList m_recentStations;
void setupConnections();
void loadFavorites();
void saveFavorites() const;
void loadState();
void saveState() const;
void setLoading(bool loading);
void setLastError(const QString &error);
QList<RadioStation*> currentList() const;
RadioStation *stationForVisibleIndex(int index) const;
void rebuildFilteredStations(bool emitFilterSignal = true);
void recordRecentStation(const QVariantMap &stationData);
void playCurrentSelection();
static QVariantMap toVariantMap(const RadioStation *station);
static bool matchesStation(const RadioStation *station, const QString &uuid, const QString &urlResolved);
};
#endif

153
src/radiobrowser.cpp Normal file
View File

@@ -0,0 +1,153 @@
#include "radiobrowser.h"
#include <QJsonDocument>
#include <QJsonArray>
#include <QJsonObject>
#include <QUrl>
#include <QUrlQuery>
#include <QStandardPaths>
#include <QDir>
#include <QFile>
#include <QCryptographicHash>
namespace {
QString apiCacheDir()
{
return QDir::homePath() + QStringLiteral("/.cache/bearwave/api_cache");
}
}
RadioBrowser::RadioBrowser(QObject *parent)
: QObject(parent)
{
m_networkManager = new QNetworkAccessManager(this);
}
void RadioBrowser::search(const QString &query)
{
QString endpoint = "/stations/byname/" + QUrl::toPercentEncoding(query);
makeRequest(endpoint);
}
void RadioBrowser::getTopStations(int count)
{
QString endpoint = "/stations/topvote/" + QString::number(count);
makeRequest(endpoint);
}
void RadioBrowser::getByCountry(const QString &countryCode)
{
QString endpoint = "/stations/bycountrycodeexact/" + countryCode;
makeRequest(endpoint);
}
void RadioBrowser::getByTag(const QString &tag)
{
QString endpoint = "/stations/bytag/" + QUrl::toPercentEncoding(tag);
makeRequest(endpoint);
}
void RadioBrowser::getWorldStations(int count)
{
QString endpoint = "/stations?hidebroken=true&limit=" + QString::number(count) + "&order=votes&reverse=true";
makeRequest(endpoint);
}
void RadioBrowser::getGermanStations()
{
QString endpoint = "/stations/bycountrycodeexact/DE?limit=50&order=votes&reverse=true";
makeRequest(endpoint);
}
void RadioBrowser::getDutchStations()
{
QString endpoint = "/stations/bycountrycodeexact/NL?limit=50&order=votes&reverse=true";
makeRequest(endpoint);
}
void RadioBrowser::makeRequest(const QString &endpoint)
{
const QString cacheDir = apiCacheDir();
QDir().mkpath(cacheDir);
const QString hash = QString(QCryptographicHash::hash(endpoint.toUtf8(), QCryptographicHash::Md5).toHex());
const QString cachePath = cacheDir + "/" + hash + ".json";
QFile cacheFile(cachePath);
if (cacheFile.exists() && cacheFile.open(QIODevice::ReadOnly)) {
QByteArray cachedData = cacheFile.readAll();
QList<RadioStation*> stations = parseJsonResponse(cachedData);
if (!stations.isEmpty()) {
emit stationsLoaded(stations);
}
cacheFile.close();
}
QUrl url(m_baseUrl + endpoint);
QNetworkRequest request;
request.setUrl(url);
request.setHeader(QNetworkRequest::UserAgentHeader, "BearWave/1.0");
QNetworkReply *reply = m_networkManager->get(request);
reply->setProperty("cachePath", cachePath);
connect(reply, &QNetworkReply::finished, this, &RadioBrowser::onReplyFinished);
}
void RadioBrowser::onReplyFinished()
{
QNetworkReply *reply = qobject_cast<QNetworkReply*>(sender());
if (!reply) {
emit error("Network error");
return;
}
if (reply->error() != QNetworkReply::NoError) {
emit error(reply->errorString());
reply->deleteLater();
return;
}
QByteArray data = reply->readAll();
QList<RadioStation*> stations = parseJsonResponse(data);
QString cachePath = reply->property("cachePath").toString();
if (!cachePath.isEmpty() && !stations.isEmpty()) {
QFile file(cachePath);
if (file.open(QIODevice::WriteOnly)) {
file.write(data);
file.close();
}
}
emit stationsLoaded(stations);
reply->deleteLater();
}
QList<RadioStation*> RadioBrowser::parseJsonResponse(const QByteArray &jsonData)
{
QList<RadioStation*> stations;
QJsonDocument doc = QJsonDocument::fromJson(jsonData);
if (!doc.isArray()) {
return stations;
}
QJsonArray array = doc.array();
for (const QJsonValue &value : array) {
if (value.isObject()) {
QJsonObject obj = value.toObject();
QString name = obj.value("name").toString();
QString url = obj.value("url_resolved").toString();
if (name.isEmpty() || url.isEmpty()) {
continue;
}
RadioStation *station = RadioStation::fromJson(obj);
stations.append(station);
}
}
return stations;
}

41
src/radiobrowser.h Normal file
View File

@@ -0,0 +1,41 @@
#ifndef RADIOBROWSER_H
#define RADIOBROWSER_H
#include <QObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QList>
#include "radiostation.h"
class RadioBrowser : public QObject
{
Q_OBJECT
public:
explicit RadioBrowser(QObject *parent = nullptr);
void search(const QString &query);
void getTopStations(int count = 50);
void getByCountry(const QString &countryCode);
void getByTag(const QString &tag);
void getWorldStations(int count = 200);
void getGermanStations();
void getDutchStations();
signals:
void stationsLoaded(const QList<RadioStation*> &stations);
void error(const QString &message);
private slots:
void onReplyFinished();
private:
QNetworkAccessManager *m_networkManager = nullptr;
QString m_baseUrl = "https://de1.api.radio-browser.info/json";
void makeRequest(const QString &endpoint);
QList<RadioStation*> parseJsonResponse(const QByteArray &jsonData);
};
#endif

162
src/radiostation.cpp Normal file
View File

@@ -0,0 +1,162 @@
#include "radiostation.h"
#include <QJsonObject>
RadioStation::RadioStation(QObject *parent)
: QObject(parent)
{
}
QString RadioStation::uuid() const
{
return m_uuid;
}
void RadioStation::setUuid(const QString &uuid)
{
m_uuid = uuid;
}
QString RadioStation::name() const
{
return m_name;
}
void RadioStation::setName(const QString &name)
{
m_name = name;
}
QString RadioStation::url() const
{
return m_url;
}
void RadioStation::setUrl(const QString &url)
{
m_url = url;
}
QString RadioStation::urlResolved() const
{
return m_urlResolved;
}
void RadioStation::setUrlResolved(const QString &urlResolved)
{
m_urlResolved = urlResolved;
}
QString RadioStation::homepage() const
{
return m_homepage;
}
void RadioStation::setHomepage(const QString &homepage)
{
m_homepage = homepage;
}
QString RadioStation::favicon() const
{
return m_favicon;
}
void RadioStation::setFavicon(const QString &favicon)
{
m_favicon = favicon;
}
QString RadioStation::country() const
{
return m_country;
}
void RadioStation::setCountry(const QString &country)
{
m_country = country;
}
QString RadioStation::tags() const
{
return m_tags;
}
void RadioStation::setTags(const QString &tags)
{
m_tags = tags;
}
QString RadioStation::codec() const
{
return m_codec;
}
void RadioStation::setCodec(const QString &codec)
{
m_codec = codec;
}
int RadioStation::bitrate() const
{
return m_bitrate;
}
void RadioStation::setBitrate(int bitrate)
{
m_bitrate = bitrate;
}
int RadioStation::votes() const
{
return m_votes;
}
void RadioStation::setVotes(int votes)
{
m_votes = votes;
}
bool RadioStation::isOnline() const
{
return m_isOnline;
}
void RadioStation::setIsOnline(bool isOnline)
{
m_isOnline = isOnline;
}
bool RadioStation::isFavorite() const
{
return m_isFavorite;
}
void RadioStation::setIsFavorite(bool isFavorite)
{
if (m_isFavorite == isFavorite) {
return;
}
m_isFavorite = isFavorite;
emit isFavoriteChanged();
}
RadioStation* RadioStation::fromJson(const QJsonObject &json)
{
RadioStation *station = new RadioStation();
station->setUuid(json.value("stationuuid").toString());
station->setName(json.value("name").toString());
station->setUrl(json.value("url").toString());
station->setUrlResolved(json.value("url_resolved").toString());
station->setHomepage(json.value("homepage").toString());
station->setFavicon(json.value("favicon").toString());
station->setCountry(json.value("country").toString());
station->setTags(json.value("tags").toString());
station->setCodec(json.value("codec").toString());
station->setBitrate(json.value("bitrate").toInt());
station->setVotes(json.value("votes").toInt());
station->setIsOnline(json.value("lastcheckok").toInt() == 1);
return station;
}

89
src/radiostation.h Normal file
View File

@@ -0,0 +1,89 @@
#ifndef RADIOSTATION_H
#define RADIOSTATION_H
#include <QObject>
#include <QString>
#include <QVariant>
class RadioStation : public QObject
{
Q_OBJECT
Q_PROPERTY(QString uuid READ uuid CONSTANT)
Q_PROPERTY(QString name READ name CONSTANT)
Q_PROPERTY(QString url READ url CONSTANT)
Q_PROPERTY(QString urlResolved READ urlResolved CONSTANT)
Q_PROPERTY(QString homepage READ homepage CONSTANT)
Q_PROPERTY(QString favicon READ favicon CONSTANT)
Q_PROPERTY(QString country READ country CONSTANT)
Q_PROPERTY(QString tags READ tags CONSTANT)
Q_PROPERTY(QString codec READ codec CONSTANT)
Q_PROPERTY(int bitrate READ bitrate CONSTANT)
Q_PROPERTY(int votes READ votes CONSTANT)
Q_PROPERTY(bool isOnline READ isOnline CONSTANT)
Q_PROPERTY(bool isFavorite READ isFavorite WRITE setIsFavorite NOTIFY isFavoriteChanged)
public:
explicit RadioStation(QObject *parent = nullptr);
QString uuid() const;
void setUuid(const QString &uuid);
QString name() const;
void setName(const QString &name);
QString url() const;
void setUrl(const QString &url);
QString urlResolved() const;
void setUrlResolved(const QString &urlResolved);
QString homepage() const;
void setHomepage(const QString &homepage);
QString favicon() const;
void setFavicon(const QString &favicon);
QString country() const;
void setCountry(const QString &country);
QString tags() const;
void setTags(const QString &tags);
QString codec() const;
void setCodec(const QString &codec);
int bitrate() const;
void setBitrate(int bitrate);
int votes() const;
void setVotes(int votes);
bool isOnline() const;
void setIsOnline(bool isOnline);
bool isFavorite() const;
void setIsFavorite(bool isFavorite);
static RadioStation* fromJson(const QJsonObject &json);
signals:
void isFavoriteChanged();
private:
QString m_uuid;
QString m_name;
QString m_url;
QString m_urlResolved;
QString m_homepage;
QString m_favicon;
QString m_country;
QString m_tags;
QString m_codec;
int m_bitrate = 0;
int m_votes = 0;
bool m_isOnline = false;
bool m_isFavorite = false;
};
#endif