Fix playback, API races, tray, MPRIS, and search UX; add tests

- Correct history/resume next-previous context and list index resync after reload
- Abort stale RadioBrowser requests and suppress error banners when cache satisfies loads
- Reset local search filter on page changes and debounce compact-mode API search
- Move system tray to C++ (QSystemTrayIcon) for reliable Wayland context menus
- Harden MPRIS registration and metadata updates for Plasma media widgets
- Add playback and API race unit tests
- Drop unused KF6 Kirigami, I18n, and CoreAddons build dependencies
- Update packaging docs, CI, and PKGBUILD to match Qt-only requirements
This commit is contained in:
Sebastian Palencsar
2026-06-20 14:06:48 +02:00
parent 4718810d24
commit 1c736b97a6
20 changed files with 1007 additions and 155 deletions

View File

@@ -12,6 +12,7 @@ set(SOURCES
bearwavecontroladaptor.cpp
notificationmanager.cpp
icyreader.cpp
systemtraymanager.cpp
)
# Create executable
@@ -45,9 +46,6 @@ target_link_libraries(bearwave PRIVATE
Qt6::QuickControls2
Qt6::Widgets
Qt6::Multimedia
KF6::Kirigami
KF6::I18n
KF6::CoreAddons
)
# Install

View File

@@ -5,16 +5,19 @@
#include <QDBusConnection>
#include <QDBusConnectionInterface>
#include <QDBusMessage>
#include <QDebug>
#include <QIcon>
#include <QLocale>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQuickWindow>
#include <QTranslator>
#include "radiobackend.h"
#include "mprisadaptor.h"
#include "bearwavecontroladaptor.h"
#include "notificationmanager.h"
#include "systemtraymanager.h"
int main(int argc, char *argv[])
@@ -50,17 +53,22 @@ int main(int argc, char *argv[])
RadioBackend backend;
engine.rootContext()->setContextProperty("radioBackend", &backend);
MprisRootAdaptor mprisRoot(&backend, &app);
MprisPlayerAdaptor mprisPlayer(&backend);
BearWaveControlAdaptor controlAdaptor(&backend);
NotificationManager notificationManager(&backend);
auto *mprisRoot = new MprisRootAdaptor(&backend, &app);
auto *mprisPlayer = new MprisPlayerAdaptor(&backend);
auto *controlAdaptor = new BearWaveControlAdaptor(&backend);
auto *notificationManager = new NotificationManager(&backend, &backend);
Q_UNUSED(mprisRoot)
Q_UNUSED(mprisPlayer)
Q_UNUSED(controlAdaptor)
Q_UNUSED(notificationManager)
sessionBus.registerObject(QStringLiteral("/org/mpris/MediaPlayer2"), &backend, QDBusConnection::ExportAdaptors);
sessionBus.registerService(QStringLiteral("org.mpris.MediaPlayer2.bearwave"));
if (!sessionBus.registerObject(QStringLiteral("/org/mpris/MediaPlayer2"), &backend, QDBusConnection::ExportAdaptors)) {
qWarning() << "Failed to register MPRIS D-Bus object";
}
if (!sessionBus.registerService(QStringLiteral("org.mpris.MediaPlayer2.bearwave"))) {
qWarning() << "Failed to register MPRIS D-Bus service org.mpris.MediaPlayer2.bearwave";
} else {
mprisPlayer->publishState();
}
const QUrl url(QStringLiteral("qrc:/Main.qml"));
QObject::connect(&engine, &QQmlApplicationEngine::objectCreated,
@@ -77,5 +85,14 @@ int main(int argc, char *argv[])
return EXIT_FAILURE;
}
QQuickWindow *mainWindow = qobject_cast<QQuickWindow *>(engine.rootObjects().constFirst());
if (!mainWindow) {
qCritical("Failed to obtain main window");
return EXIT_FAILURE;
}
SystemTrayManager trayManager(&backend, mainWindow, &app);
Q_UNUSED(trayManager)
return app.exec();
}

View File

