From 1c736b97a61850381ccad60db007f8c03b626f27 Mon Sep 17 00:00:00 2001 From: Sebastian Palencsar Date: Sat, 20 Jun 2026 14:06:48 +0200 Subject: [PATCH] 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 --- .github/workflows/build.yml | 4 - CMakeLists.txt | 13 +- CONTRIBUTING.md | 2 +- PKGBUILD | 2 +- README.md | 9 +- src/CMakeLists.txt | 4 +- src/main.cpp | 31 ++- src/mprisadaptor.cpp | 25 ++- src/mprisadaptor.h | 2 + src/qml/Main.qml | 70 ++---- src/radiobackend.cpp | 221 +++++++++++++++---- src/radiobackend.h | 9 +- src/radiobrowser.cpp | 89 ++++++-- src/radiobrowser.h | 4 + src/systemtraymanager.cpp | 144 +++++++++++++ src/systemtraymanager.h | 48 +++++ tests/CMakeLists.txt | 48 +++++ tests/radiobackend_playback_test.cpp | 306 +++++++++++++++++++++++++++ tests/radiobrowser_race_test.cpp | 104 +++++++++ translations/bearwave_de.ts | 27 +++ 20 files changed, 1007 insertions(+), 155 deletions(-) create mode 100644 src/systemtraymanager.cpp create mode 100644 src/systemtraymanager.h create mode 100644 tests/CMakeLists.txt create mode 100644 tests/radiobackend_playback_test.cpp create mode 100644 tests/radiobrowser_race_test.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 21ee654..dd556de 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index 8d03160..bf4dfaf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ea7d6a1..636e155 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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: diff --git a/PKGBUILD b/PKGBUILD index c06c7b2..8055c91 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -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') diff --git a/README.md b/README.md index 434c70d..dd20baf 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 23b8447..37a961f 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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 diff --git a/src/main.cpp b/src/main.cpp index c8b6753..e8c9708 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,16 +5,19 @@ #include #include #include +#include #include #include #include #include +#include #include #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(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(); } diff --git a/src/mprisadaptor.cpp b/src/mprisadaptor.cpp index ac85bd8..6f5f34d 100644 --- a/src/mprisadaptor.cpp +++ b/src/mprisadaptor.cpp @@ -11,6 +11,7 @@ #include #include #include +#include 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(); diff --git a/src/mprisadaptor.h b/src/mprisadaptor.h index 48c27e1..e80aed6 100644 --- a/src/mprisadaptor.h +++ b/src/mprisadaptor.h @@ -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); diff --git a/src/qml/Main.qml b/src/qml/Main.qml index 6de13e8..361d587 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -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 diff --git a/src/radiobackend.cpp b/src/radiobackend.cpp index 9a7b6bf..db2f0d0 100644 --- a/src/radiobackend.cpp +++ b/src/radiobackend.cpp @@ -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 &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 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 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 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 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 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; diff --git a/src/radiobackend.h b/src/radiobackend.h index 1455d01..1aad6d0 100644 --- a/src/radiobackend.h +++ b/src/radiobackend.h @@ -5,7 +5,6 @@ #define RADIOBACKEND_H #include -#include #include #include #include @@ -102,6 +101,8 @@ private: QList 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); }; diff --git a/src/radiobrowser.cpp b/src/radiobrowser.cpp index 8e77d37..2b1eb5e 100644 --- a/src/radiobrowser.cpp +++ b/src/radiobrowser.cpp @@ -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 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 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 stations = parseJsonResponse(data); + const QList stations = parseJsonResponse(data); if (!cachePath.isEmpty() && !stations.isEmpty()) { QFile file(cachePath); if (file.open(QIODevice::WriteOnly)) { diff --git a/src/radiobrowser.h b/src/radiobrowser.h index 520c2f4..7113c0f 100644 --- a/src/radiobrowser.h +++ b/src/radiobrowser.h @@ -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 parseJsonResponse(const QByteArray &jsonData); QVariantList parseCountriesJson(const QByteArray &jsonData); }; diff --git a/src/systemtraymanager.cpp b/src/systemtraymanager.cpp new file mode 100644 index 0000000..447b832 --- /dev/null +++ b/src/systemtraymanager.cpp @@ -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 +#include +#include +#include +#include +#include + +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(); + } +} \ No newline at end of file diff --git a/src/systemtraymanager.h b/src/systemtraymanager.h new file mode 100644 index 0000000..da97a75 --- /dev/null +++ b/src/systemtraymanager.h @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Sebastian Palencsar +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef SYSTEMTRAYMANAGER_H +#define SYSTEMTRAYMANAGER_H + +#include +#include + +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 \ No newline at end of file diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..ce72411 --- /dev/null +++ b/tests/CMakeLists.txt @@ -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) \ No newline at end of file diff --git a/tests/radiobackend_playback_test.cpp b/tests/radiobackend_playback_test.cpp new file mode 100644 index 0000000..74be22b --- /dev/null +++ b/tests/radiobackend_playback_test.cpp @@ -0,0 +1,306 @@ +// Copyright (c) 2026 Sebastian Palencsar +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 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" \ No newline at end of file diff --git a/tests/radiobrowser_race_test.cpp b/tests/radiobrowser_race_test.cpp new file mode 100644 index 0000000..0446c00 --- /dev/null +++ b/tests/radiobrowser_race_test.cpp @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Sebastian Palencsar +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 lastStations = lastVariant.value>(); + 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 finalStations = finalVariant.value>(); + 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" \ No newline at end of file diff --git a/translations/bearwave_de.ts b/translations/bearwave_de.ts index 771af49..22ec360 100644 --- a/translations/bearwave_de.ts +++ b/translations/bearwave_de.ts @@ -279,6 +279,33 @@ Zuletzt gehört + + SystemTrayManager + + BearWave + BearWave + + + Pause + Pause + + + Play + Wiedergabe + + + Hide + Verstecken + + + Show + Zeigen + + + Quit + Beenden + + RadioBrowser