Implement playing station visual feedback, track desktop notifications, and local cover caching

This commit is contained in:
Sebastian Palencsar
2026-05-22 10:26:26 +02:00
parent a172e62ef9
commit 79e2278a54
9 changed files with 587 additions and 91 deletions

View File

@@ -77,6 +77,7 @@ BearWave intentionally does not aim to be:
- resume support for last station and volume
- MPRIS integration for Plasma media controls and media keys
- system tray integration for background playback
- desktop notifications for song/track changes with local cover art caching
- embedded About dialog with links and MIT license text
## Project Status
@@ -234,6 +235,7 @@ BearWave stores user state under:
- favorites: `~/.config/bearwave/favorites.json`
- last station + volume: `~/.config/bearwave/state.json`
- API cache: `~/.cache/bearwave/api_cache/`
- cover art cache: `~/.cache/bearwave/covers/`
If these files are removed, app state resets to defaults or performs a fresh API sync.
@@ -311,6 +313,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for local build and review expectations.
- stream playback: `src/bearplayer.cpp`
- API layer: `src/radiobrowser.cpp`
- MPRIS adapter: `src/mprisadaptor.cpp`
- desktop notifications: `src/notificationmanager.cpp`
For contributor and agent guardrails, see `AGENTS.md`.

View File

@@ -10,6 +10,7 @@ set(SOURCES
coverartfetcher.cpp
mprisadaptor.cpp
bearwavecontroladaptor.cpp
notificationmanager.cpp
)
# Create executable

View File

@@ -9,6 +9,7 @@
#include "radiobackend.h"
#include "mprisadaptor.h"
#include "bearwavecontroladaptor.h"
#include "notificationmanager.h"
int main(int argc, char *argv[])
@@ -35,9 +36,11 @@ int main(int argc, char *argv[])
MprisRootAdaptor mprisRoot(&backend, &app);
MprisPlayerAdaptor mprisPlayer(&backend);
BearWaveControlAdaptor controlAdaptor(&backend);
NotificationManager notificationManager(&backend);
Q_UNUSED(mprisRoot)
Q_UNUSED(mprisPlayer)
Q_UNUSED(controlAdaptor)
Q_UNUSED(notificationManager)
QDBusConnection sessionBus = QDBusConnection::sessionBus();
sessionBus.registerObject(QStringLiteral("/org/mpris/MediaPlayer2"), &backend, QDBusConnection::ExportAdaptors);

248
src/notificationmanager.cpp Normal file
View File