@@ -11,6 +11,7 @@
#include <QDBusMessage>
#include <QDBusObjectPath>
#include <QStandardPaths>
#include <QUrl>
namespace {
void emitPlayerPropertiesChanged(const QStringList &changedProps, const QVariantMap &changedValues)
@@ -109,7 +110,7 @@ MprisPlayerAdaptor::MprisPlayerAdaptor(RadioBackend *backend)
, m_backend(backend)
{
BearPlayer *p = player();
if (!p) {
if (!p || !m_backend) {
return;
}
@@ -139,6 +140,24 @@ MprisPlayerAdaptor::MprisPlayerAdaptor(RadioBackend *backend)
changed.insert(QStringLiteral("Metadata"), metadata());
emitPlayerPropertiesChanged({QStringLiteral("Metadata")}, changed);
});
connect(m_backend, &RadioBackend::currentStationChanged, this, [this]() {
QVariantMap changed;
changed.insert(QStringLiteral("CanGoNext"), canGoNext());
changed.insert(QStringLiteral("CanGoPrevious"), canGoPrevious());
emitPlayerPropertiesChanged({QStringLiteral("CanGoNext"), QStringLiteral("CanGoPrevious")}, changed);
});
}
void MprisPlayerAdaptor::publishState()
{
QVariantMap changed;
changed.insert(QStringLiteral("PlaybackStatus"), playbackStatus());
changed.insert(QStringLiteral("Volume"), volume());
changed.insert(QStringLiteral("Metadata"), metadata());
changed.insert(QStringLiteral("CanGoNext"), canGoNext());
changed.insert(QStringLiteral("CanGoPrevious"), canGoPrevious());
emitPlayerPropertiesChanged({}, changed);
}
BearPlayer *MprisPlayerAdaptor::player() const
@@ -208,6 +227,7 @@ QVariantMap MprisPlayerAdaptor::metadata() const
const QString title = p->currentTrackTitle();
const QString artist = p->currentTrackArtist();
const QString coverUrl = p->currentCoverArtUrl();
const QString streamUrl = m_backend ? m_backend->currentStationUrl() : QString();
map.insert(QStringLiteral("mpris:trackid"), QVariant::fromValue(QDBusObjectPath(QStringLiteral("/org/kde/bearwave/track/0"))));
if (!station.isEmpty()) {
@@ -221,6 +241,9 @@ QVariantMap MprisPlayerAdaptor::metadata() const
if (!artist.isEmpty()) {
map.insert(QStringLiteral("xesam:artist"), QStringList{artist});
}
if (!streamUrl.isEmpty()) {
map.insert(QStringLiteral("xesam:url"), QUrl(streamUrl).toString());
}
QString artUrl = coverUrl;
if (artUrl.isEmpty() && m_backend) {
QObject *stationObj = m_backend->currentStation();

View File

@@ -74,6 +74,8 @@ class MprisPlayerAdaptor : public QDBusAbstractAdaptor
public:
explicit MprisPlayerAdaptor(RadioBackend *backend);
void publishState();
QString playbackStatus() const;
double rate() const;
void setRate(double value);

View File

@@ -4,7 +4,6 @@
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
@@ -20,62 +19,6 @@ ApplicationWindow {
root.hide()
}
Connections {
target: backend
function onRaiseRequested() {
root.show()
root.raise()
root.requestActivate()
}
}
Platform.SystemTrayIcon {
id: systray
visible: true
icon.name: "multimedia-player"
tooltip: qsTr("BearWave")
menu: Platform.Menu {
Platform.MenuItem {
text: backend && backend.player && backend.player.playing ? qsTr("Pause") : qsTr("Play")
onTriggered: {
if (backend && backend.player) {
backend.player.togglePlayPause()
}
}
}
Platform.MenuItem {
text: root.visible ? qsTr("Hide") : qsTr("Show")
onTriggered: {
if (root.visible) {
root.hide()
} else {
root.show()
root.raise()
root.requestActivate()
}
}
}
Platform.MenuSeparator {}
Platform.MenuItem {
text: qsTr("Quit")
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
@@ -116,6 +59,15 @@ ApplicationWindow {
toastPopup.open()
}
function resetSearchFilter() {
searchTimer.stop()
if (searchField.text !== "") {
searchField.text = ""
} else if (backend && backend.filterQuery !== "") {
backend.filterQuery = ""
}
}
function getFilteredCountries() {
if (!backend || !backend.countries) return [];
var query = countrySearchText.toLowerCase().trim();
@@ -160,6 +112,9 @@ ApplicationWindow {
onCurrentPageChanged: {
contentOpacity = 0.85
contentFadeRestart.restart()
if (currentPage !== "search") {
resetSearchFilter()
}
if (currentPage !== "world") {
selectedWorldCategory = ""
selectedWorldType = ""
@@ -485,6 +440,7 @@ ApplicationWindow {
if (backend) {
backend.filterQuery = text
}
searchTimer.restart()
}
onAccepted: {
if (text.length < 2 || !backend) return

View File

@@ -59,8 +59,12 @@ void RadioBackend::setupConnections()
connect(m_radioBrowser, &RadioBrowser::error,
this, [this](const QString &error) {
qWarning() << "RadioBrowser error:" << error;
setLastError(error);
setLoading(false);
if (!m_currentLoadSatisfied) {
setLastError(error);
} else {
setLastError(QString());
}
});
connect(m_player, &BearPlayer::currentStationChanged, this, [this](const QString &name) {
@@ -102,38 +106,46 @@ void RadioBackend::onStationsLoaded(const QList<RadioStation*> &stations)
});
rebuildFilteredStations(false);
syncStationListIndex();
m_currentLoadSatisfied = true;
setLastError(QString());
setLoading(false);
qDebug() << "Loaded" << stations.size() << "stations";
}
void RadioBackend::beginLoad()
{
m_currentLoadSatisfied = false;
setLoading(true);
}
void RadioBackend::loadGermanStations()
{
setLoading(true);
beginLoad();
m_radioBrowser->getGermanStations();
}
void RadioBackend::loadDutchStations()
{
setLoading(true);
beginLoad();
m_radioBrowser->getDutchStations();
}
void RadioBackend::loadTopStations()
{
setLoading(true);
beginLoad();
m_radioBrowser->getTopStations(100);
}
void RadioBackend::loadWorldStations()
{
setLoading(true);
beginLoad();
m_radioBrowser->getWorldStations(200);
}
void RadioBackend::loadCountries()
{
setLoading(true);
beginLoad();
m_radioBrowser->getCountries();
}
@@ -152,6 +164,8 @@ void RadioBackend::onCountriesLoaded(const QVariantList &countries)
return QString::localeAwareCompare(a.toMap()["name"].toString(), b.toMap()["name"].toString()) < 0;
});
emit countriesChanged();
m_currentLoadSatisfied = true;
setLastError(QString());
setLoading(false);
}
@@ -160,7 +174,7 @@ void RadioBackend::loadByTag(const QString &tag)
if (tag.trimmed().isEmpty()) {
return;
}
setLoading(true);
beginLoad();
m_radioBrowser->getByTag(tag.trimmed());
}
@@ -169,7 +183,7 @@ void RadioBackend::loadByCountryCode(const QString &countryCode)
if (countryCode.trimmed().isEmpty()) {
return;
}
setLoading(true);
beginLoad();
m_radioBrowser->getByCountry(countryCode.trimmed().toUpper());
}
@@ -179,7 +193,7 @@ void RadioBackend::searchStations(const QString &query)
return;
}
qDebug() << "RadioBackend: searchStations called with query:" << query;
setLoading(true);
beginLoad();
m_radioBrowser->search(query);
}
@@ -190,6 +204,8 @@ void RadioBackend::playStation(int index)
}
m_currentFromFavorites = false;
m_currentFromHistory = false;
m_standalonePlayback = false;
m_currentIndex = index;
playCurrentSelection();
@@ -202,6 +218,8 @@ void RadioBackend::playFavoriteStation(int index)
}
m_currentFromFavorites = true;
m_currentFromHistory = false;
m_standalonePlayback = false;
m_currentIndex = index;
playCurrentSelection();
@@ -209,13 +227,30 @@ void RadioBackend::playFavoriteStation(int index)
void RadioBackend::playNextStation()
{
if (m_standalonePlayback) {
return;
}
if (m_currentFromHistory) {
if (m_recentStations.isEmpty()) {
return;
}
const int nextIndex = m_currentIndex < 0 ? 0 : m_currentIndex + 1;
if (nextIndex >= m_recentStations.size()) {
return;
}
playHistoryAtIndex(nextIndex, false);
return;
}
const QList<RadioStation*> list = currentList();
if (list.isEmpty()) {
return;
}
if (m_currentIndex < 0) {
m_currentIndex = 0;
} else if (m_currentIndex < list.size() - 1) {
return;
}
if (m_currentIndex < list.size() - 1) {
++m_currentIndex;
}
playCurrentSelection();
@@ -223,32 +258,65 @@ void RadioBackend::playNextStation()
void RadioBackend::playPreviousStation()
{
if (m_standalonePlayback) {
return;
}
if (m_currentFromHistory) {
if (m_currentIndex <= 0) {
return;
}
playHistoryAtIndex(m_currentIndex - 1, false);
return;
}
const QList<RadioStation*> list = currentList();
if (list.isEmpty()) {
return;
}
if (m_currentIndex < 0) {
m_currentIndex = 0;
} else if (m_currentIndex > 0) {
--m_currentIndex;
if (m_currentIndex <= 0) {
return;
}
--m_currentIndex;
playCurrentSelection();
}
bool RadioBackend::hasNextStation() const
{
if (m_standalonePlayback) {
return false;
}
if (m_currentFromHistory) {
if (m_recentStations.isEmpty()) {
return false;
}
if (m_currentIndex < 0) {
return true;
}
return m_currentIndex < m_recentStations.size() - 1;
}
const QList<RadioStation*> list = currentList();
if (list.isEmpty()) {
return false;
}
if (m_currentIndex < 0) {
return true;
return false;
}
return m_currentIndex < list.size() - 1;
}
bool RadioBackend::hasPreviousStation() const
{
if (m_standalonePlayback) {
return false;
}
if (m_currentFromHistory) {
return m_currentIndex > 0;
}
const QList<RadioStation*> list = currentList();
if (list.isEmpty()) {
return false;
@@ -412,39 +480,17 @@ QVariantList RadioBackend::getFavoriteStations() const
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_currentStationUuid = station->uuid();
m_currentStationUrl = url;
emit currentStationChanged();
m_player->playUrl(url, station->name());
return true;
}
const int recentIndex = recentStationIndex(m_recentStations, uuid, urlResolved);
if (recentIndex >= 0) {
playHistoryAtIndex(recentIndex, true);
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_currentStationUuid = station.value(QStringLiteral("uuid")).toString();
m_currentStationUrl = url;
emit currentStationChanged();
m_player->playUrl(url, m_lastStationName);
for (RadioStation *station : m_stations) {
if (matchesStation(station, uuid, urlResolved)) {
const QVariantMap stationData = toVariantMap(station);
recordRecentStation(stationData);
playHistoryAtIndex(0, false);
return true;
}
}
@@ -468,6 +514,12 @@ void RadioBackend::resumeLastStation()
if (m_lastStationUrl.isEmpty()) {
return;
}
m_currentFromFavorites = false;
m_currentFromHistory = false;
m_standalonePlayback = true;
m_currentIndex = -1;
QVariantMap station;
station.insert(QStringLiteral("name"), m_lastStationName.isEmpty() ? tr("Last played") : m_lastStationName);
station.insert(QStringLiteral("urlResolved"), m_lastStationUrl);
@@ -668,6 +720,7 @@ void RadioBackend::rebuildFilteredStations(bool emitFilterSignal)
}
emit stationsChanged();
emit listsChanged();
syncStationListIndex();
}
void RadioBackend::recordRecentStation(const QVariantMap &stationData)
@@ -701,6 +754,54 @@ void RadioBackend::recordRecentStation(const QVariantMap &stationData)
emit listsChanged();
}
void RadioBackend::playHistoryAtIndex(int index, bool updateRecent)
{
if (index < 0 || index >= m_recentStations.size()) {
return;
}
const QVariantMap station = m_recentStations.at(index).toMap();
const QString url = station.value(QStringLiteral("urlResolved")).toString();
if (url.isEmpty()) {
return;
}
m_currentFromFavorites = false;
m_currentFromHistory = true;
m_standalonePlayback = false;
m_currentIndex = index;
m_lastStationName = station.value(QStringLiteral("name")).toString();
m_lastStationUrl = url;
emit resumeStateChanged();
if (updateRecent) {
recordRecentStation(station);
m_currentIndex = 0;
}
saveState();
m_currentStationUuid = station.value(QStringLiteral("uuid")).toString();
m_currentStationUrl = url;
emit currentStationChanged();
m_player->playUrl(url, m_lastStationName.isEmpty() ? tr("Last played") : m_lastStationName);
}
void RadioBackend::syncStationListIndex()
{
if (m_currentFromHistory || m_currentFromFavorites) {
return;
}
m_currentIndex = -1;
for (int i = 0; i < m_filteredStations.size(); ++i) {
if (matchesStation(m_filteredStations[i], m_currentStationUuid, m_currentStationUrl)) {
m_currentIndex = i;
break;
}
}
}
void RadioBackend::playCurrentSelection()
{
const QList<RadioStation*> list = currentList();
@@ -708,6 +809,9 @@ void RadioBackend::playCurrentSelection()
return;
}
m_currentFromHistory = false;
m_standalonePlayback = false;
RadioStation *station = list[m_currentIndex];
QString url = station->urlResolved().isEmpty()
? station->url()
@@ -822,6 +926,29 @@ bool RadioBackend::matchesStation(const RadioStation *station, const QString &uu
return false;
}
bool RadioBackend::matchesStationMap(const QVariantMap &station, const QString &uuid, const QString &urlResolved)
{
const QString stationUuid = station.value(QStringLiteral("uuid")).toString();
const QString stationUrl = station.value(QStringLiteral("urlResolved")).toString();
if (!uuid.isEmpty() && stationUuid == uuid) {
return true;
}
if (uuid.isEmpty() && !urlResolved.isEmpty() && stationUrl == urlResolved) {
return true;
}
return false;
}
int RadioBackend::recentStationIndex(const QVariantList &recent, const QString &uuid, const QString &urlResolved)
{
for (int i = 0; i < recent.size(); ++i) {
if (matchesStationMap(recent.at(i).toMap(), uuid, urlResolved)) {
return i;
}
}
return -1;
}
QString RadioBackend::localizeCountry(const QString &code, const QString &englishName)
{
QLocale locale;

View File

@@ -5,7 +5,6 @@
#define RADIOBACKEND_H
#include <QObject>
#include <QQmlEngine>
#include <QList>
#include <QVariantMap>
#include <QVariantList>
@@ -102,6 +101,8 @@ private:
QList<RadioStation*> m_favorites;
int m_currentIndex = -1;
bool m_currentFromFavorites = false;
bool m_currentFromHistory = false;
bool m_standalonePlayback = false;
bool m_loading = false;
QString m_lastError;
QString m_lastStationName;
@@ -111,8 +112,10 @@ private:
QString m_currentStationUuid;
QString m_currentStationUrl;
QVariantList m_countries;
bool m_currentLoadSatisfied = false;
void setupConnections();
void beginLoad();
void loadFavorites();
void saveFavorites() const;
void loadState();
@@ -124,8 +127,12 @@ private:
void rebuildFilteredStations(bool emitFilterSignal = true);
void recordRecentStation(const QVariantMap &stationData);
void playCurrentSelection();
void playHistoryAtIndex(int index, bool updateRecent);
void syncStationListIndex();
static QVariantMap toVariantMap(const RadioStation *station);
static bool matchesStation(const RadioStation *station, const QString &uuid, const QString &urlResolved);
static bool matchesStationMap(const QVariantMap &station, const QString &uuid, const QString &urlResolved);
static int recentStationIndex(const QVariantList &recent, const QString &uuid, const QString &urlResolved);
static QString localizeCountry(const QString &code, const QString &englishName);
};

View File

@@ -82,29 +82,57 @@ void RadioBrowser::getCountries()
makeRequest("/countries");
}
bool RadioBrowser::isCountriesEndpoint(const QString &endpoint) const
{
return endpoint == QStringLiteral("/countries");
}
void RadioBrowser::emitCachedResponse(const QString &endpoint, const QString &cachePath, int requestGeneration)
{
if (requestGeneration != m_requestGeneration) {
return;
}
QFile cacheFile(cachePath);
if (!cacheFile.exists() || !cacheFile.open(QIODevice::ReadOnly)) {
return;
}
const QByteArray cachedData = cacheFile.readAll();
cacheFile.close();
if (isCountriesEndpoint(endpoint)) {
const QVariantList list = parseCountriesJson(cachedData);
if (!list.isEmpty()) {
emit countriesLoaded(list);
}
return;
}
const QList<RadioStation*> stations = parseJsonResponse(cachedData);
if (!stations.isEmpty()) {
emit stationsLoaded(stations);
}
}
void RadioBrowser::makeRequest(const QString &endpoint)
{
++m_requestGeneration;
const int requestGeneration = m_requestGeneration;
if (m_activeReply) {
QNetworkReply *reply = m_activeReply;
m_activeReply = nullptr;
reply->abort();
reply->deleteLater();
}
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();
if (endpoint == "/countries") {
QVariantList list = parseCountriesJson(cachedData);
if (!list.isEmpty()) {
emit countriesLoaded(list);
}
} else {
QList<RadioStation*> stations = parseJsonResponse(cachedData);
if (!stations.isEmpty()) {
emit stationsLoaded(stations);
}
}
cacheFile.close();
}
emitCachedResponse(endpoint, cachePath, requestGeneration);
QUrl url(m_baseUrl + endpoint);
QNetworkRequest request;
@@ -114,7 +142,10 @@ void RadioBrowser::makeRequest(const QString &endpoint)
QNetworkReply *reply = m_networkManager->get(request);
reply->setProperty("cachePath", cachePath);
reply->setProperty("requestGeneration", requestGeneration);
reply->setProperty("endpoint", endpoint);
m_activeReply = reply;
connect(reply, &QNetworkReply::finished, this, &RadioBrowser::onReplyFinished);
}
@@ -127,17 +158,33 @@ void RadioBrowser::onReplyFinished()
return;
}
if (reply == m_activeReply) {
m_activeReply = nullptr;
}
const int requestGeneration = reply->property("requestGeneration").toInt();
if (requestGeneration != m_requestGeneration) {
reply->deleteLater();
return;
}
if (reply->error() == QNetworkReply::OperationCanceledError) {
reply->deleteLater();
return;
}
if (reply->error() != QNetworkReply::NoError) {
emit error(reply->errorString());
reply->deleteLater();
return;
}
QByteArray data = reply->readAll();
QString cachePath = reply->property("cachePath").toString();
const QByteArray data = reply->readAll();
const QString cachePath = reply->property("cachePath").toString();
const QString endpoint = reply->property("endpoint").toString();
if (reply->url().path().endsWith("/countries")) {
QVariantList list = parseCountriesJson(data);
if (isCountriesEndpoint(endpoint)) {
const QVariantList list = parseCountriesJson(data);
if (!cachePath.isEmpty() && !list.isEmpty()) {
QFile file(cachePath);
if (file.open(QIODevice::WriteOnly)) {
@@ -147,7 +194,7 @@ void RadioBrowser::onReplyFinished()
}
emit countriesLoaded(list);
} else {
QList<RadioStation*> stations = parseJsonResponse(data);
const QList<RadioStation*> stations = parseJsonResponse(data);
if (!cachePath.isEmpty() && !stations.isEmpty()) {
QFile file(cachePath);
if (file.open(QIODevice::WriteOnly)) {

View File

@@ -38,9 +38,13 @@ private slots:
private:
QNetworkAccessManager *m_networkManager = nullptr;
QNetworkReply *m_activeReply = nullptr;
QString m_baseUrl = "https://all.api.radio-browser.info/json";
int m_requestGeneration = 0;
void makeRequest(const QString &endpoint);
void emitCachedResponse(const QString &endpoint, const QString &cachePath, int requestGeneration);
bool isCountriesEndpoint(const QString &endpoint) const;
QList<RadioStation*> parseJsonResponse(const QByteArray &jsonData);
QVariantList parseCountriesJson(const QByteArray &jsonData);
};

144
src/systemtraymanager.cpp Normal file
View File

@@ -0,0 +1,144 @@
// Copyright (c) 2026 Sebastian Palencsar
// SPDX-License-Identifier: GPL-3.0-or-later
#include "systemtraymanager.h"
#include "radiobackend.h"
#include "bearplayer.h"
#include <QApplication>
#include <QAction>
#include <QIcon>
#include <QMenu>
#include <QSystemTrayIcon>
#include <QWindow>
SystemTrayManager::SystemTrayManager(RadioBackend *backend, QWindow *window, QApplication *app, QObject *parent)
: QObject(parent)
, m_backend(backend)
, m_window(window)
, m_app(app)
{
if (!QSystemTrayIcon::isSystemTrayAvailable() || !m_backend || !m_window || !m_app) {
return;
}
setupTrayIcon();
}
SystemTrayManager::~SystemTrayManager()
{
if (m_trayIcon) {
m_trayIcon->hide();
}
}
bool SystemTrayManager::isAvailable() const
{
return m_trayIcon != nullptr;
}
void SystemTrayManager::setupTrayIcon()
{
m_trayIcon = new QSystemTrayIcon(this);
QIcon icon = QIcon::fromTheme(QStringLiteral("de.nerdbear.bearwave"));
if (icon.isNull()) {
icon = QIcon::fromTheme(QStringLiteral("multimedia-player"));
}
m_trayIcon->setIcon(icon);
m_trayIcon->setToolTip(tr("BearWave"));
m_menu = new QMenu();
m_playPauseAction = m_menu->addAction(QString());
connect(m_playPauseAction, &QAction::triggered, this, &SystemTrayManager::togglePlayback);
m_visibilityAction = m_menu->addAction(QString());
connect(m_visibilityAction, &QAction::triggered, this, &SystemTrayManager::toggleWindowVisibility);
m_menu->addSeparator();
m_quitAction = m_menu->addAction(tr("Quit"));
connect(m_quitAction, &QAction::triggered, this, &SystemTrayManager::quitApplication);
m_trayIcon->setContextMenu(m_menu);
connect(m_trayIcon, &QSystemTrayIcon::activated, this, &SystemTrayManager::onTrayActivated);
BearPlayer *player = m_backend->player();
if (player) {
connect(player, &BearPlayer::playingChanged, this, &SystemTrayManager::updatePlayPauseAction);
}
connect(m_window, &QWindow::visibilityChanged, this, &SystemTrayManager::updateVisibilityAction);
connect(m_backend, &RadioBackend::raiseRequested, this, &SystemTrayManager::showAndActivateWindow);
updatePlayPauseAction();
updateVisibilityAction();
m_trayIcon->show();
}
void SystemTrayManager::updatePlayPauseAction()
{
if (!m_playPauseAction || !m_backend || !m_backend->player()) {
return;
}
m_playPauseAction->setText(m_backend->player()->playing() ? tr("Pause") : tr("Play"));
}
void SystemTrayManager::updateVisibilityAction()
{
if (!m_visibilityAction || !m_window) {
return;
}
m_visibilityAction->setText(m_window->isVisible() ? tr("Hide") : tr("Show"));
}
void SystemTrayManager::togglePlayback()
{
if (m_backend && m_backend->player()) {
m_backend->player()->togglePlayPause();
}
}
void SystemTrayManager::toggleWindowVisibility()
{
if (!m_window) {
return;
}
if (m_window->isVisible()) {
m_window->hide();
} else {
showAndActivateWindow();
}
}
void SystemTrayManager::showAndActivateWindow()
{
if (!m_window) {
return;
}
m_window->show();
m_window->raise();
m_window->requestActivate();
}
void SystemTrayManager::quitApplication()
{
if (m_app) {
m_app->quit();
}
}
void SystemTrayManager::onTrayActivated(QSystemTrayIcon::ActivationReason reason)
{
if (reason == QSystemTrayIcon::Trigger) {
toggleWindowVisibility();
}
}

48
src/systemtraymanager.h Normal file
View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Sebastian Palencsar
// SPDX-License-Identifier: GPL-3.0-or-later
#ifndef SYSTEMTRAYMANAGER_H
#define SYSTEMTRAYMANAGER_H
#include <QObject>
#include <QSystemTrayIcon>
class QWindow;
class QMenu;
class QAction;
class QApplication;
class RadioBackend;
class SystemTrayManager : public QObject
{
Q_OBJECT
public:
SystemTrayManager(RadioBackend *backend, QWindow *window, QApplication *app, QObject *parent = nullptr);
~SystemTrayManager() override;
bool isAvailable() const;
private slots:
void updatePlayPauseAction();
void updateVisibilityAction();
void togglePlayback();
void toggleWindowVisibility();
void showAndActivateWindow();
void quitApplication();
void onTrayActivated(QSystemTrayIcon::ActivationReason reason);
private:
void setupTrayIcon();
RadioBackend *m_backend = nullptr;
QWindow *m_window = nullptr;
QApplication *m_app = nullptr;
QSystemTrayIcon *m_trayIcon = nullptr;
QMenu *m_menu = nullptr;
QAction *m_playPauseAction = nullptr;
QAction *m_visibilityAction = nullptr;
QAction *m_quitAction = nullptr;
};
#endif