mirror of
https://github.com/spalencsar/bearwave.git
synced 2026-07-06 22:24:17 +02:00
Prepare BearWave for public repository
This commit is contained in:
36
src/CMakeLists.txt
Normal file
36
src/CMakeLists.txt
Normal 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
191
src/bearplayer.cpp
Normal 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
69
src/bearplayer.h
Normal 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
|
||||
68
src/bearwavecontroladaptor.cpp
Normal file
68
src/bearwavecontroladaptor.cpp
Normal 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();
|
||||
}
|
||||
}
|
||||
36
src/bearwavecontroladaptor.h
Normal file
36
src/bearwavecontroladaptor.h
Normal 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
82
src/coverartfetcher.cpp
Normal 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
28
src/coverartfetcher.h
Normal 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
55
src/main.cpp
Normal 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
320
src/mprisadaptor.cpp
Normal 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
104
src/mprisadaptor.h
Normal 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
9
src/qml.qrc
Normal 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
938
src/qml/Main.qml
Normal 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
668
src/radiobackend.cpp
Normal 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
108
src/radiobackend.h
Normal 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
153
src/radiobrowser.cpp
Normal 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
41
src/radiobrowser.h
Normal 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
162
src/radiostation.cpp
Normal 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
89
src/radiostation.h
Normal 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
|
||||
Reference in New Issue
Block a user