@@ -0,0 +1,248 @@
#include "notificationmanager.h"
#include "radiobackend.h"
#include "bearplayer.h"
#include <QDBusInterface>
#include <QDBusReply>
#include <QDBusMessage>
#include <QDBusConnection>
#include <QDir>
#include <QFile>
#include <QCryptographicHash>
#include <QStandardPaths>
#include <QDebug>
NotificationManager::NotificationManager(RadioBackend *backend, QObject *parent)
: QObject(parent)
, m_backend(backend)
, m_player(backend->player())
, m_notificationShown(false)
{
m_coversDir = QDir::homePath() + QStringLiteral("/.cache/bearwave/covers");
QDir().mkpath(m_coversDir);
m_networkManager = new QNetworkAccessManager(this);
m_notifyTimer.setSingleShot(true);
connect(&m_notifyTimer, &QTimer::timeout, this, &NotificationManager::onNotifyTimeout);
connect(m_player, &BearPlayer::trackInfoChanged, this, &NotificationManager::onTrackInfoChanged);
connect(m_player, &BearPlayer::currentStationChanged, this, &NotificationManager::onTrackInfoChanged);
}
NotificationManager::~NotificationManager()
{
closeNotification();
}
void NotificationManager::onTrackInfoChanged()
{
const QString station = m_player->currentStationName();
const QString artist = m_player->currentTrackArtist();
const QString title = m_player->currentTrackTitle();
const QString coverUrl = m_player->currentCoverArtUrl();
if (artist.isEmpty() && title.isEmpty()) {
m_notifyTimer.stop();
if (m_currentReply) {
m_currentReply->abort();
m_currentReply->deleteLater();
m_currentReply = nullptr;
}
closeNotification();
m_lastArtist.clear();
m_lastTitle.clear();
m_lastStation.clear();
m_notifiedCoverUrl.clear();
m_activeCoverPath.clear();
m_notificationShown = false;
return;
}
bool trackChanged = (artist != m_lastArtist || title != m_lastTitle || station != m_lastStation);
if (trackChanged) {
m_notifyTimer.stop();
if (m_currentReply) {
m_currentReply->abort();
m_currentReply->deleteLater();
m_currentReply = nullptr;
}
m_lastArtist = artist;
m_lastTitle = title;
m_lastStation = station;
m_notifiedCoverUrl.clear();
m_activeCoverPath.clear();
m_notificationShown = false;
}
if (m_notificationShown) {
return;
}
if (coverUrl.isEmpty()) {
if (!m_notifyTimer.isActive()) {
m_notifyTimer.start(800);
}
} else {
m_notifiedCoverUrl = coverUrl;
const QString hash = QString(QCryptographicHash::hash(coverUrl.toUtf8(), QCryptographicHash::Md5).toHex());
const QString filePath = m_coversDir + "/" + hash + ".jpg";
if (QFile::exists(filePath)) {
m_activeCoverPath = filePath;
triggerNotification();
} else {
downloadCover(coverUrl);
if (!m_notifyTimer.isActive()) {
m_notifyTimer.start(800);
}
}
}
}
void NotificationManager::downloadCover(const QString &url)
{
if (m_currentReply) {
m_currentReply->abort();
m_currentReply->deleteLater();
m_currentReply = nullptr;
}
m_pendingCoverUrl = url;
QNetworkRequest request(url);
m_currentReply = m_networkManager->get(request);
connect(m_currentReply, &QNetworkReply::finished, this, &NotificationManager::onDownloadFinished);
}
void NotificationManager::onDownloadFinished()
{
QNetworkReply *reply = qobject_cast<QNetworkReply*>(sender());
if (!reply) return;
if (reply == m_currentReply) {
m_currentReply = nullptr;
}
if (reply->error() == QNetworkReply::NoError) {
QByteArray data = reply->readAll();
const QString hash = QString(QCryptographicHash::hash(m_pendingCoverUrl.toUtf8(), QCryptographicHash::Md5).toHex());
const QString filePath = m_coversDir + "/" + hash + ".jpg";
QFile file(filePath);
if (file.open(QIODevice::WriteOnly)) {
file.write(data);
file.close();
m_activeCoverPath = filePath;
triggerNotification();
}
} else {
if (reply->error() != QNetworkReply::OperationCanceledError) {
qDebug() << "Cover download failed:" << reply->errorString();
}
}
reply->deleteLater();
}
void NotificationManager::onNotifyTimeout()
{
triggerNotification();
}
void NotificationManager::triggerNotification()
{
m_notifyTimer.stop();
qDebug() << "Triggering notification - station:" << m_lastStation << "artist:" << m_lastArtist << "title:" << m_lastTitle << "cover:" << m_activeCoverPath;
showNotification(m_lastStation, m_lastArtist, m_lastTitle, m_activeCoverPath);
m_notificationShown = true;
}
void NotificationManager::showNotification(const QString &station, const QString &artist, const QString &title, const QString &coverPath)
{
QDBusInterface notifyInterface(
QStringLiteral("org.freedesktop.Notifications"),
QStringLiteral("/org/freedesktop/Notifications"),
QStringLiteral("org.freedesktop.Notifications"),
QDBusConnection::sessionBus()
);
if (!notifyInterface.isValid()) {
qWarning() << "DBus Notification interface is invalid";
return;
}
QString summary;
QString body;
if (!title.isEmpty()) {
summary = title;
if (!artist.isEmpty()) {
body = artist;
}
if (!station.isEmpty()) {
if (!body.isEmpty()) body += QStringLiteral("\n");
body += tr("Station: %1").arg(station);
}
} else {
if (!station.isEmpty()) {
summary = station;
} else {
summary = QStringLiteral("BearWave");
}
if (!artist.isEmpty()) {
body = artist;
}
}
QVariantMap hints;
hints.insert(QStringLiteral("desktop-entry"), QStringLiteral("org.kde.bearwave"));
if (!coverPath.isEmpty()) {
hints.insert(QStringLiteral("image-path"), coverPath);
hints.insert(QStringLiteral("image_path"), coverPath);
}
QList<QVariant> args;
args << QStringLiteral("BearWave")
<< m_lastNotificationId
<< QStringLiteral("org.kde.bearwave")
<< summary
<< body
<< QStringList()
<< hints
<< -1;
QDBusReply<uint> dbusReply = notifyInterface.callWithArgumentList(
QDBus::Block,
QStringLiteral("Notify"),
args
);
if (dbusReply.isValid()) {
m_lastNotificationId = dbusReply.value();
} else {
qWarning() << "Failed to send notification via DBus:" << dbusReply.error().message();
}
}
void NotificationManager::closeNotification()
{
if (m_lastNotificationId == 0) {
return;
}
qDebug() << "Closing notification with ID:" << m_lastNotificationId;
QDBusInterface notifyInterface(
QStringLiteral("org.freedesktop.Notifications"),
QStringLiteral("/org/freedesktop/Notifications"),
QStringLiteral("org.freedesktop.Notifications"),
QDBusConnection::sessionBus()
);
if (notifyInterface.isValid()) {
notifyInterface.call(QStringLiteral("CloseNotification"), m_lastNotificationId);
}
m_lastNotificationId = 0;
}

