mirror of
https://github.com/spalencsar/bearwave.git
synced 2026-07-06 14:24:14 +02:00
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:
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
2
PKGBUILD
2
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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
31
src/main.cpp
31
src/main.cpp
@@ -5,16 +5,19 @@
|
||||
#include <QDBusConnection>
|
||||
#include <QDBusConnectionInterface>
|
||||
#include <QDBusMessage>
|
||||
#include <QDebug>
|
||||
#include <QIcon>
|
||||
#include <QLocale>
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QQmlContext>
|
||||
#include <QQuickWindow>
|
||||
#include <QTranslator>
|
||||
|
||||
#include "radiobackend.h"
|
||||
#include "mprisadaptor.h"
|
||||
#include "bearwavecontroladaptor.h"
|
||||
#include "notificationmanager.h"
|
||||
#include "systemtraymanager.h"
|
||||
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
@@ -50,17 +53,22 @@ int main(int argc, char *argv[])
|
||||
RadioBackend backend;
|
||||
engine.rootContext()->setContextProperty("radioBackend", &backend);
|
||||
|
||||
MprisRootAdaptor mprisRoot(&backend, &app);
|
||||
MprisPlayerAdaptor mprisPlayer(&backend);
|
||||
BearWaveControlAdaptor controlAdaptor(&backend);
|
||||
NotificationManager notificationManager(&backend);
|
||||
auto *mprisRoot = new MprisRootAdaptor(&backend, &app);
|
||||
auto *mprisPlayer = new MprisPlayerAdaptor(&backend);
|
||||
auto *controlAdaptor = new BearWaveControlAdaptor(&backend);
|
||||
auto *notificationManager = new NotificationManager(&backend, &backend);
|
||||
Q_UNUSED(mprisRoot)
|
||||
Q_UNUSED(mprisPlayer)
|
||||
Q_UNUSED(controlAdaptor)
|
||||
Q_UNUSED(notificationManager)
|
||||
|
||||
sessionBus.registerObject(QStringLiteral("/org/mpris/MediaPlayer2"), &backend, QDBusConnection::ExportAdaptors);
|
||||
sessionBus.registerService(QStringLiteral("org.mpris.MediaPlayer2.bearwave"));
|
||||
if (!sessionBus.registerObject(QStringLiteral("/org/mpris/MediaPlayer2"), &backend, QDBusConnection::ExportAdaptors)) {
|
||||
qWarning() << "Failed to register MPRIS D-Bus object";
|
||||
}
|
||||
if (!sessionBus.registerService(QStringLiteral("org.mpris.MediaPlayer2.bearwave"))) {
|
||||
qWarning() << "Failed to register MPRIS D-Bus service org.mpris.MediaPlayer2.bearwave";
|
||||
} else {
|
||||
mprisPlayer->publishState();
|
||||
}
|
||||
|
||||
const QUrl url(QStringLiteral("qrc:/Main.qml"));
|
||||
QObject::connect(&engine, &QQmlApplicationEngine::objectCreated,
|
||||
@@ -77,5 +85,14 @@ int main(int argc, char *argv[])
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
||||
QQuickWindow *mainWindow = qobject_cast<QQuickWindow *>(engine.rootObjects().constFirst());
|
||||
if (!mainWindow) {
|
||||
qCritical("Failed to obtain main window");
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
||||
SystemTrayManager trayManager(&backend, mainWindow, &app);
|
||||
Q_UNUSED(trayManager)
|
||||
|
||||
return app.exec();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <QDBusMessage>
|
||||
#include <QDBusObjectPath>
|
||||
#include <QStandardPaths>
|
||||
#include <QUrl>
|
||||
|
||||
namespace {
|
||||
void emitPlayerPropertiesChanged(const QStringList &changedProps, const QVariantMap &changedValues)
|
||||
@@ -109,7 +110,7 @@ MprisPlayerAdaptor::MprisPlayerAdaptor(RadioBackend *backend)
|
||||
, m_backend(backend)
|
||||
{
|
||||
BearPlayer *p = player();
|
||||
if (!p) {
|
||||
if (!p || !m_backend) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -139,6 +140,24 @@ MprisPlayerAdaptor::MprisPlayerAdaptor(RadioBackend *backend)
|
||||
changed.insert(QStringLiteral("Metadata"), metadata());
|
||||
emitPlayerPropertiesChanged({QStringLiteral("Metadata")}, changed);
|
||||
});
|
||||
|
||||
connect(m_backend, &RadioBackend::currentStationChanged, this, [this]() {
|
||||
QVariantMap changed;
|
||||
changed.insert(QStringLiteral("CanGoNext"), canGoNext());
|
||||
changed.insert(QStringLiteral("CanGoPrevious"), canGoPrevious());
|
||||
emitPlayerPropertiesChanged({QStringLiteral("CanGoNext"), QStringLiteral("CanGoPrevious")}, changed);
|
||||
});
|
||||
}
|
||||
|
||||
void MprisPlayerAdaptor::publishState()
|
||||
{
|
||||
QVariantMap changed;
|
||||
changed.insert(QStringLiteral("PlaybackStatus"), playbackStatus());
|
||||
changed.insert(QStringLiteral("Volume"), volume());
|
||||
changed.insert(QStringLiteral("Metadata"), metadata());
|
||||
changed.insert(QStringLiteral("CanGoNext"), canGoNext());
|
||||
changed.insert(QStringLiteral("CanGoPrevious"), canGoPrevious());
|
||||
emitPlayerPropertiesChanged({}, changed);
|
||||
}
|
||||
|
||||
BearPlayer *MprisPlayerAdaptor::player() const
|
||||
@@ -208,6 +227,7 @@ QVariantMap MprisPlayerAdaptor::metadata() const
|
||||
const QString title = p->currentTrackTitle();
|
||||
const QString artist = p->currentTrackArtist();
|
||||
const QString coverUrl = p->currentCoverArtUrl();
|
||||
const QString streamUrl = m_backend ? m_backend->currentStationUrl() : QString();
|
||||
|
||||
map.insert(QStringLiteral("mpris:trackid"), QVariant::fromValue(QDBusObjectPath(QStringLiteral("/org/kde/bearwave/track/0"))));
|
||||
if (!station.isEmpty()) {
|
||||
@@ -221,6 +241,9 @@ QVariantMap MprisPlayerAdaptor::metadata() const
|
||||
if (!artist.isEmpty()) {
|
||||
map.insert(QStringLiteral("xesam:artist"), QStringList{artist});
|
||||
}
|
||||
if (!streamUrl.isEmpty()) {
|
||||
map.insert(QStringLiteral("xesam:url"), QUrl(streamUrl).toString());
|
||||
}
|
||||
QString artUrl = coverUrl;
|
||||
if (artUrl.isEmpty() && m_backend) {
|
||||
QObject *stationObj = m_backend->currentStation();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -59,8 +59,12 @@ void RadioBackend::setupConnections()
|
||||
connect(m_radioBrowser, &RadioBrowser::error,
|
||||
this, [this](const QString &error) {
|
||||
qWarning() << "RadioBrowser error:" << error;
|
||||
setLastError(error);
|
||||
setLoading(false);
|
||||
if (!m_currentLoadSatisfied) {
|
||||
setLastError(error);
|
||||
} else {
|
||||
setLastError(QString());
|
||||
}
|
||||
});
|
||||
|
||||
connect(m_player, &BearPlayer::currentStationChanged, this, [this](const QString &name) {
|
||||
@@ -102,38 +106,46 @@ void RadioBackend::onStationsLoaded(const QList<RadioStation*> &stations)
|
||||
});
|
||||
|
||||
rebuildFilteredStations(false);
|
||||
syncStationListIndex();
|
||||
m_currentLoadSatisfied = true;
|
||||
setLastError(QString());
|
||||
setLoading(false);
|
||||
qDebug() << "Loaded" << stations.size() << "stations";
|
||||
}
|
||||
|
||||
void RadioBackend::beginLoad()
|
||||
{
|
||||
m_currentLoadSatisfied = false;
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
void RadioBackend::loadGermanStations()
|
||||
{
|
||||
setLoading(true);
|
||||
beginLoad();
|
||||
m_radioBrowser->getGermanStations();
|
||||
}
|
||||
|
||||
void RadioBackend::loadDutchStations()
|
||||
{
|
||||
setLoading(true);
|
||||
beginLoad();
|
||||
m_radioBrowser->getDutchStations();
|
||||
}
|
||||
|
||||
void RadioBackend::loadTopStations()
|
||||
{
|
||||
setLoading(true);
|
||||
beginLoad();
|
||||
m_radioBrowser->getTopStations(100);
|
||||
}
|
||||
|
||||
void RadioBackend::loadWorldStations()
|
||||
{
|
||||
setLoading(true);
|
||||
beginLoad();
|
||||
m_radioBrowser->getWorldStations(200);
|
||||
}
|
||||
|
||||
void RadioBackend::loadCountries()
|
||||
{
|
||||
setLoading(true);
|
||||
beginLoad();
|
||||
m_radioBrowser->getCountries();
|
||||
}
|
||||
|
||||
@@ -152,6 +164,8 @@ void RadioBackend::onCountriesLoaded(const QVariantList &countries)
|
||||
return QString::localeAwareCompare(a.toMap()["name"].toString(), b.toMap()["name"].toString()) < 0;
|
||||
});
|
||||
emit countriesChanged();
|
||||
m_currentLoadSatisfied = true;
|
||||
setLastError(QString());
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -160,7 +174,7 @@ void RadioBackend::loadByTag(const QString &tag)
|
||||
if (tag.trimmed().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
beginLoad();
|
||||
m_radioBrowser->getByTag(tag.trimmed());
|
||||
}
|
||||
|
||||
@@ -169,7 +183,7 @@ void RadioBackend::loadByCountryCode(const QString &countryCode)
|
||||
if (countryCode.trimmed().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
beginLoad();
|
||||
m_radioBrowser->getByCountry(countryCode.trimmed().toUpper());
|
||||
}
|
||||
|
||||
@@ -179,7 +193,7 @@ void RadioBackend::searchStations(const QString &query)
|
||||
return;
|
||||
}
|
||||
qDebug() << "RadioBackend: searchStations called with query:" << query;
|
||||
setLoading(true);
|
||||
beginLoad();
|
||||
m_radioBrowser->search(query);
|
||||
}
|
||||
|
||||
@@ -190,6 +204,8 @@ void RadioBackend::playStation(int index)
|
||||
}
|
||||
|
||||
m_currentFromFavorites = false;
|
||||
m_currentFromHistory = false;
|
||||
m_standalonePlayback = false;
|
||||
m_currentIndex = index;
|
||||
|
||||
playCurrentSelection();
|
||||
@@ -202,6 +218,8 @@ void RadioBackend::playFavoriteStation(int index)
|
||||
}
|
||||
|
||||
m_currentFromFavorites = true;
|
||||
m_currentFromHistory = false;
|
||||
m_standalonePlayback = false;
|
||||
m_currentIndex = index;
|
||||
|
||||
playCurrentSelection();
|
||||
@@ -209,13 +227,30 @@ void RadioBackend::playFavoriteStation(int index)
|
||||
|
||||
void RadioBackend::playNextStation()
|
||||
{
|
||||
if (m_standalonePlayback) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_currentFromHistory) {
|
||||
if (m_recentStations.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
const int nextIndex = m_currentIndex < 0 ? 0 : m_currentIndex + 1;
|
||||
if (nextIndex >= m_recentStations.size()) {
|
||||
return;
|
||||
}
|
||||
playHistoryAtIndex(nextIndex, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const QList<RadioStation*> list = currentList();
|
||||
if (list.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (m_currentIndex < 0) {
|
||||
m_currentIndex = 0;
|
||||
} else if (m_currentIndex < list.size() - 1) {
|
||||
return;
|
||||
}
|
||||
if (m_currentIndex < list.size() - 1) {
|
||||
++m_currentIndex;
|
||||
}
|
||||
playCurrentSelection();
|
||||
@@ -223,32 +258,65 @@ void RadioBackend::playNextStation()
|
||||
|
||||
void RadioBackend::playPreviousStation()
|
||||
{
|
||||
if (m_standalonePlayback) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_currentFromHistory) {
|
||||
if (m_currentIndex <= 0) {
|
||||
return;
|
||||
}
|
||||
playHistoryAtIndex(m_currentIndex - 1, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const QList<RadioStation*> list = currentList();
|
||||
if (list.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (m_currentIndex < 0) {
|
||||
m_currentIndex = 0;
|
||||
} else if (m_currentIndex > 0) {
|
||||
--m_currentIndex;
|
||||
if (m_currentIndex <= 0) {
|
||||
return;
|
||||
}
|
||||
--m_currentIndex;
|
||||
playCurrentSelection();
|
||||
}
|
||||
|
||||
bool RadioBackend::hasNextStation() const
|
||||
{
|
||||
if (m_standalonePlayback) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (m_currentFromHistory) {
|
||||
if (m_recentStations.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (m_currentIndex < 0) {
|
||||
return true;
|
||||
}
|
||||
return m_currentIndex < m_recentStations.size() - 1;
|
||||
}
|
||||
|
||||
const QList<RadioStation*> list = currentList();
|
||||
if (list.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (m_currentIndex < 0) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
return m_currentIndex < list.size() - 1;
|
||||
}
|
||||
|
||||
bool RadioBackend::hasPreviousStation() const
|
||||
{
|
||||
if (m_standalonePlayback) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (m_currentFromHistory) {
|
||||
return m_currentIndex > 0;
|
||||
}
|
||||
|
||||
const QList<RadioStation*> list = currentList();
|
||||
if (list.isEmpty()) {
|
||||
return false;
|
||||
@@ -412,39 +480,17 @@ QVariantList RadioBackend::getFavoriteStations() const
|
||||
|
||||
bool RadioBackend::playRecentByUuid(const QString &uuid, const QString &urlResolved)
|
||||
{
|
||||
for (RadioStation *station : m_stations) {
|
||||
if (matchesStation(station, uuid, urlResolved)) {
|
||||
const QString url = station->urlResolved().isEmpty() ? station->url() : station->urlResolved();
|
||||
m_lastStationName = station->name();
|
||||
m_lastStationUrl = url;
|
||||
emit resumeStateChanged();
|
||||
recordRecentStation(toVariantMap(station));
|
||||
saveState();
|
||||
m_currentStationUuid = station->uuid();
|
||||
m_currentStationUrl = url;
|
||||
emit currentStationChanged();
|
||||
m_player->playUrl(url, station->name());
|
||||
const int recentIndex = recentStationIndex(m_recentStations, uuid, urlResolved);
|
||||
if (recentIndex >= 0) {
|
||||
playHistoryAtIndex(recentIndex, true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const QVariant &entry : m_recentStations) {
|
||||
const QVariantMap station = entry.toMap();
|
||||
if ((!uuid.isEmpty() && station.value(QStringLiteral("uuid")).toString() == uuid) ||
|
||||
(uuid.isEmpty() && !urlResolved.isEmpty() && station.value(QStringLiteral("urlResolved")).toString() == urlResolved)) {
|
||||
const QString url = station.value(QStringLiteral("urlResolved")).toString();
|
||||
if (url.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
m_lastStationName = station.value(QStringLiteral("name")).toString();
|
||||
m_lastStationUrl = url;
|
||||
emit resumeStateChanged();
|
||||
recordRecentStation(station);
|
||||
saveState();
|
||||
m_currentStationUuid = station.value(QStringLiteral("uuid")).toString();
|
||||
m_currentStationUrl = url;
|
||||
emit currentStationChanged();
|
||||
m_player->playUrl(url, m_lastStationName);
|
||||
for (RadioStation *station : m_stations) {
|
||||
if (matchesStation(station, uuid, urlResolved)) {
|
||||
const QVariantMap stationData = toVariantMap(station);
|
||||
recordRecentStation(stationData);
|
||||
playHistoryAtIndex(0, false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -468,6 +514,12 @@ void RadioBackend::resumeLastStation()
|
||||
if (m_lastStationUrl.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_currentFromFavorites = false;
|
||||
m_currentFromHistory = false;
|
||||
m_standalonePlayback = true;
|
||||
m_currentIndex = -1;
|
||||
|
||||
QVariantMap station;
|
||||
station.insert(QStringLiteral("name"), m_lastStationName.isEmpty() ? tr("Last played") : m_lastStationName);
|
||||
station.insert(QStringLiteral("urlResolved"), m_lastStationUrl);
|
||||
@@ -668,6 +720,7 @@ void RadioBackend::rebuildFilteredStations(bool emitFilterSignal)
|
||||
}
|
||||
emit stationsChanged();
|
||||
emit listsChanged();
|
||||
syncStationListIndex();
|
||||
}
|
||||
|
||||
void RadioBackend::recordRecentStation(const QVariantMap &stationData)
|
||||
@@ -701,6 +754,54 @@ void RadioBackend::recordRecentStation(const QVariantMap &stationData)
|
||||
emit listsChanged();
|
||||
}
|
||||
|
||||
void RadioBackend::playHistoryAtIndex(int index, bool updateRecent)
|
||||
{
|
||||
if (index < 0 || index >= m_recentStations.size()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QVariantMap station = m_recentStations.at(index).toMap();
|
||||
const QString url = station.value(QStringLiteral("urlResolved")).toString();
|
||||
if (url.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_currentFromFavorites = false;
|
||||
m_currentFromHistory = true;
|
||||
m_standalonePlayback = false;
|
||||
m_currentIndex = index;
|
||||
|
||||
m_lastStationName = station.value(QStringLiteral("name")).toString();
|
||||
m_lastStationUrl = url;
|
||||
emit resumeStateChanged();
|
||||
|
||||
if (updateRecent) {
|
||||
recordRecentStation(station);
|
||||
m_currentIndex = 0;
|
||||
}
|
||||
|
||||
saveState();
|
||||
m_currentStationUuid = station.value(QStringLiteral("uuid")).toString();
|
||||
m_currentStationUrl = url;
|
||||
emit currentStationChanged();
|
||||
m_player->playUrl(url, m_lastStationName.isEmpty() ? tr("Last played") : m_lastStationName);
|
||||
}
|
||||
|
||||
void RadioBackend::syncStationListIndex()
|
||||
{
|
||||
if (m_currentFromHistory || m_currentFromFavorites) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_currentIndex = -1;
|
||||
for (int i = 0; i < m_filteredStations.size(); ++i) {
|
||||
if (matchesStation(m_filteredStations[i], m_currentStationUuid, m_currentStationUrl)) {
|
||||
m_currentIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RadioBackend::playCurrentSelection()
|
||||
{
|
||||
const QList<RadioStation*> list = currentList();
|
||||
@@ -708,6 +809,9 @@ void RadioBackend::playCurrentSelection()
|
||||
return;
|
||||
}
|
||||
|
||||
m_currentFromHistory = false;
|
||||
m_standalonePlayback = false;
|
||||
|
||||
RadioStation *station = list[m_currentIndex];
|
||||
QString url = station->urlResolved().isEmpty()
|
||||
? station->url()
|
||||
@@ -822,6 +926,29 @@ bool RadioBackend::matchesStation(const RadioStation *station, const QString &uu
|
||||
return false;
|
||||
}
|
||||
|
||||
bool RadioBackend::matchesStationMap(const QVariantMap &station, const QString &uuid, const QString &urlResolved)
|
||||
{
|
||||
const QString stationUuid = station.value(QStringLiteral("uuid")).toString();
|
||||
const QString stationUrl = station.value(QStringLiteral("urlResolved")).toString();
|
||||
if (!uuid.isEmpty() && stationUuid == uuid) {
|
||||
return true;
|
||||
}
|
||||
if (uuid.isEmpty() && !urlResolved.isEmpty() && stationUrl == urlResolved) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int RadioBackend::recentStationIndex(const QVariantList &recent, const QString &uuid, const QString &urlResolved)
|
||||
{
|
||||
for (int i = 0; i < recent.size(); ++i) {
|
||||
if (matchesStationMap(recent.at(i).toMap(), uuid, urlResolved)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
QString RadioBackend::localizeCountry(const QString &code, const QString &englishName)
|
||||
{
|
||||
QLocale locale;
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
#define RADIOBACKEND_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QQmlEngine>
|
||||
#include <QList>
|
||||
#include <QVariantMap>
|
||||
#include <QVariantList>
|
||||
@@ -102,6 +101,8 @@ private:
|
||||
QList<RadioStation*> m_favorites;
|
||||
int m_currentIndex = -1;
|
||||
bool m_currentFromFavorites = false;
|
||||
bool m_currentFromHistory = false;
|
||||
bool m_standalonePlayback = false;
|
||||
bool m_loading = false;
|
||||
QString m_lastError;
|
||||
QString m_lastStationName;
|
||||
@@ -111,8 +112,10 @@ private:
|
||||
QString m_currentStationUuid;
|
||||
QString m_currentStationUrl;
|
||||
QVariantList m_countries;
|
||||
bool m_currentLoadSatisfied = false;
|
||||
|
||||
void setupConnections();
|
||||
void beginLoad();
|
||||
void loadFavorites();
|
||||
void saveFavorites() const;
|
||||
void loadState();
|
||||
@@ -124,8 +127,12 @@ private:
|
||||
void rebuildFilteredStations(bool emitFilterSignal = true);
|
||||
void recordRecentStation(const QVariantMap &stationData);
|
||||
void playCurrentSelection();
|
||||
void playHistoryAtIndex(int index, bool updateRecent);
|
||||
void syncStationListIndex();
|
||||
static QVariantMap toVariantMap(const RadioStation *station);
|
||||
static bool matchesStation(const RadioStation *station, const QString &uuid, const QString &urlResolved);
|
||||
static bool matchesStationMap(const QVariantMap &station, const QString &uuid, const QString &urlResolved);
|
||||
static int recentStationIndex(const QVariantList &recent, const QString &uuid, const QString &urlResolved);
|
||||
static QString localizeCountry(const QString &code, const QString &englishName);
|
||||
};
|
||||
|
||||
|
||||
@@ -82,29 +82,57 @@ void RadioBrowser::getCountries()
|
||||
makeRequest("/countries");
|
||||
}
|
||||
|
||||
bool RadioBrowser::isCountriesEndpoint(const QString &endpoint) const
|
||||
{
|
||||
return endpoint == QStringLiteral("/countries");
|
||||
}
|
||||
|
||||
void RadioBrowser::emitCachedResponse(const QString &endpoint, const QString &cachePath, int requestGeneration)
|
||||
{
|
||||
if (requestGeneration != m_requestGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
QFile cacheFile(cachePath);
|
||||
if (!cacheFile.exists() || !cacheFile.open(QIODevice::ReadOnly)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QByteArray cachedData = cacheFile.readAll();
|
||||
cacheFile.close();
|
||||
|
||||
if (isCountriesEndpoint(endpoint)) {
|
||||
const QVariantList list = parseCountriesJson(cachedData);
|
||||
if (!list.isEmpty()) {
|
||||
emit countriesLoaded(list);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const QList<RadioStation*> stations = parseJsonResponse(cachedData);
|
||||
if (!stations.isEmpty()) {
|
||||
emit stationsLoaded(stations);
|
||||
}
|
||||
}
|
||||
|
||||
void RadioBrowser::makeRequest(const QString &endpoint)
|
||||
{
|
||||
++m_requestGeneration;
|
||||
const int requestGeneration = m_requestGeneration;
|
||||
|
||||
if (m_activeReply) {
|
||||
QNetworkReply *reply = m_activeReply;
|
||||
m_activeReply = nullptr;
|
||||
reply->abort();
|
||||
reply->deleteLater();
|
||||
}
|
||||
|
||||
const QString cacheDir = apiCacheDir();
|
||||
QDir().mkpath(cacheDir);
|
||||
const QString hash = QString(QCryptographicHash::hash(endpoint.toUtf8(), QCryptographicHash::Md5).toHex());
|
||||
const QString cachePath = cacheDir + "/" + hash + ".json";
|
||||
|
||||
QFile cacheFile(cachePath);
|
||||
if (cacheFile.exists() && cacheFile.open(QIODevice::ReadOnly)) {
|
||||
QByteArray cachedData = cacheFile.readAll();
|
||||
if (endpoint == "/countries") {
|
||||
QVariantList list = parseCountriesJson(cachedData);
|
||||
if (!list.isEmpty()) {
|
||||
emit countriesLoaded(list);
|
||||
}
|
||||
} else {
|
||||
QList<RadioStation*> stations = parseJsonResponse(cachedData);
|
||||
if (!stations.isEmpty()) {
|
||||
emit stationsLoaded(stations);
|
||||
}
|
||||
}
|
||||
cacheFile.close();
|
||||
}
|
||||
emitCachedResponse(endpoint, cachePath, requestGeneration);
|
||||
|
||||
QUrl url(m_baseUrl + endpoint);
|
||||
QNetworkRequest request;
|
||||
@@ -114,7 +142,10 @@ void RadioBrowser::makeRequest(const QString &endpoint)
|
||||
|
||||
QNetworkReply *reply = m_networkManager->get(request);
|
||||
reply->setProperty("cachePath", cachePath);
|
||||
reply->setProperty("requestGeneration", requestGeneration);
|
||||
reply->setProperty("endpoint", endpoint);
|
||||
|
||||
m_activeReply = reply;
|
||||
connect(reply, &QNetworkReply::finished, this, &RadioBrowser::onReplyFinished);
|
||||
}
|
||||
|
||||
@@ -127,17 +158,33 @@ void RadioBrowser::onReplyFinished()
|
||||
return;
|
||||
}
|
||||
|
||||
if (reply == m_activeReply) {
|
||||
m_activeReply = nullptr;
|
||||
}
|
||||
|
||||
const int requestGeneration = reply->property("requestGeneration").toInt();
|
||||
if (requestGeneration != m_requestGeneration) {
|
||||
reply->deleteLater();
|
||||
return;
|
||||
}
|
||||
|
||||
if (reply->error() == QNetworkReply::OperationCanceledError) {
|
||||
reply->deleteLater();
|
||||
return;
|
||||
}
|
||||
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
emit error(reply->errorString());
|
||||
reply->deleteLater();
|
||||
return;
|
||||
}
|
||||
|
||||
QByteArray data = reply->readAll();
|
||||
QString cachePath = reply->property("cachePath").toString();
|
||||
const QByteArray data = reply->readAll();
|
||||
const QString cachePath = reply->property("cachePath").toString();
|
||||
const QString endpoint = reply->property("endpoint").toString();
|
||||
|
||||
if (reply->url().path().endsWith("/countries")) {
|
||||
QVariantList list = parseCountriesJson(data);
|
||||
if (isCountriesEndpoint(endpoint)) {
|
||||
const QVariantList list = parseCountriesJson(data);
|
||||
if (!cachePath.isEmpty() && !list.isEmpty()) {
|
||||
QFile file(cachePath);
|
||||
if (file.open(QIODevice::WriteOnly)) {
|
||||
@@ -147,7 +194,7 @@ void RadioBrowser::onReplyFinished()
|
||||
}
|
||||
emit countriesLoaded(list);
|
||||
} else {
|
||||
QList<RadioStation*> stations = parseJsonResponse(data);
|
||||
const QList<RadioStation*> stations = parseJsonResponse(data);
|
||||
if (!cachePath.isEmpty() && !stations.isEmpty()) {
|
||||
QFile file(cachePath);
|
||||
if (file.open(QIODevice::WriteOnly)) {
|
||||
|
||||
@@ -38,9 +38,13 @@ private slots:
|
||||
|
||||
private:
|
||||
QNetworkAccessManager *m_networkManager = nullptr;
|
||||
QNetworkReply *m_activeReply = nullptr;
|
||||
QString m_baseUrl = "https://all.api.radio-browser.info/json";
|
||||
int m_requestGeneration = 0;
|
||||
|
||||
void makeRequest(const QString &endpoint);
|
||||
void emitCachedResponse(const QString &endpoint, const QString &cachePath, int requestGeneration);
|
||||
bool isCountriesEndpoint(const QString &endpoint) const;
|
||||
QList<RadioStation*> parseJsonResponse(const QByteArray &jsonData);
|
||||
QVariantList parseCountriesJson(const QByteArray &jsonData);
|
||||
};
|
||||
|
||||
144
src/systemtraymanager.cpp
Normal file
144
src/systemtraymanager.cpp
Normal 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
48
src/systemtraymanager.h
Normal 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
48
tests/CMakeLists.txt
Normal 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)
|
||||
306
tests/radiobackend_playback_test.cpp
Normal file
306
tests/radiobackend_playback_test.cpp
Normal 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"
|
||||
104
tests/radiobrowser_race_test.cpp
Normal file
104
tests/radiobrowser_race_test.cpp
Normal 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"
|
||||
@@ -279,6 +279,33 @@
|
||||
<translation>Zuletzt gehört</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>SystemTrayManager</name>
|
||||
<message>
|
||||
<source>BearWave</source>
|
||||
<translation>BearWave</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Pause</source>
|
||||
<translation>Pause</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Play</source>
|
||||
<translation>Wiedergabe</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Hide</source>
|
||||
<translation>Verstecken</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Show</source>
|
||||
<translation>Zeigen</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Quit</source>
|
||||
<translation>Beenden</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>RadioBrowser</name>
|
||||
<message>
|
||||
|
||||
Reference in New Issue
Block a user