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

@@ -27,14 +27,10 @@ jobs:
pacman -S --noconfirm --needed \
base-devel \
cmake \
extra-cmake-modules \
git \
qt6-base \
qt6-declarative \
qt6-tools \
kirigami \
kcoreaddons \
ki18n \
qt6-multimedia \
qt6-multimedia-ffmpeg
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release

View File

@@ -12,16 +12,15 @@ set(CMAKE_AUTOUIC ON)
# Qt6
find_package(Qt6 REQUIRED COMPONENTS Core DBus Quick QuickControls2 Widgets LinguistTools Multimedia)
# KDE Frameworks
find_package(KF6Kirigami REQUIRED)
find_package(KF6I18n REQUIRED)
find_package(KF6CoreAddons REQUIRED)
# Add src directory
add_subdirectory(src)
option(BEARWAVE_BUILD_TESTS "Build BearWave unit tests" ON)
if(BEARWAVE_BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
endif()
# Configure and install desktop file with correct binary path
configure_file(
${CMAKE_CURRENT_SOURCE_DIR}/de.nerdbear.bearwave.desktop.in

View File

@@ -1,6 +1,6 @@
# Contributing to BearWave
BearWave is a KDE-focused desktop internet radio app built with C++/Qt 6, QML, KDE Frameworks, and Phonon.
BearWave is a KDE-focused desktop internet radio app built with C++/Qt 6, QML, and QtMultimedia.
The project favors:

View File

@@ -6,7 +6,7 @@ pkgdesc="KDE-focused desktop internet radio app"
arch=('x86_64')
url="https://github.com/spalencsar/bearwave"
license=('GPL-3.0-or-later')
depends=('qt6-base' 'qt6-declarative' 'kirigami' 'ki18n' 'kcoreaddons' 'qt6-multimedia' 'qt6-multimedia-ffmpeg')
depends=('qt6-base' 'qt6-declarative' 'qt6-multimedia' 'qt6-multimedia-ffmpeg')
makedepends=('cmake' 'extra-cmake-modules' 'git' 'ninja')
provides=('bearwave')
conflicts=('bearwave')

View File

@@ -1,6 +1,6 @@
# BearWave
KDE-focused desktop internet radio app for Linux, built with Qt 6, QML, KDE Frameworks, and QtMultimedia.
KDE-focused desktop internet radio app for Linux, built with Qt 6, QML, and QtMultimedia.
BearWave is designed for fast station browsing, simple playback controls, favorites, resume support, tray behavior, and clean Plasma integration without turning into a heavy media suite.
@@ -134,7 +134,6 @@ BearWave is currently aligned with and tested primarily against:
- Arch Linux
- KDE Plasma 6
- Qt 6
- KDE Frameworks 6
- Qt6 Multimedia
If support for other distributions becomes reliable, they should be added here explicitly instead of implied.
@@ -169,15 +168,15 @@ If you want the least surprising path today, use either:
### Arch Linux
```bash
sudo pacman -S cmake extra-cmake-modules qt6-base qt6-declarative qt6-tools \
kirigami qt6-multimedia qt6-multimedia-ffmpeg
sudo pacman -S cmake qt6-base qt6-declarative qt6-tools \
qt6-multimedia qt6-multimedia-ffmpeg
```
### KDE Neon / Ubuntu-based
```bash
sudo apt install cmake ninja-build qt6-base-dev qt6-declarative-dev qt6-tools-dev \
libkf6kirigami-dev libkf6i18n-dev libkf6coreaddons-dev qt6-multimedia-dev
qt6-multimedia-dev
```
Note: exact package names can vary between distro releases, and non-Arch dependency sets should currently be treated as best-effort guidance rather than a guaranteed tested path.

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

48
tests/CMakeLists.txt Normal file
View File

@@ -0,0 +1,48 @@
find_package(Qt6 REQUIRED COMPONENTS Test)
set(TEST_SOURCES
radiobackend_playback_test.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../src/radiobackend.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../src/radiostation.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../src/radiobrowser.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../src/bearplayer.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../src/coverartfetcher.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../src/icyreader.cpp
)
add_executable(radiobackend_playback_test ${TEST_SOURCES})
target_include_directories(radiobackend_playback_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/../src
)
target_link_libraries(radiobackend_playback_test PRIVATE
Qt6::Core
Qt6::Network
Qt6::Test
Qt6::Widgets
Qt6::Multimedia
)
add_test(NAME radiobackend_playback_test COMMAND radiobackend_playback_test)
set(RACE_TEST_SOURCES
radiobrowser_race_test.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../src/radiobrowser.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../src/radiostation.cpp
)
add_executable(radiobrowser_race_test ${RACE_TEST_SOURCES})
target_include_directories(radiobrowser_race_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/../src
)
target_link_libraries(radiobrowser_race_test PRIVATE
Qt6::Core
Qt6::Network
Qt6::Test
Qt6::Widgets
)
add_test(NAME radiobrowser_race_test COMMAND radiobrowser_race_test)

View File

@@ -0,0 +1,306 @@
// Copyright (c) 2026 Sebastian Palencsar
// SPDX-License-Identifier: GPL-3.0-or-later
#include <QtTest>
#include <QApplication>
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QTemporaryDir>
#include <QCryptographicHash>
#include <QEventLoop>
#include <QTimer>
#include "radiobackend.h"
class RadioBackendPlaybackTest : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void init();
void history_next_navigates_older_entries();
void resume_disables_list_navigation();
void station_list_next_uses_filtered_index();
void history_next_does_not_jump_to_station_list();
void cached_load_keeps_error_clear_after_network_failure();
void list_reload_resyncs_index_for_playing_station();
void list_next_disabled_when_playing_station_not_in_new_list();
};
namespace {
QTemporaryDir *s_tempHome = nullptr;
QString cachePathForEndpoint(const QString &endpoint)
{
const QString cacheDir = QDir::homePath() + QStringLiteral("/.cache/bearwave/api_cache");
const QString hash = QString(QCryptographicHash::hash(endpoint.toUtf8(), QCryptographicHash::Md5).toHex());
return cacheDir + QStringLiteral("/") + hash + QStringLiteral(".json");
}
void writeStationCache(const QString &endpoint, const QString &name,
const QString &url = QString(), const QString &uuid = QString())
{
QDir().mkpath(QDir::homePath() + QStringLiteral("/.cache/bearwave/api_cache"));
QJsonObject station;
station.insert(QStringLiteral("name"), name);
station.insert(QStringLiteral("url_resolved"),
url.isEmpty() ? QStringLiteral("http://example.com/") + name : url);
station.insert(QStringLiteral("country"), QStringLiteral("Germany"));
station.insert(QStringLiteral("stationuuid"), uuid.isEmpty() ? name : uuid);
QJsonArray array;
array.append(station);
QFile file(cachePathForEndpoint(endpoint));
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return;
}
file.write(QJsonDocument(array).toJson());
file.close();
}
void writeStationListCache(const QString &endpoint, const QJsonArray &stations)
{
QDir().mkpath(QDir::homePath() + QStringLiteral("/.cache/bearwave/api_cache"));
QFile file(cachePathForEndpoint(endpoint));
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return;
}
file.write(QJsonDocument(stations).toJson());
file.close();
}
int visibleIndexForUrl(RadioBackend &backend, const QString &url)
{
const QList<QObject*> stations = backend.stations();
for (int i = 0; i < stations.size(); ++i) {
QObject *station = stations[i];
const QString resolved = station->property("urlResolved").toString();
if (resolved == url) {
return i;
}
}
return -1;
}
}
void RadioBackendPlaybackTest::initTestCase()
{
s_tempHome = new QTemporaryDir();
QVERIFY(s_tempHome->isValid());
qputenv("HOME", s_tempHome->path().toUtf8());
const QString configDir = QDir::homePath() + QStringLiteral("/.config/bearwave");
QDir().mkpath(configDir);
QJsonObject state;
state.insert(QStringLiteral("lastStationName"), QStringLiteral("Station A"));
state.insert(QStringLiteral("lastStationUrl"), QStringLiteral("http://example.com/a"));
state.insert(QStringLiteral("volume"), 0.5);
QJsonArray recent;
recent.append(QJsonObject{
{QStringLiteral("name"), QStringLiteral("Station A")},
{QStringLiteral("uuid"), QStringLiteral("uuid-a")},
{QStringLiteral("urlResolved"), QStringLiteral("http://example.com/a")}
});
recent.append(QJsonObject{
{QStringLiteral("name"), QStringLiteral("Station B")},
{QStringLiteral("uuid"), QStringLiteral("uuid-b")},
{QStringLiteral("urlResolved"), QStringLiteral("http://example.com/b")}
});
recent.append(QJsonObject{
{QStringLiteral("name"), QStringLiteral("Station C")},
{QStringLiteral("uuid"), QStringLiteral("uuid-c")},
{QStringLiteral("urlResolved"), QStringLiteral("http://example.com/c")}
});
state.insert(QStringLiteral("recentStations"), recent);
QFile file(configDir + QStringLiteral("/state.json"));
QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Truncate));
file.write(QJsonDocument(state).toJson());
file.close();
}
void RadioBackendPlaybackTest::init()
{
initTestCase();
}
void RadioBackendPlaybackTest::history_next_navigates_older_entries()
{
RadioBackend backend;
QVERIFY(backend.playRecentByUuid(QStringLiteral("uuid-b"), QString()));
QCOMPARE(backend.currentStationUrl(), QStringLiteral("http://example.com/b"));
QVERIFY(backend.hasNextStation());
QVERIFY(!backend.hasPreviousStation());
backend.playNextStation();
QCOMPARE(backend.currentStationUrl(), QStringLiteral("http://example.com/a"));
QVERIFY(backend.hasPreviousStation());
}
void RadioBackendPlaybackTest::resume_disables_list_navigation()
{
RadioBackend backend;
backend.resumeLastStation();
QCOMPARE(backend.currentStationUrl(), QStringLiteral("http://example.com/a"));
QVERIFY(!backend.hasNextStation());
QVERIFY(!backend.hasPreviousStation());
backend.playNextStation();
QCOMPARE(backend.currentStationUrl(), QStringLiteral("http://example.com/a"));
}
void RadioBackendPlaybackTest::station_list_next_uses_filtered_index()
{
RadioBackend backend;
backend.addManualStation(QStringLiteral("Station One"), QStringLiteral("http://example.com/one"), QStringLiteral("DE"));
backend.addManualStation(QStringLiteral("Station Two"), QStringLiteral("http://example.com/two"), QStringLiteral("DE"));
backend.playStation(0);
QCOMPARE(backend.currentStationUrl(), QStringLiteral("http://example.com/two"));
QVERIFY(backend.hasNextStation());
backend.playNextStation();
QCOMPARE(backend.currentStationUrl(), QStringLiteral("http://example.com/one"));
}
void RadioBackendPlaybackTest::history_next_does_not_jump_to_station_list()
{
RadioBackend backend;
// Loaded station list present, but playback started from history (not playStation()).
backend.addManualStation(QStringLiteral("Station X"), QStringLiteral("http://example.com/x"), QStringLiteral("DE"));
backend.addManualStation(QStringLiteral("Station Y"), QStringLiteral("http://example.com/y"), QStringLiteral("DE"));
QVERIFY(backend.playRecentByUuid(QStringLiteral("uuid-c"), QString()));
QCOMPARE(backend.currentStationUrl(), QStringLiteral("http://example.com/c"));
backend.playNextStation();
QCOMPARE(backend.currentStationUrl(), QStringLiteral("http://example.com/a"));
QVERIFY(backend.currentStationUrl() != QStringLiteral("http://example.com/x"));
QVERIFY(backend.currentStationUrl() != QStringLiteral("http://example.com/y"));
}
void RadioBackendPlaybackTest::cached_load_keeps_error_clear_after_network_failure()
{
writeStationCache(QStringLiteral("/stations/topvote/100"), QStringLiteral("Cached Top Station"));
RadioBackend backend;
QVERIFY(QFile::exists(QDir::homePath() + QStringLiteral("/.cache/bearwave/api_cache")));
backend.loadTopStations();
QTRY_VERIFY_WITH_TIMEOUT(backend.stations().size() > 0, 2000);
QCOMPARE(backend.lastError(), QString());
QEventLoop loop;
QTimer::singleShot(11000, &loop, &QEventLoop::quit);
loop.exec();
QCOMPARE(backend.lastError(), QString());
QVERIFY(backend.stations().size() > 0);
}
void RadioBackendPlaybackTest::list_reload_resyncs_index_for_playing_station()
{
const QString topEndpoint = QStringLiteral("/stations/topvote/100");
const QString deEndpoint = QStringLiteral("/stations/bycountrycodeexact/DE?limit=50&order=votes&reverse=true");
const QString sharedUrl = QStringLiteral("http://example.com/shared");
QJsonArray topStations;
topStations.append(QJsonObject{
{QStringLiteral("name"), QStringLiteral("Alpha Top")},
{QStringLiteral("url_resolved"), QStringLiteral("http://example.com/alpha")},
{QStringLiteral("country"), QStringLiteral("Global")},
{QStringLiteral("stationuuid"), QStringLiteral("uuid-alpha")}
});
topStations.append(QJsonObject{
{QStringLiteral("name"), QStringLiteral("Shared Station")},
{QStringLiteral("url_resolved"), sharedUrl},
{QStringLiteral("country"), QStringLiteral("Global")},
{QStringLiteral("stationuuid"), QStringLiteral("uuid-shared")}
});
topStations.append(QJsonObject{
{QStringLiteral("name"), QStringLiteral("Zulu Top")},
{QStringLiteral("url_resolved"), QStringLiteral("http://example.com/zulu")},
{QStringLiteral("country"), QStringLiteral("Global")},
{QStringLiteral("stationuuid"), QStringLiteral("uuid-zulu")}
});
writeStationListCache(topEndpoint, topStations);
QJsonArray deStations;
deStations.append(QJsonObject{
{QStringLiteral("name"), QStringLiteral("DE Only")},
{QStringLiteral("url_resolved"), QStringLiteral("http://example.com/deonly")},
{QStringLiteral("country"), QStringLiteral("Germany")},
{QStringLiteral("stationuuid"), QStringLiteral("uuid-deonly")}
});
deStations.append(QJsonObject{
{QStringLiteral("name"), QStringLiteral("Shared Station")},
{QStringLiteral("url_resolved"), sharedUrl},
{QStringLiteral("country"), QStringLiteral("Germany")},
{QStringLiteral("stationuuid"), QStringLiteral("uuid-shared")}
});
writeStationListCache(deEndpoint, deStations);
RadioBackend backend;
backend.loadTopStations();
QTRY_VERIFY_WITH_TIMEOUT(backend.stations().size() == 3, 2000);
const int sharedTopIndex = visibleIndexForUrl(backend, sharedUrl);
QVERIFY(sharedTopIndex >= 0);
backend.playStation(sharedTopIndex);
QCOMPARE(backend.currentStationUrl(), sharedUrl);
QVERIFY(backend.hasNextStation());
backend.loadGermanStations();
QTRY_VERIFY_WITH_TIMEOUT(backend.stations().size() == 2, 2000);
QCOMPARE(backend.currentStationUrl(), sharedUrl);
QVERIFY(!backend.hasNextStation());
QVERIFY(backend.hasPreviousStation());
backend.playNextStation();
QCOMPARE(backend.currentStationUrl(), sharedUrl);
backend.playPreviousStation();
QCOMPARE(backend.currentStationUrl(), QStringLiteral("http://example.com/deonly"));
}
void RadioBackendPlaybackTest::list_next_disabled_when_playing_station_not_in_new_list()
{
const QString topEndpoint = QStringLiteral("/stations/topvote/100");
const QString deEndpoint = QStringLiteral("/stations/bycountrycodeexact/DE?limit=50&order=votes&reverse=true");
const QString topUrl = QStringLiteral("http://example.com/toponly");
writeStationCache(topEndpoint, QStringLiteral("Top Only"), topUrl, QStringLiteral("uuid-top"));
writeStationCache(deEndpoint, QStringLiteral("DE Only"), QStringLiteral("http://example.com/deonly"),
QStringLiteral("uuid-de"));
RadioBackend backend;
backend.loadTopStations();
QTRY_VERIFY_WITH_TIMEOUT(backend.stations().size() == 1, 2000);
backend.playStation(0);
QCOMPARE(backend.currentStationUrl(), topUrl);
QVERIFY(!backend.hasNextStation());
backend.loadGermanStations();
QTRY_VERIFY_WITH_TIMEOUT(backend.stations().size() == 1, 2000);
QCOMPARE(backend.currentStationUrl(), topUrl);
QVERIFY(!backend.hasNextStation());
QVERIFY(!backend.hasPreviousStation());
backend.playNextStation();
QCOMPARE(backend.currentStationUrl(), topUrl);
}
QTEST_MAIN(RadioBackendPlaybackTest)
#include "radiobackend_playback_test.moc"

