From 79e2278a54f46b327b00e482059d8cb7c2ea733d Mon Sep 17 00:00:00 2001 From: Sebastian Palencsar Date: Fri, 22 May 2026 10:26:26 +0200 Subject: [PATCH] Implement playing station visual feedback, track desktop notifications, and local cover caching --- README.md | 3 + src/CMakeLists.txt | 1 + src/main.cpp | 3 + src/notificationmanager.cpp | 248 ++++++++++++++++++++++++++++++ src/notificationmanager.h | 50 ++++++ src/qml/Main.qml | 297 +++++++++++++++++++++++++----------- src/radiobackend.cpp | 55 +++++++ src/radiobackend.h | 9 ++ translations/bearwave_de.ts | 12 ++ 9 files changed, 587 insertions(+), 91 deletions(-) create mode 100644 src/notificationmanager.cpp create mode 100644 src/notificationmanager.h diff --git a/README.md b/README.md index da2dd01..0e5f8cf 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 04dc549..3e21586 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -10,6 +10,7 @@ set(SOURCES coverartfetcher.cpp mprisadaptor.cpp bearwavecontroladaptor.cpp + notificationmanager.cpp ) # Create executable diff --git a/src/main.cpp b/src/main.cpp index 89cd597..1f02f0c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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); diff --git a/src/notificationmanager.cpp b/src/notificationmanager.cpp new file mode 100644 index 0000000..bfa7fb9 --- /dev/null +++ b/src/notificationmanager.cpp @@ -0,0 +1,248 @@ +#include "notificationmanager.h" +#include "radiobackend.h" +#include "bearplayer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(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 args; + args << QStringLiteral("BearWave") + << m_lastNotificationId + << QStringLiteral("org.kde.bearwave") + << summary + << body + << QStringList() + << hints + << -1; + + QDBusReply 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; +} diff --git a/src/notificationmanager.h b/src/notificationmanager.h new file mode 100644 index 0000000..4c53dc2 --- /dev/null +++ b/src/notificationmanager.h @@ -0,0 +1,50 @@ +#ifndef NOTIFICATIONMANAGER_H +#define NOTIFICATIONMANAGER_H + +#include +#include +#include +#include +#include + +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 diff --git a/src/qml/Main.qml b/src/qml/Main.qml index cdde6cf..567e4b9 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -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,106 +737,197 @@ ApplicationWindow { width: stationScrollView.availableWidth height: compactMode ? 72 : 78 radius: 10 - color: cardMouse.containsMouse ? cardHover : card - border.color: cardBorder - MouseArea { - id: cardMouse - anchors.fill: parent - hoverEnabled: true - acceptedButtons: Qt.LeftButton - onClicked: { - if (!backend) return - if (currentPage === "favorites") { - backend.playFavoriteStation(index) - } else { - backend.playStation(index) + 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 - RowLayout { - anchors.fill: parent - anchors.margins: 10 - spacing: 10 + color: stationCard.isCurrent + ? (cardMouse.containsMouse ? "#1d3350" : "#16283e") + : (cardMouse.containsMouse ? cardHover : card) + border.color: stationCard.isCurrent ? accent : cardBorder + border.width: stationCard.isCurrent ? 2 : 1 - Rectangle { - Layout.preferredWidth: 44 - Layout.preferredHeight: 44 - radius: 8 - color: "#123154" - border.color: accent - - Image { - anchors.fill: parent - anchors.margins: 3 - source: (stationCard.modelData.favicon && stationCard.modelData.favicon.startsWith("https://")) - ? stationCard.modelData.favicon - : "" - fillMode: Image.PreserveAspectFit - asynchronous: true - cache: true - visible: source !== "" && status === Image.Ready - } - - Label { - anchors.centerIn: parent - text: qsTr("FM") - color: "#d8ecff" - font.bold: true - visible: !parent.children[0].visible - } - } - - ColumnLayout { - Layout.fillWidth: true - spacing: 2 - - Label { - Layout.fillWidth: true - text: stationCard.modelData.name - color: textMain - font.bold: true - font.pixelSize: compactMode ? 13 : 14 - elide: Text.ElideRight - } - - Label { - Layout.fillWidth: true - text: stationCard.modelData.country + " • " - + (stationCard.modelData.bitrate > 0 ? stationCard.modelData.bitrate + " kbps" : qsTr("Stream")) - color: textMuted - font.pixelSize: 11 - elide: Text.ElideRight - } - } - - Button { - Layout.preferredWidth: compactMode ? 34 : 40 - Layout.preferredHeight: 40 - text: stationCard.modelData.isFavorite ? "★" : "☆" + MouseArea { + id: cardMouse + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton onClicked: { if (!backend) return - backend.toggleFavoriteById(stationCard.modelData.uuid, stationCard.modelData.urlResolved) - toast(stationCard.modelData.isFavorite ? qsTr("Removed from favorites") : qsTr("Added to favorites")) - } - } - - Button { - Layout.preferredWidth: compactMode ? 34 : 40 - Layout.preferredHeight: 40 - text: "▶" - onClicked: { - if (!backend) return - if (currentPage === "favorites") { - backend.playFavoriteStation(index) + if (stationCard.isCurrent) { + backend.player.togglePlayPause() } else { - backend.playStation(index) + if (currentPage === "favorites") { + backend.playFavoriteStation(index) + } else if (currentPage === "history") { + backend.playRecentByUuid(modelData.uuid, modelData.urlResolved) + } else { + backend.playStation(index) + } + } + } + } + + RowLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 10 + + Rectangle { + Layout.preferredWidth: 44 + Layout.preferredHeight: 44 + radius: 8 + color: "#123154" + border.color: accent + + Image { + anchors.fill: parent + anchors.margins: 3 + source: (stationCard.modelData.favicon && stationCard.modelData.favicon.startsWith("https://")) + ? stationCard.modelData.favicon + : "" + fillMode: Image.PreserveAspectFit + asynchronous: true + cache: true + visible: source !== "" && status === Image.Ready + } + + Label { + anchors.centerIn: parent + text: qsTr("FM") + color: "#d8ecff" + font.bold: true + visible: !parent.children[0].visible + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Label { + Layout.fillWidth: true + text: stationCard.modelData.name + 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 + " • " + + (stationCard.modelData.bitrate > 0 ? stationCard.modelData.bitrate + " kbps" : qsTr("Stream")) + color: textMuted + font.pixelSize: 11 + elide: Text.ElideRight + } + } + + Button { + Layout.preferredWidth: compactMode ? 34 : 40 + Layout.preferredHeight: 40 + text: stationCard.modelData.isFavorite ? "★" : "☆" + onClicked: { + if (!backend) return + backend.toggleFavoriteById(stationCard.modelData.uuid, stationCard.modelData.urlResolved) + toast(stationCard.modelData.isFavorite ? qsTr("Removed from favorites") : qsTr("Added to favorites")) + } + } + + Button { + Layout.preferredWidth: compactMode ? 34 : 40 + Layout.preferredHeight: 40 + 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) + } + } } } } } - } } } @@ -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 } } diff --git a/src/radiobackend.cpp b/src/radiobackend.cpp index 5feccc6..d9a282f 100644 --- a/src/radiobackend.cpp +++ b/src/radiobackend.cpp @@ -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()); } diff --git a/src/radiobackend.h b/src/radiobackend.h index 221c9e0..4d1990e 100644 --- a/src/radiobackend.h +++ b/src/radiobackend.h @@ -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 stations() const; QList 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 &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(); diff --git a/translations/bearwave_de.ts b/translations/bearwave_de.ts index fdbfef7..f7da3fe 100644 --- a/translations/bearwave_de.ts +++ b/translations/bearwave_de.ts @@ -211,6 +211,18 @@ Copyright (c) 2026 Copyright (c) 2026 + + History + Verlauf + + + No playback history + Kein Wiedergabeverlauf + + + Play some stations to build history + Spiele Sender ab, um einen Verlauf aufzubauen + RadioBackend