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 \ pacman -S --noconfirm --needed \
base-devel \ base-devel \
cmake \ cmake \
extra-cmake-modules \
git \ git \
qt6-base \ qt6-base \
qt6-declarative \ qt6-declarative \
qt6-tools \ qt6-tools \
kirigami \
kcoreaddons \
ki18n \
qt6-multimedia \ qt6-multimedia \
qt6-multimedia-ffmpeg qt6-multimedia-ffmpeg
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake -S . -B build -DCMAKE_BUILD_TYPE=Release

View File

@@ -12,16 +12,15 @@ set(CMAKE_AUTOUIC ON)
# Qt6 # Qt6
find_package(Qt6 REQUIRED COMPONENTS Core DBus Quick QuickControls2 Widgets LinguistTools Multimedia) 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 src directory
add_subdirectory(src) 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 and install desktop file with correct binary path
configure_file( configure_file(
${CMAKE_CURRENT_SOURCE_DIR}/de.nerdbear.bearwave.desktop.in ${CMAKE_CURRENT_SOURCE_DIR}/de.nerdbear.bearwave.desktop.in

View File

@@ -1,6 +1,6 @@
# Contributing to BearWave # 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: The project favors:

View File

@@ -6,7 +6,7 @@ pkgdesc="KDE-focused desktop internet radio app"
arch=('x86_64') arch=('x86_64')
url="https://github.com/spalencsar/bearwave" url="https://github.com/spalencsar/bearwave"
license=('GPL-3.0-or-later') 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') makedepends=('cmake' 'extra-cmake-modules' 'git' 'ninja')
provides=('bearwave') provides=('bearwave')
conflicts=('bearwave') conflicts=('bearwave')

View File

@@ -1,6 +1,6 @@
# BearWave # 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. 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 - Arch Linux
- KDE Plasma 6 - KDE Plasma 6
- Qt 6 - Qt 6
- KDE Frameworks 6
- Qt6 Multimedia - Qt6 Multimedia
If support for other distributions becomes reliable, they should be added here explicitly instead of implied. 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 ### Arch Linux
```bash ```bash
sudo pacman -S cmake extra-cmake-modules qt6-base qt6-declarative qt6-tools \ sudo pacman -S cmake qt6-base qt6-declarative qt6-tools \
kirigami qt6-multimedia qt6-multimedia-ffmpeg qt6-multimedia qt6-multimedia-ffmpeg
``` ```
### KDE Neon / Ubuntu-based ### KDE Neon / Ubuntu-based
```bash ```bash
sudo apt install cmake ninja-build qt6-base-dev qt6-declarative-dev qt6-tools-dev \ 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. 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 bearwavecontroladaptor.cpp
notificationmanager.cpp notificationmanager.cpp
icyreader.cpp icyreader.cpp
systemtraymanager.cpp
) )
# Create executable # Create executable
@@ -45,9 +46,6 @@ target_link_libraries(bearwave PRIVATE
Qt6::QuickControls2 Qt6::QuickControls2
Qt6::Widgets Qt6::Widgets
Qt6::Multimedia Qt6::Multimedia
KF6::Kirigami
KF6::I18n
KF6::CoreAddons
) )
# Install # Install

View File

