mirror of
https://github.com/spalencsar/bearwave.git
synced 2026-07-06 22:24:17 +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:
@@ -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());
|
||||
return true;
|
||||
}
|
||||
const int recentIndex = recentStationIndex(m_recentStations, uuid, urlResolved);
|
||||
if (recentIndex >= 0) {
|
||||
playHistoryAtIndex(recentIndex, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const QVariant &entry : m_recentStations) {
|
||||
const QVariantMap station = entry.toMap();
|
||||
if ((!uuid.isEmpty() && station.value(QStringLiteral("uuid")).toString() == uuid) ||
|
||||
(uuid.isEmpty() && !urlResolved.isEmpty() && station.value(QStringLiteral("urlResolved")).toString() == urlResolved)) {
|
||||
const QString url = station.value(QStringLiteral("urlResolved")).toString();
|
||||
if (url.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
m_lastStationName = station.value(QStringLiteral("name")).toString();
|
||||
m_lastStationUrl = url;
|
||||
emit resumeStateChanged();
|
||||
recordRecentStation(station);
|
||||
saveState();
|
||||
m_currentStationUuid = station.value(QStringLiteral("uuid")).toString();
|
||||
m_currentStationUrl = url;
|
||||
emit currentStationChanged();
|
||||
m_player->playUrl(url, m_lastStationName);
|
||||
for (RadioStation *station : m_stations) {
|
||||
if (matchesStation(station, uuid, urlResolved)) {
|
||||
const QVariantMap stationData = toVariantMap(station);
|
||||
recordRecentStation(stationData);
|
||||
playHistoryAtIndex(0, false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -468,6 +514,12 @@ void RadioBackend::resumeLastStation()
|
||||
if (m_lastStationUrl.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_currentFromFavorites = false;
|
||||
m_currentFromHistory = false;
|
||||
m_standalonePlayback = true;
|
||||
m_currentIndex = -1;
|
||||
|
||||
QVariantMap station;
|
||||
station.insert(QStringLiteral("name"), m_lastStationName.isEmpty() ? tr("Last played") : m_lastStationName);
|
||||
station.insert(QStringLiteral("urlResolved"), m_lastStationUrl);
|
||||
@@ -668,6 +720,7 @@ void RadioBackend::rebuildFilteredStations(bool emitFilterSignal)
|
||||
}
|
||||
emit stationsChanged();
|
||||
emit listsChanged();
|
||||
syncStationListIndex();
|
||||
}
|
||||
|
||||
void RadioBackend::recordRecentStation(const QVariantMap &stationData)
|
||||
@@ -701,6 +754,54 @@ void RadioBackend::recordRecentStation(const QVariantMap &stationData)
|
||||
emit listsChanged();
|
||||
}
|
||||
|
||||
void RadioBackend::playHistoryAtIndex(int index, bool updateRecent)
|
||||
{
|
||||
if (index < 0 || index >= m_recentStations.size()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QVariantMap station = m_recentStations.at(index).toMap();
|
||||
const QString url = station.value(QStringLiteral("urlResolved")).toString();
|
||||
if (url.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_currentFromFavorites = false;
|
||||
m_currentFromHistory = true;
|
||||
m_standalonePlayback = false;
|
||||
m_currentIndex = index;
|
||||
|
||||
m_lastStationName = station.value(QStringLiteral("name")).toString();
|
||||
m_lastStationUrl = url;
|
||||
emit resumeStateChanged();
|
||||
|
||||
if (updateRecent) {
|
||||
recordRecentStation(station);
|
||||
m_currentIndex = 0;
|
||||
}
|
||||
|
||||
saveState();
|
||||
m_currentStationUuid = station.value(QStringLiteral("uuid")).toString();
|
||||
m_currentStationUrl = url;
|
||||
emit currentStationChanged();
|
||||
m_player->playUrl(url, m_lastStationName.isEmpty() ? tr("Last played") : m_lastStationName);
|
||||
}
|
||||
|
||||
void RadioBackend::syncStationListIndex()
|
||||
{
|
||||
if (m_currentFromHistory || m_currentFromFavorites) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_currentIndex = -1;
|
||||
for (int i = 0; i < m_filteredStations.size(); ++i) {
|
||||
if (matchesStation(m_filteredStations[i], m_currentStationUuid, m_currentStationUrl)) {
|
||||
m_currentIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RadioBackend::playCurrentSelection()
|
||||
{
|
||||
const QList<RadioStation*> list = currentList();
|
||||
@@ -708,6 +809,9 @@ void RadioBackend::playCurrentSelection()
|
||||
return;
|
||||
}
|
||||
|
||||
m_currentFromHistory = false;
|
||||
m_standalonePlayback = false;
|
||||
|
||||
RadioStation *station = list[m_currentIndex];
|
||||
QString url = station->urlResolved().isEmpty()
|
||||
? station->url()
|
||||
@@ -822,6 +926,29 @@ bool RadioBackend::matchesStation(const RadioStation *station, const QString &uu
|
||||
return false;
|
||||
}
|
||||
|
||||
bool RadioBackend::matchesStationMap(const QVariantMap &station, const QString &uuid, const QString &urlResolved)
|
||||
{
|
||||
const QString stationUuid = station.value(QStringLiteral("uuid")).toString();
|
||||
const QString stationUrl = station.value(QStringLiteral("urlResolved")).toString();
|
||||
if (!uuid.isEmpty() && stationUuid == uuid) {
|
||||
return true;
|
||||
}
|
||||
if (uuid.isEmpty() && !urlResolved.isEmpty() && stationUrl == urlResolved) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int RadioBackend::recentStationIndex(const QVariantList &recent, const QString &uuid, const QString &urlResolved)
|
||||
{
|
||||
for (int i = 0; i < recent.size(); ++i) {
|
||||
if (matchesStationMap(recent.at(i).toMap(), uuid, urlResolved)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
QString RadioBackend::localizeCountry(const QString &code, const QString &englishName)
|
||||
{
|
||||
QLocale locale;
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user