50
src/notificationmanager.h Normal file
View File

@@ -0,0 +1,50 @@
#ifndef NOTIFICATIONMANAGER_H
#define NOTIFICATIONMANAGER_H
#include <QObject>
#include <QString>
#include <QTimer>
#include <QNetworkAccessManager>
#include <QNetworkReply>
class RadioBackend;
class BearPlayer;
class NotificationManager : public QObject
{
Q_OBJECT
public:
explicit NotificationManager(RadioBackend *backend, QObject *parent = nullptr);
~NotificationManager();
private slots:
void onTrackInfoChanged();
void onDownloadFinished();
void onNotifyTimeout();
private:
void showNotification(const QString &station, const QString &artist, const QString &title, const QString &coverPath);
void closeNotification();
void downloadCover(const QString &url);
void triggerNotification();
RadioBackend *m_backend = nullptr;
BearPlayer *m_player = nullptr;
QNetworkAccessManager *m_networkManager = nullptr;
QNetworkReply *m_currentReply = nullptr;
QTimer m_notifyTimer;
quint32 m_lastNotificationId = 0;
QString m_coversDir;
QString m_lastArtist;
QString m_lastTitle;
QString m_lastStation;
QString m_notifiedCoverUrl;
QString m_pendingCoverUrl;
QString m_activeCoverPath;
bool m_notificationShown = false;
};
#endif // NOTIFICATIONMANAGER_H

View File