@@ -5,16 +5,19 @@
#include <QDBusConnection> #include <QDBusConnection>
#include <QDBusConnectionInterface> #include <QDBusConnectionInterface>
#include <QDBusMessage> #include <QDBusMessage>
#include <QDebug>
#include <QIcon> #include <QIcon>
#include <QLocale> #include <QLocale>
#include <QQmlApplicationEngine> #include <QQmlApplicationEngine>
#include <QQmlContext> #include <QQmlContext>
#include <QQuickWindow>
#include <QTranslator> #include <QTranslator>
#include "radiobackend.h" #include "radiobackend.h"
#include "mprisadaptor.h" #include "mprisadaptor.h"
#include "bearwavecontroladaptor.h" #include "bearwavecontroladaptor.h"
#include "notificationmanager.h" #include "notificationmanager.h"
#include "systemtraymanager.h"
int main(int argc, char *argv[]) int main(int argc, char *argv[])
@@ -50,17 +53,22 @@ int main(int argc, char *argv[])
RadioBackend backend; RadioBackend backend;
engine.rootContext()->setContextProperty("radioBackend", &backend); engine.rootContext()->setContextProperty("radioBackend", &backend);
MprisRootAdaptor mprisRoot(&backend, &app); auto *mprisRoot = new MprisRootAdaptor(&backend, &app);
MprisPlayerAdaptor mprisPlayer(&backend); auto *mprisPlayer = new MprisPlayerAdaptor(&backend);
BearWaveControlAdaptor controlAdaptor(&backend); auto *controlAdaptor = new BearWaveControlAdaptor(&backend);
NotificationManager notificationManager(&backend); auto *notificationManager = new NotificationManager(&backend, &backend);
Q_UNUSED(mprisRoot) Q_UNUSED(mprisRoot)
Q_UNUSED(mprisPlayer)
Q_UNUSED(controlAdaptor) Q_UNUSED(controlAdaptor)
Q_UNUSED(notificationManager) Q_UNUSED(notificationManager)
sessionBus.registerObject(QStringLiteral("/org/mpris/MediaPlayer2"), &backend, QDBusConnection::ExportAdaptors); if (!sessionBus.registerObject(QStringLiteral("/org/mpris/MediaPlayer2"), &backend, QDBusConnection::ExportAdaptors)) {
sessionBus.registerService(QStringLiteral("org.mpris.MediaPlayer2.bearwave")); 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")); const QUrl url(QStringLiteral("qrc:/Main.qml"));
QObject::connect(&engine, &QQmlApplicationEngine::objectCreated, QObject::connect(&engine, &QQmlApplicationEngine::objectCreated,
@@ -77,5 +85,14 @@ int main(int argc, char *argv[])
return EXIT_FAILURE; 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(); return app.exec();
} }

View File

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

View File

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

View File

@@ -4,7 +4,6 @@
import QtQuick 2.15 import QtQuick 2.15
import QtQuick.Controls 2.15 import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15 import QtQuick.Layouts 1.15
import Qt.labs.platform 1.1 as Platform
ApplicationWindow { ApplicationWindow {
id: root id: root
@@ -20,62 +19,6 @@ ApplicationWindow {
root.hide() 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 currentPage: "top"
property var backend: (typeof radioBackend !== "undefined" ? radioBackend : null) property var backend: (typeof radioBackend !== "undefined" ? radioBackend : null)
property bool compactMode: width < 780 property bool compactMode: width < 780
@@ -116,6 +59,15 @@ ApplicationWindow {
toastPopup.open() toastPopup.open()
} }
function resetSearchFilter() {
searchTimer.stop()
if (searchField.text !== "") {
searchField.text = ""
} else if (backend && backend.filterQuery !== "") {
backend.filterQuery = ""
}
}
function getFilteredCountries() { function getFilteredCountries() {
if (!backend || !backend.countries) return []; if (!backend || !backend.countries) return [];
var query = countrySearchText.toLowerCase().trim(); var query = countrySearchText.toLowerCase().trim();
@@ -160,6 +112,9 @@ ApplicationWindow {
onCurrentPageChanged: { onCurrentPageChanged: {
contentOpacity = 0.85 contentOpacity = 0.85
contentFadeRestart.restart() contentFadeRestart.restart()
if (currentPage !== "search") {
resetSearchFilter()
}
if (currentPage !== "world") { if (currentPage !== "world") {
selectedWorldCategory = "" selectedWorldCategory = ""
selectedWorldType = "" selectedWorldType = ""
@@ -485,6 +440,7 @@ ApplicationWindow {
if (backend) { if (backend) {
backend.filterQuery = text backend.filterQuery = text
} }
searchTimer.restart()
} }
onAccepted: { onAccepted: {
if (text.length < 2 || !backend) return if (text.length < 2 || !backend) return

View File

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

View File

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

View File

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

View File

@@ -38,9 +38,13 @@ private slots:
private: private:
QNetworkAccessManager *m_networkManager = nullptr; QNetworkAccessManager *m_networkManager = nullptr;
QNetworkReply *m_activeReply = nullptr;
QString m_baseUrl = "https://all.api.radio-browser.info/json"; QString m_baseUrl = "https://all.api.radio-browser.info/json";
int m_requestGeneration = 0;
void makeRequest(const QString &endpoint); 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); QList<RadioStation*> parseJsonResponse(const QByteArray &jsonData);
QVariantList parseCountriesJson(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> <translation>Zuletzt gehört</translation>
</message> </message>
</context> </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> <context>
<name>RadioBrowser</name> <name>RadioBrowser</name>
<message> <message>