View File

@@ -0,0 +1,104 @@
// Copyright (c) 2026 Sebastian Palencsar
// SPDX-License-Identifier: GPL-3.0-or-later
#include <QtTest>
#include <QApplication>
#include <QSignalSpy>
#include <QTemporaryDir>
#include <QDir>
#include <QFile>
#include <QCryptographicHash>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QEventLoop>
#include <QTimer>
#include "radiobrowser.h"
class RadioBrowserRaceTest : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void latest_request_wins_after_rapid_category_switch();
};
namespace {
QTemporaryDir *s_tempHome = nullptr;
QString cachePathForEndpoint(const QString &endpoint)
{
const QString cacheDir = QDir::homePath() + QStringLiteral("/.cache/bearwave/api_cache");
const QString hash = QString(QCryptographicHash::hash(endpoint.toUtf8(), QCryptographicHash::Md5).toHex());
return cacheDir + QStringLiteral("/") + hash + QStringLiteral(".json");
}
void writeStationCache(const QString &endpoint, const QString &name, const QString &country)
{
QDir().mkpath(QDir::homePath() + QStringLiteral("/.cache/bearwave/api_cache"));
QJsonObject station;
station.insert(QStringLiteral("name"), name);
station.insert(QStringLiteral("url_resolved"), QStringLiteral("http://example.com/") + name);
station.insert(QStringLiteral("country"), country);
station.insert(QStringLiteral("stationuuid"), name);
QJsonArray array;
array.append(station);
QFile file(cachePathForEndpoint(endpoint));
QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Truncate));
file.write(QJsonDocument(array).toJson());
file.close();
}
}
void RadioBrowserRaceTest::initTestCase()
{
s_tempHome = new QTemporaryDir();
QVERIFY(s_tempHome->isValid());
qputenv("HOME", s_tempHome->path().toUtf8());
writeStationCache(QStringLiteral("/stations/topvote/5"), QStringLiteral("Top Station"), QStringLiteral("Global"));
writeStationCache(QStringLiteral("/stations/bycountrycodeexact/DE?limit=50&order=votes&reverse=true"),
QStringLiteral("DE Station"), QStringLiteral("Germany"));
writeStationCache(QStringLiteral("/stations/bycountrycodeexact/NL?limit=50&order=votes&reverse=true"),
QStringLiteral("NL Station"), QStringLiteral("The Netherlands"));
}
void RadioBrowserRaceTest::latest_request_wins_after_rapid_category_switch()
{
RadioBrowser browser;
QSignalSpy stationsSpy(&browser, &RadioBrowser::stationsLoaded);
browser.getTopStations(5);
browser.getGermanStations();
browser.getDutchStations();
QCOMPARE(stationsSpy.count(), 3);
const auto lastVariant = stationsSpy.at(2).at(0);
const QList<RadioStation*> lastStations = lastVariant.value<QList<RadioStation*>>();
QVERIFY(!lastStations.isEmpty());
QCOMPARE(lastStations.first()->name(), QStringLiteral("NL Station"));
QEventLoop loop;
QTimer::singleShot(2000, &loop, &QEventLoop::quit);
loop.exec();
const auto finalVariant = stationsSpy.takeLast().at(0);
const QList<RadioStation*> finalStations = finalVariant.value<QList<RadioStation*>>();
QVERIFY(!finalStations.isEmpty());
const QString finalName = finalStations.first()->name();
const QString finalCountry = finalStations.first()->country();
QVERIFY(finalName != QStringLiteral("Top Station"));
QVERIFY(finalName != QStringLiteral("DE Station"));
QVERIFY(finalCountry.contains(QStringLiteral("Netherlands"), Qt::CaseInsensitive)
|| finalName == QStringLiteral("NL Station"));
}
QTEST_MAIN(RadioBrowserRaceTest)
#include "radiobrowser_race_test.moc"

View File

@@ -279,6 +279,33 @@
<translation>Zuletzt gehört</translation>
</message>
</context>
<context>
<name>SystemTrayManager</name>
<message>
<source>BearWave</source>
<translation>BearWave</translation>
</message>
<message>
<source>Pause</source>
<translation>Pause</translation>
</message>
<message>
<source>Play</source>
<translation>Wiedergabe</translation>
</message>
<message>
<source>Hide</source>
<translation>Verstecken</translation>
</message>
<message>
<source>Show</source>
<translation>Zeigen</translation>
</message>
<message>
<source>Quit</source>
<translation>Beenden</translation>
</message>
</context>
<context>
<name>RadioBrowser</name>
<message>