@@ -90,7 +90,13 @@ ApplicationWindow {
if (!backend) {
return []
}
return currentPage === "favorites" ? backend.favoriteStations : backend.stations
if (currentPage === "favorites") {
return backend.favoriteStations
} else if (currentPage === "history") {
return backend.recentStations
} else {
return backend.stations
}
}
onCurrentPageChanged: {
@@ -201,6 +207,15 @@ ApplicationWindow {
}
}
Button {
text: qsTr("History")
highlighted: currentPage === "history"
onClicked: {
currentPage = "history"
activeQuickFilter = ""
}
}
Item { Layout.fillWidth: true }
Button {
@@ -311,6 +326,15 @@ ApplicationWindow {
activeQuickFilter = ""
}
}
Button {
text: qsTr("History")
highlighted: currentPage === "history"
onClicked: {
currentPage = "history"
activeQuickFilter = ""
}
}
}
}
@@ -713,8 +737,25 @@ ApplicationWindow {
width: stationScrollView.availableWidth
height: compactMode ? 72 : 78
radius: 10
color: cardMouse.containsMouse ? cardHover : card
border.color: cardBorder
readonly property bool isCurrent: {
if (!backend || !modelData) return false;
var currentUuid = backend.currentStationUuid;
var currentUrl = backend.currentStationUrl;
var cardUuid = modelData.uuid || "";
var cardUrl = modelData.urlResolved || modelData.url || "";
if (currentUuid !== "" && cardUuid !== "") {
return currentUuid === cardUuid;
}
return currentUrl !== "" && currentUrl === cardUrl;
}
readonly property bool isPlaying: isCurrent && backend && backend.player && backend.player.playing
color: stationCard.isCurrent
? (cardMouse.containsMouse ? "#1d3350" : "#16283e")
: (cardMouse.containsMouse ? cardHover : card)
border.color: stationCard.isCurrent ? accent : cardBorder
border.width: stationCard.isCurrent ? 2 : 1
MouseArea {
id: cardMouse
@@ -723,13 +764,19 @@ ApplicationWindow {
acceptedButtons: Qt.LeftButton
onClicked: {
if (!backend) return
if (stationCard.isCurrent) {
backend.player.togglePlayPause()
} else {
if (currentPage === "favorites") {
backend.playFavoriteStation(index)
} else if (currentPage === "history") {
backend.playRecentByUuid(modelData.uuid, modelData.urlResolved)
} else {
backend.playStation(index)
}
}
}
}
RowLayout {
anchors.fill: parent
@@ -768,15 +815,77 @@ ApplicationWindow {
Layout.fillWidth: true
spacing: 2
RowLayout {
Layout.fillWidth: true
spacing: 6
Label {
Layout.fillWidth: true
text: stationCard.modelData.name
color: textMain
color: stationCard.isCurrent ? accent : textMain
font.bold: true
font.pixelSize: compactMode ? 13 : 14
elide: Text.ElideRight
}
Row {
id: eqAnimation
spacing: 2
Layout.alignment: Qt.AlignVCenter
visible: stationCard.isPlaying
Rectangle {
id: bar1
width: 2
height: 12
color: accent
radius: 1
Behavior on height {
NumberAnimation { duration: 120 }
}
}
Rectangle {
id: bar2
width: 2
height: 12
color: accent
radius: 1
Behavior on height {
NumberAnimation { duration: 120 }
}
}
Rectangle {
id: bar3
width: 2
height: 12
color: accent
radius: 1
Behavior on height {
NumberAnimation { duration: 120 }
}
}
Timer {
interval: 150
running: stationCard.isPlaying
repeat: true
onTriggered: {
bar1.height = Math.floor(Math.random() * 11) + 3
bar2.height = Math.floor(Math.random() * 11) + 3
bar3.height = Math.floor(Math.random() * 11) + 3
}
}
onVisibleChanged: {
if (!visible) {
bar1.height = 12
bar2.height = 12
bar3.height = 12
}
}
}
}
Label {
Layout.fillWidth: true
text: stationCard.modelData.country + " • "
@@ -801,11 +910,16 @@ ApplicationWindow {
Button {
Layout.preferredWidth: compactMode ? 34 : 40
Layout.preferredHeight: 40
text: "▶"
text: (stationCard.isCurrent && backend && backend.player && backend.player.playing) ? "⏸" : "▶"
onClicked: {
if (!backend) return
if (stationCard.isCurrent) {
backend.player.togglePlayPause()
} else {
if (currentPage === "favorites") {
backend.playFavoriteStation(index)
} else if (currentPage === "history") {
backend.playRecentByUuid(modelData.uuid, modelData.urlResolved)
} else {
backend.playStation(index)
}
@@ -815,6 +929,7 @@ ApplicationWindow {
}
}
}
}
Column {
anchors.centerIn: parent
@@ -822,13 +937,13 @@ ApplicationWindow {
visible: stationList.count === 0
Label {
text: qsTr("No stations loaded yet")
text: currentPage === "history" ? qsTr("No playback history") : qsTr("No stations loaded yet")
color: textMain
font.bold: true
}
Label {
text: qsTr("Load DE/NL stations or use search")
text: currentPage === "history" ? qsTr("Play some stations to build history") : qsTr("Load DE/NL stations or use search")
color: textMuted
}
}

View File

@@ -62,6 +62,10 @@ void RadioBackend::setupConnections()
m_lastStationName = name;
emit resumeStateChanged();
saveState();
} else {
m_currentStationUuid.clear();
m_currentStationUrl.clear();
emit currentStationChanged();
}
});
@@ -344,6 +348,28 @@ QVariantList RadioBackend::getRecentStations() const
return m_recentStations;
}
QVariantList RadioBackend::recentStations() const
{
QVariantList updatedList;
for (const QVariant &entry : m_recentStations) {
QVariantMap station = entry.toMap();
const QString uuid = station.value(QStringLiteral("uuid")).toString();
const QString urlResolved = station.value(QStringLiteral("urlResolved")).toString();
bool isFav = false;
for (const RadioStation *fav : m_favorites) {
if ((!uuid.isEmpty() && fav->uuid() == uuid) ||
(uuid.isEmpty() && !urlResolved.isEmpty() && fav->urlResolved() == urlResolved)) {
isFav = true;
break;
}
}
station.insert(QStringLiteral("isFavorite"), isFav);
updatedList.append(station);
}
return updatedList;
}
QVariantList RadioBackend::getFavoriteStations() const
{
QVariantList result;
@@ -363,6 +389,9 @@ bool RadioBackend::playRecentByUuid(const QString &uuid, const QString &urlResol
emit resumeStateChanged();
recordRecentStation(toVariantMap(station));
saveState();
m_currentStationUuid = station->uuid();
m_currentStationUrl = url;
emit currentStationChanged();
m_player->playUrl(url, station->name());
return true;
}
@@ -381,6 +410,9 @@ bool RadioBackend::playRecentByUuid(const QString &uuid, const QString &urlResol
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;
}
@@ -410,6 +442,26 @@ void RadioBackend::resumeLastStation()
station.insert(QStringLiteral("urlResolved"), m_lastStationUrl);
recordRecentStation(station);
saveState();
QString resolvedUuid;
for (const RadioStation *s : m_stations) {
if (s->urlResolved() == m_lastStationUrl || s->url() == m_lastStationUrl) {
resolvedUuid = s->uuid();
break;
}
}
if (resolvedUuid.isEmpty()) {
for (const RadioStation *s : m_favorites) {
if (s->urlResolved() == m_lastStationUrl || s->url() == m_lastStationUrl) {
resolvedUuid = s->uuid();
break;
}
}
}
m_currentStationUuid = resolvedUuid;
m_currentStationUrl = m_lastStationUrl;
emit currentStationChanged();
m_player->playUrl(m_lastStationUrl, m_lastStationName.isEmpty() ? tr("Last played") : m_lastStationName);
}
@@ -634,6 +686,9 @@ void RadioBackend::playCurrentSelection()
emit resumeStateChanged();
recordRecentStation(toVariantMap(station));
saveState();
m_currentStationUuid = station->uuid();
m_currentStationUrl = url;
emit currentStationChanged();
m_player->playUrl(url, station->name());
}

View File

@@ -24,12 +24,18 @@ class RadioBackend : public QObject
Q_PROPERTY(bool canResumeLastStation READ canResumeLastStation NOTIFY resumeStateChanged)
Q_PROPERTY(QString lastStationName READ lastStationName NOTIFY resumeStateChanged)
Q_PROPERTY(QString filterQuery READ filterQuery WRITE setFilterQuery NOTIFY filterQueryChanged)
Q_PROPERTY(QVariantList recentStations READ recentStations NOTIFY listsChanged)
Q_PROPERTY(QString currentStationUuid READ currentStationUuid NOTIFY currentStationChanged)
Q_PROPERTY(QString currentStationUrl READ currentStationUrl NOTIFY currentStationChanged)
public:
explicit RadioBackend(QObject *parent = nullptr);
QList<QObject*> stations() const;
QList<QObject*> favoriteStations() const;
QVariantList recentStations() const;
QString currentStationUuid() const { return m_currentStationUuid; }
QString currentStationUrl() const { return m_currentStationUrl; }
BearPlayer* player() const { return m_player; }
bool loading() const { return m_loading; }
QString lastError() const { return m_lastError; }
@@ -70,6 +76,7 @@ signals:
void lastErrorChanged();
void resumeStateChanged();
void filterQueryChanged();
void currentStationChanged();
private slots:
void onStationsLoaded(const QList<RadioStation*> &stations);
@@ -88,6 +95,8 @@ private:
QString m_lastStationUrl;
QString m_filterQuery;
QVariantList m_recentStations;
QString m_currentStationUuid;
QString m_currentStationUrl;
void setupConnections();
void loadFavorites();

View File

@@ -211,6 +211,18 @@
<source>Copyright (c) 2026</source>
<translation>Copyright (c) 2026</translation>
</message>
<message>
<source>History</source>
<translation>Verlauf</translation>
</message>
<message>
<source>No playback history</source>
<translation>Kein Wiedergabeverlauf</translation>
</message>
<message>
<source>Play some stations to build history</source>
<translation>Spiele Sender ab, um einen Verlauf aufzubauen</translation>
</message>
</context>
<context>
<name>RadioBackend</name>