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 \
|
pacman -S --noconfirm --needed \
|
||||||
base-devel \
|
base-devel \
|
||||||
cmake \
|
cmake \
|
||||||
extra-cmake-modules \
|
|
||||||
git \
|
git \
|
||||||
qt6-base \
|
qt6-base \
|
||||||
qt6-declarative \
|
qt6-declarative \
|
||||||
qt6-tools \
|
qt6-tools \
|
||||||
kirigami \
|
|
||||||
kcoreaddons \
|
|
||||||
ki18n \
|
|
||||||
qt6-multimedia \
|
qt6-multimedia \
|
||||||
qt6-multimedia-ffmpeg
|
qt6-multimedia-ffmpeg
|
||||||
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
|
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
|
||||||
|
|||||||
@@ -12,16 +12,15 @@ set(CMAKE_AUTOUIC ON)
|
|||||||
# Qt6
|
# Qt6
|
||||||
find_package(Qt6 REQUIRED COMPONENTS Core DBus Quick QuickControls2 Widgets LinguistTools Multimedia)
|
find_package(Qt6 REQUIRED COMPONENTS Core DBus Quick QuickControls2 Widgets LinguistTools Multimedia)
|
||||||
|
|
||||||
# KDE Frameworks
|
|
||||||
find_package(KF6Kirigami REQUIRED)
|
|
||||||
find_package(KF6I18n REQUIRED)
|
|
||||||
find_package(KF6CoreAddons REQUIRED)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Add src directory
|
# Add src directory
|
||||||
add_subdirectory(src)
|
add_subdirectory(src)
|
||||||
|
|
||||||
|
option(BEARWAVE_BUILD_TESTS "Build BearWave unit tests" ON)
|
||||||
|
if(BEARWAVE_BUILD_TESTS)
|
||||||
|
enable_testing()
|
||||||
|
add_subdirectory(tests)
|
||||||
|
endif()
|
||||||
|
|
||||||
# Configure and install desktop file with correct binary path
|
# Configure and install desktop file with correct binary path
|
||||||
configure_file(
|
configure_file(
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/de.nerdbear.bearwave.desktop.in
|
${CMAKE_CURRENT_SOURCE_DIR}/de.nerdbear.bearwave.desktop.in
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Contributing to BearWave
|
# Contributing to BearWave
|
||||||
|
|
||||||
BearWave is a KDE-focused desktop internet radio app built with C++/Qt 6, QML, KDE Frameworks, and Phonon.
|
BearWave is a KDE-focused desktop internet radio app built with C++/Qt 6, QML, and QtMultimedia.
|
||||||
|
|
||||||
The project favors:
|
The project favors:
|
||||||
|
|
||||||
|
|||||||
2
PKGBUILD
2
PKGBUILD
@@ -6,7 +6,7 @@ pkgdesc="KDE-focused desktop internet radio app"
|
|||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://github.com/spalencsar/bearwave"
|
url="https://github.com/spalencsar/bearwave"
|
||||||
license=('GPL-3.0-or-later')
|
license=('GPL-3.0-or-later')
|
||||||
depends=('qt6-base' 'qt6-declarative' 'kirigami' 'ki18n' 'kcoreaddons' 'qt6-multimedia' 'qt6-multimedia-ffmpeg')
|
depends=('qt6-base' 'qt6-declarative' 'qt6-multimedia' 'qt6-multimedia-ffmpeg')
|
||||||
makedepends=('cmake' 'extra-cmake-modules' 'git' 'ninja')
|
makedepends=('cmake' 'extra-cmake-modules' 'git' 'ninja')
|
||||||
provides=('bearwave')
|
provides=('bearwave')
|
||||||
conflicts=('bearwave')
|
conflicts=('bearwave')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# BearWave
|
# BearWave
|
||||||
|
|
||||||
KDE-focused desktop internet radio app for Linux, built with Qt 6, QML, KDE Frameworks, and QtMultimedia.
|
KDE-focused desktop internet radio app for Linux, built with Qt 6, QML, and QtMultimedia.
|
||||||
|
|
||||||
BearWave is designed for fast station browsing, simple playback controls, favorites, resume support, tray behavior, and clean Plasma integration without turning into a heavy media suite.
|
BearWave is designed for fast station browsing, simple playback controls, favorites, resume support, tray behavior, and clean Plasma integration without turning into a heavy media suite.
|
||||||
|
|
||||||
@@ -134,7 +134,6 @@ BearWave is currently aligned with and tested primarily against:
|
|||||||
- Arch Linux
|
- Arch Linux
|
||||||
- KDE Plasma 6
|
- KDE Plasma 6
|
||||||
- Qt 6
|
- Qt 6
|
||||||
- KDE Frameworks 6
|
|
||||||
- Qt6 Multimedia
|
- Qt6 Multimedia
|
||||||
|
|
||||||
If support for other distributions becomes reliable, they should be added here explicitly instead of implied.
|
If support for other distributions becomes reliable, they should be added here explicitly instead of implied.
|
||||||
@@ -169,15 +168,15 @@ If you want the least surprising path today, use either:
|
|||||||
### Arch Linux
|
### Arch Linux
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo pacman -S cmake extra-cmake-modules qt6-base qt6-declarative qt6-tools \
|
sudo pacman -S cmake qt6-base qt6-declarative qt6-tools \
|
||||||
kirigami qt6-multimedia qt6-multimedia-ffmpeg
|
qt6-multimedia qt6-multimedia-ffmpeg
|
||||||
```
|
```
|
||||||
|
|
||||||
### KDE Neon / Ubuntu-based
|
### KDE Neon / Ubuntu-based
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo apt install cmake ninja-build qt6-base-dev qt6-declarative-dev qt6-tools-dev \
|
sudo apt install cmake ninja-build qt6-base-dev qt6-declarative-dev qt6-tools-dev \
|
||||||
libkf6kirigami-dev libkf6i18n-dev libkf6coreaddons-dev qt6-multimedia-dev
|
qt6-multimedia-dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: exact package names can vary between distro releases, and non-Arch dependency sets should currently be treated as best-effort guidance rather than a guaranteed tested path.
|
Note: exact package names can vary between distro releases, and non-Arch dependency sets should currently be treated as best-effort guidance rather than a guaranteed tested path.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ set(SOURCES
|
|||||||
bearwavecontroladaptor.cpp
|
bearwavecontroladaptor.cpp
|
||||||
notificationmanager.cpp
|
notificationmanager.cpp
|
||||||
icyreader.cpp
|
icyreader.cpp
|
||||||
|
systemtraymanager.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create executable
|
# Create executable
|
||||||
@@ -45,9 +46,6 @@ target_link_libraries(bearwave PRIVATE
|
|||||||
Qt6::QuickControls2
|
Qt6::QuickControls2
|
||||||
Qt6::Widgets
|
Qt6::Widgets
|
||||||
Qt6::Multimedia
|
Qt6::Multimedia
|
||||||
KF6::Kirigami
|
|
||||||
KF6::I18n
|
|
||||||
KF6::CoreAddons
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Install
|
# Install
|
||||||
|
|||||||
31
src/main.cpp
31
src/main.cpp
@@ -5,16 +5,19 @@
|
|||||||
#include <QDBusConnection>
|
#include <QDBusConnection>
|
||||||
#include <QDBusConnectionInterface>
|
#include <QDBusConnectionInterface>
|
||||||
#include <QDBusMessage>
|
#include <QDBusMessage>
|
||||||
|
#include <QDebug>
|
||||||
#include <QIcon>
|
#include <QIcon>
|
||||||
#include <QLocale>
|
#include <QLocale>
|
||||||
#include <QQmlApplicationEngine>
|
#include <QQmlApplicationEngine>
|
||||||
#include <QQmlContext>
|
#include <QQmlContext>
|
||||||
|
#include <QQuickWindow>
|
||||||
#include <QTranslator>
|
#include <QTranslator>
|
||||||
|
|
||||||
#include "radiobackend.h"
|
#include "radiobackend.h"
|
||||||
#include "mprisadaptor.h"
|
#include "mprisadaptor.h"
|
||||||
#include "bearwavecontroladaptor.h"
|
#include "bearwavecontroladaptor.h"
|
||||||
#include "notificationmanager.h"
|
#include "notificationmanager.h"
|
||||||
|
#include "systemtraymanager.h"
|
||||||
|
|
||||||
|
|
||||||
int main(int argc, char *argv[])
|
int main(int argc, char *argv[])
|
||||||
@@ -50,17 +53,22 @@ int main(int argc, char *argv[])
|
|||||||
RadioBackend backend;
|
RadioBackend backend;
|
||||||
engine.rootContext()->setContextProperty("radioBackend", &backend);
|
engine.rootContext()->setContextProperty("radioBackend", &backend);
|
||||||
|
|
||||||
MprisRootAdaptor mprisRoot(&backend, &app);
|
auto *mprisRoot = new MprisRootAdaptor(&backend, &app);
|
||||||
MprisPlayerAdaptor mprisPlayer(&backend);
|
auto *mprisPlayer = new MprisPlayerAdaptor(&backend);
|
||||||
BearWaveControlAdaptor controlAdaptor(&backend);
|
auto *controlAdaptor = new BearWaveControlAdaptor(&backend);
|
||||||
NotificationManager notificationManager(&backend);
|
auto *notificationManager = new NotificationManager(&backend, &backend);
|
||||||
Q_UNUSED(mprisRoot)
|
Q_UNUSED(mprisRoot)
|
||||||
Q_UNUSED(mprisPlayer)
|
|
||||||
Q_UNUSED(controlAdaptor)
|
Q_UNUSED(controlAdaptor)
|
||||||
Q_UNUSED(notificationManager)
|
Q_UNUSED(notificationManager)
|
||||||
|
|
||||||
sessionBus.registerObject(QStringLiteral("/org/mpris/MediaPlayer2"), &backend, QDBusConnection::ExportAdaptors);
|
if (!sessionBus.registerObject(QStringLiteral("/org/mpris/MediaPlayer2"), &backend, QDBusConnection::ExportAdaptors)) {
|
||||||
sessionBus.registerService(QStringLiteral("org.mpris.MediaPlayer2.bearwave"));
|
qWarning() << "Failed to register MPRIS D-Bus object";
|
||||||
|
}
|
||||||
|
if (!sessionBus.registerService(QStringLiteral("org.mpris.MediaPlayer2.bearwave"))) {
|
||||||
|
qWarning() << "Failed to register MPRIS D-Bus service org.mpris.MediaPlayer2.bearwave";
|
||||||
|
} else {
|
||||||
|
mprisPlayer->publishState();
|
||||||
|
}
|
||||||
|
|
||||||
const QUrl url(QStringLiteral("qrc:/Main.qml"));
|
const QUrl url(QStringLiteral("qrc:/Main.qml"));
|
||||||
QObject::connect(&engine, &QQmlApplicationEngine::objectCreated,
|
QObject::connect(&engine, &QQmlApplicationEngine::objectCreated,
|
||||||
@@ -77,5 +85,14 @@ int main(int argc, char *argv[])
|
|||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QQuickWindow *mainWindow = qobject_cast<QQuickWindow *>(engine.rootObjects().constFirst());
|
||||||
|
if (!mainWindow) {
|
||||||
|
qCritical("Failed to obtain main window");
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemTrayManager trayManager(&backend, mainWindow, &app);
|
||||||
|
Q_UNUSED(trayManager)
|
||||||
|
|
||||||
return app.exec();
|
return app.exec();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
#include <QDBusMessage>
|
#include <QDBusMessage>
|
||||||
#include <QDBusObjectPath>
|
#include <QDBusObjectPath>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
void emitPlayerPropertiesChanged(const QStringList &changedProps, const QVariantMap &changedValues)
|
void emitPlayerPropertiesChanged(const QStringList &changedProps, const QVariantMap &changedValues)
|
||||||
@@ -109,7 +110,7 @@ MprisPlayerAdaptor::MprisPlayerAdaptor(RadioBackend *backend)
|
|||||||
, m_backend(backend)
|
, m_backend(backend)
|
||||||
{
|
{
|
||||||
BearPlayer *p = player();
|
BearPlayer *p = player();
|
||||||
if (!p) {
|
if (!p || !m_backend) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +140,24 @@ MprisPlayerAdaptor::MprisPlayerAdaptor(RadioBackend *backend)
|
|||||||
changed.insert(QStringLiteral("Metadata"), metadata());
|
changed.insert(QStringLiteral("Metadata"), metadata());
|
||||||
emitPlayerPropertiesChanged({QStringLiteral("Metadata")}, changed);
|
emitPlayerPropertiesChanged({QStringLiteral("Metadata")}, changed);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
connect(m_backend, &RadioBackend::currentStationChanged, this, [this]() {
|
||||||
|
QVariantMap changed;
|
||||||
|
changed.insert(QStringLiteral("CanGoNext"), canGoNext());
|
||||||
|
changed.insert(QStringLiteral("CanGoPrevious"), canGoPrevious());
|
||||||
|
emitPlayerPropertiesChanged({QStringLiteral("CanGoNext"), QStringLiteral("CanGoPrevious")}, changed);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void MprisPlayerAdaptor::publishState()
|
||||||
|
{
|
||||||
|
QVariantMap changed;
|
||||||
|
changed.insert(QStringLiteral("PlaybackStatus"), playbackStatus());
|
||||||
|
changed.insert(QStringLiteral("Volume"), volume());
|
||||||
|
changed.insert(QStringLiteral("Metadata"), metadata());
|
||||||
|
changed.insert(QStringLiteral("CanGoNext"), canGoNext());
|
||||||
|
changed.insert(QStringLiteral("CanGoPrevious"), canGoPrevious());
|
||||||
|
emitPlayerPropertiesChanged({}, changed);
|
||||||
}
|
}
|
||||||
|
|
||||||
BearPlayer *MprisPlayerAdaptor::player() const
|
BearPlayer *MprisPlayerAdaptor::player() const
|
||||||
@@ -208,6 +227,7 @@ QVariantMap MprisPlayerAdaptor::metadata() const
|
|||||||
const QString title = p->currentTrackTitle();
|
const QString title = p->currentTrackTitle();
|
||||||
const QString artist = p->currentTrackArtist();
|
const QString artist = p->currentTrackArtist();
|
||||||
const QString coverUrl = p->currentCoverArtUrl();
|
const QString coverUrl = p->currentCoverArtUrl();
|
||||||
|
const QString streamUrl = m_backend ? m_backend->currentStationUrl() : QString();
|
||||||
|
|
||||||
map.insert(QStringLiteral("mpris:trackid"), QVariant::fromValue(QDBusObjectPath(QStringLiteral("/org/kde/bearwave/track/0"))));
|
map.insert(QStringLiteral("mpris:trackid"), QVariant::fromValue(QDBusObjectPath(QStringLiteral("/org/kde/bearwave/track/0"))));
|
||||||
if (!station.isEmpty()) {
|
if (!station.isEmpty()) {
|
||||||
@@ -221,6 +241,9 @@ QVariantMap MprisPlayerAdaptor::metadata() const
|
|||||||
if (!artist.isEmpty()) {
|
if (!artist.isEmpty()) {
|
||||||
map.insert(QStringLiteral("xesam:artist"), QStringList{artist});
|
map.insert(QStringLiteral("xesam:artist"), QStringList{artist});
|
||||||
}
|
}
|
||||||
|
if (!streamUrl.isEmpty()) {
|
||||||
|
map.insert(QStringLiteral("xesam:url"), QUrl(streamUrl).toString());
|
||||||
|
}
|
||||||
QString artUrl = coverUrl;
|
QString artUrl = coverUrl;
|
||||||
if (artUrl.isEmpty() && m_backend) {
|
if (artUrl.isEmpty() && m_backend) {
|
||||||
QObject *stationObj = m_backend->currentStation();
|
QObject *stationObj = m_backend->currentStation();
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ class MprisPlayerAdaptor : public QDBusAbstractAdaptor
|
|||||||
public:
|
public:
|
||||||
explicit MprisPlayerAdaptor(RadioBackend *backend);
|
explicit MprisPlayerAdaptor(RadioBackend *backend);
|
||||||
|
|
||||||
|
void publishState();
|
||||||
|
|
||||||
QString playbackStatus() const;
|
QString playbackStatus() const;
|
||||||
double rate() const;
|
double rate() const;
|
||||||
void setRate(double value);
|
void setRate(double value);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
import QtQuick 2.15
|
import QtQuick 2.15
|
||||||
import QtQuick.Controls 2.15
|
import QtQuick.Controls 2.15
|
||||||
import QtQuick.Layouts 1.15
|
import QtQuick.Layouts 1.15
|
||||||
import Qt.labs.platform 1.1 as Platform
|
|
||||||
|
|
||||||
ApplicationWindow {
|
ApplicationWindow {
|
||||||
id: root
|
id: root
|
||||||
@@ -20,62 +19,6 @@ ApplicationWindow {
|
|||||||
root.hide()
|
root.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
Connections {
|
|
||||||
target: backend
|
|
||||||
function onRaiseRequested() {
|
|
||||||
root.show()
|
|
||||||
root.raise()
|
|
||||||
root.requestActivate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Platform.SystemTrayIcon {
|
|
||||||
id: systray
|
|
||||||
visible: true
|
|
||||||
icon.name: "multimedia-player"
|
|
||||||
tooltip: qsTr("BearWave")
|
|
||||||
|
|
||||||
menu: Platform.Menu {
|
|
||||||
Platform.MenuItem {
|
|
||||||
text: backend && backend.player && backend.player.playing ? qsTr("Pause") : qsTr("Play")
|
|
||||||
onTriggered: {
|
|
||||||
if (backend && backend.player) {
|
|
||||||
backend.player.togglePlayPause()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Platform.MenuItem {
|
|
||||||
text: root.visible ? qsTr("Hide") : qsTr("Show")
|
|
||||||
onTriggered: {
|
|
||||||
if (root.visible) {
|
|
||||||
root.hide()
|
|
||||||
} else {
|
|
||||||
root.show()
|
|
||||||
root.raise()
|
|
||||||
root.requestActivate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Platform.MenuSeparator {}
|
|
||||||
Platform.MenuItem {
|
|
||||||
text: qsTr("Quit")
|
|
||||||
onTriggered: Qt.quit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onActivated: function(reason) {
|
|
||||||
if (reason === Platform.SystemTrayIcon.Trigger) {
|
|
||||||
if (root.visible) {
|
|
||||||
root.hide()
|
|
||||||
} else {
|
|
||||||
root.show()
|
|
||||||
root.raise()
|
|
||||||
root.requestActivate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
property var currentPage: "top"
|
property var currentPage: "top"
|
||||||
property var backend: (typeof radioBackend !== "undefined" ? radioBackend : null)
|
property var backend: (typeof radioBackend !== "undefined" ? radioBackend : null)
|
||||||
property bool compactMode: width < 780
|
property bool compactMode: width < 780
|
||||||
@@ -116,6 +59,15 @@ ApplicationWindow {
|
|||||||
toastPopup.open()
|
toastPopup.open()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetSearchFilter() {
|
||||||
|
searchTimer.stop()
|
||||||
|
if (searchField.text !== "") {
|
||||||
|
searchField.text = ""
|
||||||
|
} else if (backend && backend.filterQuery !== "") {
|
||||||
|
backend.filterQuery = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getFilteredCountries() {
|
function getFilteredCountries() {
|
||||||
if (!backend || !backend.countries) return [];
|
if (!backend || !backend.countries) return [];
|
||||||
var query = countrySearchText.toLowerCase().trim();
|
var query = countrySearchText.toLowerCase().trim();
|
||||||
@@ -160,6 +112,9 @@ ApplicationWindow {
|
|||||||
onCurrentPageChanged: {
|
onCurrentPageChanged: {
|
||||||
contentOpacity = 0.85
|
contentOpacity = 0.85
|
||||||
contentFadeRestart.restart()
|
contentFadeRestart.restart()
|
||||||
|
if (currentPage !== "search") {
|
||||||
|
resetSearchFilter()
|
||||||
|
}
|
||||||
if (currentPage !== "world") {
|
if (currentPage !== "world") {
|
||||||
selectedWorldCategory = ""
|
selectedWorldCategory = ""
|
||||||
selectedWorldType = ""
|
selectedWorldType = ""
|
||||||
@@ -485,6 +440,7 @@ ApplicationWindow {
|
|||||||
if (backend) {
|
if (backend) {
|
||||||
backend.filterQuery = text
|
backend.filterQuery = text
|
||||||
}
|
}
|
||||||
|
searchTimer.restart()
|
||||||
}
|
}
|
||||||
onAccepted: {
|
onAccepted: {
|
||||||
if (text.length < 2 || !backend) return
|
if (text.length < 2 || !backend) return
|
||||||
|
|||||||
@@ -59,8 +59,12 @@ void RadioBackend::setupConnections()
|
|||||||
connect(m_radioBrowser, &RadioBrowser::error,
|
connect(m_radioBrowser, &RadioBrowser::error,
|
||||||
this, [this](const QString &error) {
|
this, [this](const QString &error) {
|
||||||
qWarning() << "RadioBrowser error:" << error;
|
qWarning() << "RadioBrowser error:" << error;
|
||||||
setLastError(error);
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
if (!m_currentLoadSatisfied) {
|
||||||
|
setLastError(error);
|
||||||
|
} else {
|
||||||
|
setLastError(QString());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
connect(m_player, &BearPlayer::currentStationChanged, this, [this](const QString &name) {
|
connect(m_player, &BearPlayer::currentStationChanged, this, [this](const QString &name) {
|
||||||
@@ -102,38 +106,46 @@ void RadioBackend::onStationsLoaded(const QList<RadioStation*> &stations)
|
|||||||
});
|
});
|
||||||
|
|
||||||
rebuildFilteredStations(false);
|
rebuildFilteredStations(false);
|
||||||
|
syncStationListIndex();
|
||||||
|
m_currentLoadSatisfied = true;
|
||||||
setLastError(QString());
|
setLastError(QString());
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
qDebug() << "Loaded" << stations.size() << "stations";
|
qDebug() << "Loaded" << stations.size() << "stations";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RadioBackend::beginLoad()
|
||||||
|
{
|
||||||
|
m_currentLoadSatisfied = false;
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
void RadioBackend::loadGermanStations()
|
void RadioBackend::loadGermanStations()
|
||||||
{
|
{
|
||||||
setLoading(true);
|
beginLoad();
|
||||||
m_radioBrowser->getGermanStations();
|
m_radioBrowser->getGermanStations();
|
||||||
}
|
}
|
||||||
|
|
||||||
void RadioBackend::loadDutchStations()
|
void RadioBackend::loadDutchStations()
|
||||||
{
|
{
|
||||||
setLoading(true);
|
beginLoad();
|
||||||
m_radioBrowser->getDutchStations();
|
m_radioBrowser->getDutchStations();
|
||||||
}
|
}
|
||||||
|
|
||||||
void RadioBackend::loadTopStations()
|
void RadioBackend::loadTopStations()
|
||||||
{
|
{
|
||||||
setLoading(true);
|
beginLoad();
|
||||||
m_radioBrowser->getTopStations(100);
|
m_radioBrowser->getTopStations(100);
|
||||||
}
|
}
|
||||||
|
|
||||||
void RadioBackend::loadWorldStations()
|
void RadioBackend::loadWorldStations()
|
||||||
{
|
{
|
||||||
setLoading(true);
|
beginLoad();
|
||||||
m_radioBrowser->getWorldStations(200);
|
m_radioBrowser->getWorldStations(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
void RadioBackend::loadCountries()
|
void RadioBackend::loadCountries()
|
||||||
{
|
{
|
||||||
setLoading(true);
|
beginLoad();
|
||||||
m_radioBrowser->getCountries();
|
m_radioBrowser->getCountries();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +164,8 @@ void RadioBackend::onCountriesLoaded(const QVariantList &countries)
|
|||||||
return QString::localeAwareCompare(a.toMap()["name"].toString(), b.toMap()["name"].toString()) < 0;
|
return QString::localeAwareCompare(a.toMap()["name"].toString(), b.toMap()["name"].toString()) < 0;
|
||||||
});
|
});
|
||||||
emit countriesChanged();
|
emit countriesChanged();
|
||||||
|
m_currentLoadSatisfied = true;
|
||||||
|
setLastError(QString());
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +174,7 @@ void RadioBackend::loadByTag(const QString &tag)
|
|||||||
if (tag.trimmed().isEmpty()) {
|
if (tag.trimmed().isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
beginLoad();
|
||||||
m_radioBrowser->getByTag(tag.trimmed());
|
m_radioBrowser->getByTag(tag.trimmed());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +183,7 @@ void RadioBackend::loadByCountryCode(const QString &countryCode)
|
|||||||
if (countryCode.trimmed().isEmpty()) {
|
if (countryCode.trimmed().isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
beginLoad();
|
||||||
m_radioBrowser->getByCountry(countryCode.trimmed().toUpper());
|
m_radioBrowser->getByCountry(countryCode.trimmed().toUpper());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +193,7 @@ void RadioBackend::searchStations(const QString &query)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
qDebug() << "RadioBackend: searchStations called with query:" << query;
|
qDebug() << "RadioBackend: searchStations called with query:" << query;
|
||||||
setLoading(true);
|
beginLoad();
|
||||||
m_radioBrowser->search(query);
|
m_radioBrowser->search(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +204,8 @@ void RadioBackend::playStation(int index)
|
|||||||
}
|
}
|
||||||
|
|
||||||
m_currentFromFavorites = false;
|
m_currentFromFavorites = false;
|
||||||
|
m_currentFromHistory = false;
|
||||||
|
m_standalonePlayback = false;
|
||||||
m_currentIndex = index;
|
m_currentIndex = index;
|
||||||
|
|
||||||
playCurrentSelection();
|
playCurrentSelection();
|
||||||
@@ -202,6 +218,8 @@ void RadioBackend::playFavoriteStation(int index)
|
|||||||
}
|
}
|
||||||
|
|
||||||
m_currentFromFavorites = true;
|
m_currentFromFavorites = true;
|
||||||
|
m_currentFromHistory = false;
|
||||||
|
m_standalonePlayback = false;
|
||||||
m_currentIndex = index;
|
m_currentIndex = index;
|
||||||
|
|
||||||
playCurrentSelection();
|
playCurrentSelection();
|
||||||
@@ -209,13 +227,30 @@ void RadioBackend::playFavoriteStation(int index)
|
|||||||
|
|
||||||
void RadioBackend::playNextStation()
|
void RadioBackend::playNextStation()
|
||||||
{
|
{
|
||||||
|
if (m_standalonePlayback) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_currentFromHistory) {
|
||||||
|
if (m_recentStations.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const int nextIndex = m_currentIndex < 0 ? 0 : m_currentIndex + 1;
|
||||||
|
if (nextIndex >= m_recentStations.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
playHistoryAtIndex(nextIndex, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const QList<RadioStation*> list = currentList();
|
const QList<RadioStation*> list = currentList();
|
||||||
if (list.isEmpty()) {
|
if (list.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (m_currentIndex < 0) {
|
if (m_currentIndex < 0) {
|
||||||
m_currentIndex = 0;
|
return;
|
||||||
} else if (m_currentIndex < list.size() - 1) {
|
}
|
||||||
|
if (m_currentIndex < list.size() - 1) {
|
||||||
++m_currentIndex;
|
++m_currentIndex;
|
||||||
}
|
}
|
||||||
playCurrentSelection();
|
playCurrentSelection();
|
||||||
@@ -223,32 +258,65 @@ void RadioBackend::playNextStation()
|
|||||||
|
|
||||||
void RadioBackend::playPreviousStation()
|
void RadioBackend::playPreviousStation()
|
||||||
{
|
{
|
||||||
|
if (m_standalonePlayback) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_currentFromHistory) {
|
||||||
|
if (m_currentIndex <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
playHistoryAtIndex(m_currentIndex - 1, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const QList<RadioStation*> list = currentList();
|
const QList<RadioStation*> list = currentList();
|
||||||
if (list.isEmpty()) {
|
if (list.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (m_currentIndex < 0) {
|
if (m_currentIndex <= 0) {
|
||||||
m_currentIndex = 0;
|
return;
|
||||||
} else if (m_currentIndex > 0) {
|
|
||||||
--m_currentIndex;
|
|
||||||
}
|
}
|
||||||
|
--m_currentIndex;
|
||||||
playCurrentSelection();
|
playCurrentSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool RadioBackend::hasNextStation() const
|
bool RadioBackend::hasNextStation() const
|
||||||
{
|
{
|
||||||
|
if (m_standalonePlayback) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_currentFromHistory) {
|
||||||
|
if (m_recentStations.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (m_currentIndex < 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return m_currentIndex < m_recentStations.size() - 1;
|
||||||
|
}
|
||||||
|
|
||||||
const QList<RadioStation*> list = currentList();
|
const QList<RadioStation*> list = currentList();
|
||||||
if (list.isEmpty()) {
|
if (list.isEmpty()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (m_currentIndex < 0) {
|
if (m_currentIndex < 0) {
|
||||||
return true;
|
return false;
|
||||||
}
|
}
|
||||||
return m_currentIndex < list.size() - 1;
|
return m_currentIndex < list.size() - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool RadioBackend::hasPreviousStation() const
|
bool RadioBackend::hasPreviousStation() const
|
||||||
{
|
{
|
||||||
|
if (m_standalonePlayback) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_currentFromHistory) {
|
||||||
|
return m_currentIndex > 0;
|
||||||
|
}
|
||||||
|
|
||||||
const QList<RadioStation*> list = currentList();
|
const QList<RadioStation*> list = currentList();
|
||||||
if (list.isEmpty()) {
|
if (list.isEmpty()) {
|
||||||
return false;
|
return false;
|
||||||
@@ -412,39 +480,17 @@ QVariantList RadioBackend::getFavoriteStations() const
|
|||||||
|
|
||||||
bool RadioBackend::playRecentByUuid(const QString &uuid, const QString &urlResolved)
|
bool RadioBackend::playRecentByUuid(const QString &uuid, const QString &urlResolved)
|
||||||
{
|
{
|
||||||
for (RadioStation *station : m_stations) {
|
const int recentIndex = recentStationIndex(m_recentStations, uuid, urlResolved);
|
||||||
if (matchesStation(station, uuid, urlResolved)) {
|
if (recentIndex >= 0) {
|
||||||
const QString url = station->urlResolved().isEmpty() ? station->url() : station->urlResolved();
|
playHistoryAtIndex(recentIndex, true);
|
||||||
m_lastStationName = station->name();
|
return true;
|
||||||
m_lastStationUrl = url;
|
|
||||||
emit resumeStateChanged();
|
|
||||||
recordRecentStation(toVariantMap(station));
|
|
||||||
saveState();
|
|
||||||
m_currentStationUuid = station->uuid();
|
|
||||||
m_currentStationUrl = url;
|
|
||||||
emit currentStationChanged();
|
|
||||||
m_player->playUrl(url, station->name());
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const QVariant &entry : m_recentStations) {
|
for (RadioStation *station : m_stations) {
|
||||||
const QVariantMap station = entry.toMap();
|
if (matchesStation(station, uuid, urlResolved)) {
|
||||||
if ((!uuid.isEmpty() && station.value(QStringLiteral("uuid")).toString() == uuid) ||
|
const QVariantMap stationData = toVariantMap(station);
|
||||||
(uuid.isEmpty() && !urlResolved.isEmpty() && station.value(QStringLiteral("urlResolved")).toString() == urlResolved)) {
|
recordRecentStation(stationData);
|
||||||
const QString url = station.value(QStringLiteral("urlResolved")).toString();
|
playHistoryAtIndex(0, false);
|
||||||
if (url.isEmpty()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
m_lastStationName = station.value(QStringLiteral("name")).toString();
|
|
||||||
m_lastStationUrl = url;
|
|
||||||
emit resumeStateChanged();
|
|
||||||
recordRecentStation(station);
|
|
||||||
saveState();
|
|
||||||
m_currentStationUuid = station.value(QStringLiteral("uuid")).toString();
|
|
||||||
m_currentStationUrl = url;
|
|
||||||
emit currentStationChanged();
|
|
||||||
m_player->playUrl(url, m_lastStationName);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -468,6 +514,12 @@ void RadioBackend::resumeLastStation()
|
|||||||
if (m_lastStationUrl.isEmpty()) {
|
if (m_lastStationUrl.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m_currentFromFavorites = false;
|
||||||
|
m_currentFromHistory = false;
|
||||||
|
m_standalonePlayback = true;
|
||||||
|
m_currentIndex = -1;
|
||||||
|
|
||||||
QVariantMap station;
|
QVariantMap station;
|
||||||
station.insert(QStringLiteral("name"), m_lastStationName.isEmpty() ? tr("Last played") : m_lastStationName);
|
station.insert(QStringLiteral("name"), m_lastStationName.isEmpty() ? tr("Last played") : m_lastStationName);
|
||||||
station.insert(QStringLiteral("urlResolved"), m_lastStationUrl);
|
station.insert(QStringLiteral("urlResolved"), m_lastStationUrl);
|
||||||
@@ -668,6 +720,7 @@ void RadioBackend::rebuildFilteredStations(bool emitFilterSignal)
|
|||||||
}
|
}
|
||||||
emit stationsChanged();
|
emit stationsChanged();
|
||||||
emit listsChanged();
|
emit listsChanged();
|
||||||
|
syncStationListIndex();
|
||||||
}
|
}
|
||||||
|
|
||||||
void RadioBackend::recordRecentStation(const QVariantMap &stationData)
|
void RadioBackend::recordRecentStation(const QVariantMap &stationData)
|
||||||
@@ -701,6 +754,54 @@ void RadioBackend::recordRecentStation(const QVariantMap &stationData)
|
|||||||
emit listsChanged();
|
emit listsChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RadioBackend::playHistoryAtIndex(int index, bool updateRecent)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= m_recentStations.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QVariantMap station = m_recentStations.at(index).toMap();
|
||||||
|
const QString url = station.value(QStringLiteral("urlResolved")).toString();
|
||||||
|
if (url.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_currentFromFavorites = false;
|
||||||
|
m_currentFromHistory = true;
|
||||||
|
m_standalonePlayback = false;
|
||||||
|
m_currentIndex = index;
|
||||||
|
|
||||||
|
m_lastStationName = station.value(QStringLiteral("name")).toString();
|
||||||
|
m_lastStationUrl = url;
|
||||||
|
emit resumeStateChanged();
|
||||||
|
|
||||||
|
if (updateRecent) {
|
||||||
|
recordRecentStation(station);
|
||||||
|
m_currentIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveState();
|
||||||
|
m_currentStationUuid = station.value(QStringLiteral("uuid")).toString();
|
||||||
|
m_currentStationUrl = url;
|
||||||
|
emit currentStationChanged();
|
||||||
|
m_player->playUrl(url, m_lastStationName.isEmpty() ? tr("Last played") : m_lastStationName);
|
||||||
|
}
|
||||||
|
|
||||||
|
void RadioBackend::syncStationListIndex()
|
||||||
|
{
|
||||||
|
if (m_currentFromHistory || m_currentFromFavorites) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_currentIndex = -1;
|
||||||
|
for (int i = 0; i < m_filteredStations.size(); ++i) {
|
||||||
|
if (matchesStation(m_filteredStations[i], m_currentStationUuid, m_currentStationUrl)) {
|
||||||
|
m_currentIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void RadioBackend::playCurrentSelection()
|
void RadioBackend::playCurrentSelection()
|
||||||
{
|
{
|
||||||
const QList<RadioStation*> list = currentList();
|
const QList<RadioStation*> list = currentList();
|
||||||
@@ -708,6 +809,9 @@ void RadioBackend::playCurrentSelection()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m_currentFromHistory = false;
|
||||||
|
m_standalonePlayback = false;
|
||||||
|
|
||||||
RadioStation *station = list[m_currentIndex];
|
RadioStation *station = list[m_currentIndex];
|
||||||
QString url = station->urlResolved().isEmpty()
|
QString url = station->urlResolved().isEmpty()
|
||||||
? station->url()
|
? station->url()
|
||||||
@@ -822,6 +926,29 @@ bool RadioBackend::matchesStation(const RadioStation *station, const QString &uu
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool RadioBackend::matchesStationMap(const QVariantMap &station, const QString &uuid, const QString &urlResolved)
|
||||||
|
{
|
||||||
|
const QString stationUuid = station.value(QStringLiteral("uuid")).toString();
|
||||||
|
const QString stationUrl = station.value(QStringLiteral("urlResolved")).toString();
|
||||||
|
if (!uuid.isEmpty() && stationUuid == uuid) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (uuid.isEmpty() && !urlResolved.isEmpty() && stationUrl == urlResolved) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int RadioBackend::recentStationIndex(const QVariantList &recent, const QString &uuid, const QString &urlResolved)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < recent.size(); ++i) {
|
||||||
|
if (matchesStationMap(recent.at(i).toMap(), uuid, urlResolved)) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
QString RadioBackend::localizeCountry(const QString &code, const QString &englishName)
|
QString RadioBackend::localizeCountry(const QString &code, const QString &englishName)
|
||||||
{
|
{
|
||||||
QLocale locale;
|
QLocale locale;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
#define RADIOBACKEND_H
|
#define RADIOBACKEND_H
|
||||||
|
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QQmlEngine>
|
|
||||||
#include <QList>
|
#include <QList>
|
||||||
#include <QVariantMap>
|
#include <QVariantMap>
|
||||||
#include <QVariantList>
|
#include <QVariantList>
|
||||||
@@ -102,6 +101,8 @@ private:
|
|||||||
QList<RadioStation*> m_favorites;
|
QList<RadioStation*> m_favorites;
|
||||||
int m_currentIndex = -1;
|
int m_currentIndex = -1;
|
||||||
bool m_currentFromFavorites = false;
|
bool m_currentFromFavorites = false;
|
||||||
|
bool m_currentFromHistory = false;
|
||||||
|
bool m_standalonePlayback = false;
|
||||||
bool m_loading = false;
|
bool m_loading = false;
|
||||||
QString m_lastError;
|
QString m_lastError;
|
||||||
QString m_lastStationName;
|
QString m_lastStationName;
|
||||||
@@ -111,8 +112,10 @@ private:
|
|||||||
QString m_currentStationUuid;
|
QString m_currentStationUuid;
|
||||||
QString m_currentStationUrl;
|
QString m_currentStationUrl;
|
||||||
QVariantList m_countries;
|
QVariantList m_countries;
|
||||||
|
bool m_currentLoadSatisfied = false;
|
||||||
|
|
||||||
void setupConnections();
|
void setupConnections();
|
||||||
|
void beginLoad();
|
||||||
void loadFavorites();
|
void loadFavorites();
|
||||||
void saveFavorites() const;
|
void saveFavorites() const;
|
||||||
void loadState();
|
void loadState();
|
||||||
@@ -124,8 +127,12 @@ private:
|
|||||||
void rebuildFilteredStations(bool emitFilterSignal = true);
|
void rebuildFilteredStations(bool emitFilterSignal = true);
|
||||||
void recordRecentStation(const QVariantMap &stationData);
|
void recordRecentStation(const QVariantMap &stationData);
|
||||||
void playCurrentSelection();
|
void playCurrentSelection();
|
||||||
|
void playHistoryAtIndex(int index, bool updateRecent);
|
||||||
|
void syncStationListIndex();
|
||||||
static QVariantMap toVariantMap(const RadioStation *station);
|
static QVariantMap toVariantMap(const RadioStation *station);
|
||||||
static bool matchesStation(const RadioStation *station, const QString &uuid, const QString &urlResolved);
|
static bool matchesStation(const RadioStation *station, const QString &uuid, const QString &urlResolved);
|
||||||
|
static bool matchesStationMap(const QVariantMap &station, const QString &uuid, const QString &urlResolved);
|
||||||
|
static int recentStationIndex(const QVariantList &recent, const QString &uuid, const QString &urlResolved);
|
||||||
static QString localizeCountry(const QString &code, const QString &englishName);
|
static QString localizeCountry(const QString &code, const QString &englishName);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -82,29 +82,57 @@ void RadioBrowser::getCountries()
|
|||||||
makeRequest("/countries");
|
makeRequest("/countries");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool RadioBrowser::isCountriesEndpoint(const QString &endpoint) const
|
||||||
|
{
|
||||||
|
return endpoint == QStringLiteral("/countries");
|
||||||
|
}
|
||||||
|
|
||||||
|
void RadioBrowser::emitCachedResponse(const QString &endpoint, const QString &cachePath, int requestGeneration)
|
||||||
|
{
|
||||||
|
if (requestGeneration != m_requestGeneration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QFile cacheFile(cachePath);
|
||||||
|
if (!cacheFile.exists() || !cacheFile.open(QIODevice::ReadOnly)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QByteArray cachedData = cacheFile.readAll();
|
||||||
|
cacheFile.close();
|
||||||
|
|
||||||
|
if (isCountriesEndpoint(endpoint)) {
|
||||||
|
const QVariantList list = parseCountriesJson(cachedData);
|
||||||
|
if (!list.isEmpty()) {
|
||||||
|
emit countriesLoaded(list);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QList<RadioStation*> stations = parseJsonResponse(cachedData);
|
||||||
|
if (!stations.isEmpty()) {
|
||||||
|
emit stationsLoaded(stations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void RadioBrowser::makeRequest(const QString &endpoint)
|
void RadioBrowser::makeRequest(const QString &endpoint)
|
||||||
{
|
{
|
||||||
|
++m_requestGeneration;
|
||||||
|
const int requestGeneration = m_requestGeneration;
|
||||||
|
|
||||||
|
if (m_activeReply) {
|
||||||
|
QNetworkReply *reply = m_activeReply;
|
||||||
|
m_activeReply = nullptr;
|
||||||
|
reply->abort();
|
||||||
|
reply->deleteLater();
|
||||||
|
}
|
||||||
|
|
||||||
const QString cacheDir = apiCacheDir();
|
const QString cacheDir = apiCacheDir();
|
||||||
QDir().mkpath(cacheDir);
|
QDir().mkpath(cacheDir);
|
||||||
const QString hash = QString(QCryptographicHash::hash(endpoint.toUtf8(), QCryptographicHash::Md5).toHex());
|
const QString hash = QString(QCryptographicHash::hash(endpoint.toUtf8(), QCryptographicHash::Md5).toHex());
|
||||||
const QString cachePath = cacheDir + "/" + hash + ".json";
|
const QString cachePath = cacheDir + "/" + hash + ".json";
|
||||||
|
|
||||||
QFile cacheFile(cachePath);
|
emitCachedResponse(endpoint, cachePath, requestGeneration);
|
||||||
if (cacheFile.exists() && cacheFile.open(QIODevice::ReadOnly)) {
|
|
||||||
QByteArray cachedData = cacheFile.readAll();
|
|
||||||
if (endpoint == "/countries") {
|
|
||||||
QVariantList list = parseCountriesJson(cachedData);
|
|
||||||
if (!list.isEmpty()) {
|
|
||||||
emit countriesLoaded(list);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
QList<RadioStation*> stations = parseJsonResponse(cachedData);
|
|
||||||
if (!stations.isEmpty()) {
|
|
||||||
emit stationsLoaded(stations);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cacheFile.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
QUrl url(m_baseUrl + endpoint);
|
QUrl url(m_baseUrl + endpoint);
|
||||||
QNetworkRequest request;
|
QNetworkRequest request;
|
||||||
@@ -114,7 +142,10 @@ void RadioBrowser::makeRequest(const QString &endpoint)
|
|||||||
|
|
||||||
QNetworkReply *reply = m_networkManager->get(request);
|
QNetworkReply *reply = m_networkManager->get(request);
|
||||||
reply->setProperty("cachePath", cachePath);
|
reply->setProperty("cachePath", cachePath);
|
||||||
|
reply->setProperty("requestGeneration", requestGeneration);
|
||||||
|
reply->setProperty("endpoint", endpoint);
|
||||||
|
|
||||||
|
m_activeReply = reply;
|
||||||
connect(reply, &QNetworkReply::finished, this, &RadioBrowser::onReplyFinished);
|
connect(reply, &QNetworkReply::finished, this, &RadioBrowser::onReplyFinished);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,17 +158,33 @@ void RadioBrowser::onReplyFinished()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (reply == m_activeReply) {
|
||||||
|
m_activeReply = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int requestGeneration = reply->property("requestGeneration").toInt();
|
||||||
|
if (requestGeneration != m_requestGeneration) {
|
||||||
|
reply->deleteLater();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reply->error() == QNetworkReply::OperationCanceledError) {
|
||||||
|
reply->deleteLater();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (reply->error() != QNetworkReply::NoError) {
|
if (reply->error() != QNetworkReply::NoError) {
|
||||||
emit error(reply->errorString());
|
emit error(reply->errorString());
|
||||||
reply->deleteLater();
|
reply->deleteLater();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
QByteArray data = reply->readAll();
|
const QByteArray data = reply->readAll();
|
||||||
QString cachePath = reply->property("cachePath").toString();
|
const QString cachePath = reply->property("cachePath").toString();
|
||||||
|
const QString endpoint = reply->property("endpoint").toString();
|
||||||
|
|
||||||
if (reply->url().path().endsWith("/countries")) {
|
if (isCountriesEndpoint(endpoint)) {
|
||||||
QVariantList list = parseCountriesJson(data);
|
const QVariantList list = parseCountriesJson(data);
|
||||||
if (!cachePath.isEmpty() && !list.isEmpty()) {
|
if (!cachePath.isEmpty() && !list.isEmpty()) {
|
||||||
QFile file(cachePath);
|
QFile file(cachePath);
|
||||||
if (file.open(QIODevice::WriteOnly)) {
|
if (file.open(QIODevice::WriteOnly)) {
|
||||||
@@ -147,7 +194,7 @@ void RadioBrowser::onReplyFinished()
|
|||||||
}
|
}
|
||||||
emit countriesLoaded(list);
|
emit countriesLoaded(list);
|
||||||
} else {
|
} else {
|
||||||
QList<RadioStation*> stations = parseJsonResponse(data);
|
const QList<RadioStation*> stations = parseJsonResponse(data);
|
||||||
if (!cachePath.isEmpty() && !stations.isEmpty()) {
|
if (!cachePath.isEmpty() && !stations.isEmpty()) {
|
||||||
QFile file(cachePath);
|
QFile file(cachePath);
|
||||||
if (file.open(QIODevice::WriteOnly)) {
|
if (file.open(QIODevice::WriteOnly)) {
|
||||||
|
|||||||
@@ -38,9 +38,13 @@ private slots:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
QNetworkAccessManager *m_networkManager = nullptr;
|
QNetworkAccessManager *m_networkManager = nullptr;
|
||||||
|
QNetworkReply *m_activeReply = nullptr;
|
||||||
QString m_baseUrl = "https://all.api.radio-browser.info/json";
|
QString m_baseUrl = "https://all.api.radio-browser.info/json";
|
||||||
|
int m_requestGeneration = 0;
|
||||||
|
|
||||||
void makeRequest(const QString &endpoint);
|
void makeRequest(const QString &endpoint);
|
||||||
|
void emitCachedResponse(const QString &endpoint, const QString &cachePath, int requestGeneration);
|
||||||
|
bool isCountriesEndpoint(const QString &endpoint) const;
|
||||||
QList<RadioStation*> parseJsonResponse(const QByteArray &jsonData);
|
QList<RadioStation*> parseJsonResponse(const QByteArray &jsonData);
|
||||||
QVariantList parseCountriesJson(const QByteArray &jsonData);
|
QVariantList parseCountriesJson(const QByteArray &jsonData);
|
||||||
};
|
};
|
||||||
|
|||||||
144
src/systemtraymanager.cpp
Normal file
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>
|
<translation>Zuletzt gehört</translation>
|
||||||
</message>
|
</message>
|
||||||
</context>
|
</context>
|
||||||
|
<context>
|
||||||
|
<name>SystemTrayManager</name>
|
||||||
|
<message>
|
||||||
|
<source>BearWave</source>
|
||||||
|
<translation>BearWave</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Pause</source>
|
||||||
|
<translation>Pause</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Play</source>
|
||||||
|
<translation>Wiedergabe</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Hide</source>
|
||||||
|
<translation>Verstecken</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Show</source>
|
||||||
|
<translation>Zeigen</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Quit</source>
|
||||||
|
<translation>Beenden</translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
<context>
|
<context>
|
||||||
<name>RadioBrowser</name>
|
<name>RadioBrowser</name>
|
||||||
<message>
|
<message>
|
||||||
|
|||||||
Reference in New Issue
Block a user