mirror of
https://github.com/spalencsar/bearwave.git
synced 2026-07-06 22:24:17 +02:00
Implement playing station visual feedback, track desktop notifications, and local cover caching
This commit is contained in:
@@ -77,6 +77,7 @@ BearWave intentionally does not aim to be:
|
|||||||
- resume support for last station and volume
|
- resume support for last station and volume
|
||||||
- MPRIS integration for Plasma media controls and media keys
|
- MPRIS integration for Plasma media controls and media keys
|
||||||
- system tray integration for background playback
|
- 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
|
- embedded About dialog with links and MIT license text
|
||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
@@ -234,6 +235,7 @@ BearWave stores user state under:
|
|||||||
- favorites: `~/.config/bearwave/favorites.json`
|
- favorites: `~/.config/bearwave/favorites.json`
|
||||||
- last station + volume: `~/.config/bearwave/state.json`
|
- last station + volume: `~/.config/bearwave/state.json`
|
||||||
- API cache: `~/.cache/bearwave/api_cache/`
|
- 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.
|
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`
|
- stream playback: `src/bearplayer.cpp`
|
||||||
- API layer: `src/radiobrowser.cpp`
|
- API layer: `src/radiobrowser.cpp`
|
||||||
- MPRIS adapter: `src/mprisadaptor.cpp`
|
- MPRIS adapter: `src/mprisadaptor.cpp`
|
||||||
|
- desktop notifications: `src/notificationmanager.cpp`
|
||||||
|
|
||||||
For contributor and agent guardrails, see `AGENTS.md`.
|
For contributor and agent guardrails, see `AGENTS.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ set(SOURCES
|
|||||||
coverartfetcher.cpp
|
coverartfetcher.cpp
|
||||||
mprisadaptor.cpp
|
mprisadaptor.cpp
|
||||||
bearwavecontroladaptor.cpp
|
bearwavecontroladaptor.cpp
|
||||||
|
notificationmanager.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create executable
|
# Create executable
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include "radiobackend.h"
|
#include "radiobackend.h"
|
||||||
#include "mprisadaptor.h"
|
#include "mprisadaptor.h"
|
||||||
#include "bearwavecontroladaptor.h"
|
#include "bearwavecontroladaptor.h"
|
||||||
|
#include "notificationmanager.h"
|
||||||
|
|
||||||
|
|
||||||
int main(int argc, char *argv[])
|
int main(int argc, char *argv[])
|
||||||
@@ -35,9 +36,11 @@ int main(int argc, char *argv[])
|
|||||||
MprisRootAdaptor mprisRoot(&backend, &app);
|
MprisRootAdaptor mprisRoot(&backend, &app);
|
||||||
MprisPlayerAdaptor mprisPlayer(&backend);
|
MprisPlayerAdaptor mprisPlayer(&backend);
|
||||||
BearWaveControlAdaptor controlAdaptor(&backend);
|
BearWaveControlAdaptor controlAdaptor(&backend);
|
||||||
|
NotificationManager notificationManager(&backend);
|
||||||
Q_UNUSED(mprisRoot)
|
Q_UNUSED(mprisRoot)
|
||||||
Q_UNUSED(mprisPlayer)
|
Q_UNUSED(mprisPlayer)
|
||||||
Q_UNUSED(controlAdaptor)
|
Q_UNUSED(controlAdaptor)
|
||||||
|
Q_UNUSED(notificationManager)
|
||||||
|
|
||||||
QDBusConnection sessionBus = QDBusConnection::sessionBus();
|
QDBusConnection sessionBus = QDBusConnection::sessionBus();
|
||||||
sessionBus.registerObject(QStringLiteral("/org/mpris/MediaPlayer2"), &backend, QDBusConnection::ExportAdaptors);
|
sessionBus.registerObject(QStringLiteral("/org/mpris/MediaPlayer2"), &backend, QDBusConnection::ExportAdaptors);
|
||||||
|
|||||||
248
src/notificationmanager.cpp
Normal file
248
src/notificationmanager.cpp
Normal 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
50
src/notificationmanager.h
Normal 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
|
||||||
297
src/qml/Main.qml
297
src/qml/Main.qml
@@ -90,7 +90,13 @@ ApplicationWindow {
|
|||||||
if (!backend) {
|
if (!backend) {
|
||||||
return []
|
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: {
|
onCurrentPageChanged: {
|
||||||
@@ -201,6 +207,15 @@ ApplicationWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: qsTr("History")
|
||||||
|
highlighted: currentPage === "history"
|
||||||
|
onClicked: {
|
||||||
|
currentPage = "history"
|
||||||
|
activeQuickFilter = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Item { Layout.fillWidth: true }
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
@@ -311,6 +326,15 @@ ApplicationWindow {
|
|||||||
activeQuickFilter = ""
|
activeQuickFilter = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: qsTr("History")
|
||||||
|
highlighted: currentPage === "history"
|
||||||
|
onClicked: {
|
||||||
|
currentPage = "history"
|
||||||
|
activeQuickFilter = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,106 +737,197 @@ ApplicationWindow {
|
|||||||
width: stationScrollView.availableWidth
|
width: stationScrollView.availableWidth
|
||||||
height: compactMode ? 72 : 78
|
height: compactMode ? 72 : 78
|
||||||
radius: 10
|
radius: 10
|
||||||
color: cardMouse.containsMouse ? cardHover : card
|
|
||||||
border.color: cardBorder
|
|
||||||
|
|
||||||
MouseArea {
|
readonly property bool isCurrent: {
|
||||||
id: cardMouse
|
if (!backend || !modelData) return false;
|
||||||
anchors.fill: parent
|
var currentUuid = backend.currentStationUuid;
|
||||||
hoverEnabled: true
|
var currentUrl = backend.currentStationUrl;
|
||||||
acceptedButtons: Qt.LeftButton
|
var cardUuid = modelData.uuid || "";
|
||||||
onClicked: {
|
var cardUrl = modelData.urlResolved || modelData.url || "";
|
||||||
if (!backend) return
|
if (currentUuid !== "" && cardUuid !== "") {
|
||||||
if (currentPage === "favorites") {
|
return currentUuid === cardUuid;
|
||||||
backend.playFavoriteStation(index)
|
|
||||||
} else {
|
|
||||||
backend.playStation(index)
|
|
||||||
}
|
}
|
||||||
|
return currentUrl !== "" && currentUrl === cardUrl;
|
||||||
}
|
}
|
||||||
}
|
readonly property bool isPlaying: isCurrent && backend && backend.player && backend.player.playing
|
||||||
|
|
||||||
RowLayout {
|
color: stationCard.isCurrent
|
||||||
anchors.fill: parent
|
? (cardMouse.containsMouse ? "#1d3350" : "#16283e")
|
||||||
anchors.margins: 10
|
: (cardMouse.containsMouse ? cardHover : card)
|
||||||
spacing: 10
|
border.color: stationCard.isCurrent ? accent : cardBorder
|
||||||
|
border.width: stationCard.isCurrent ? 2 : 1
|
||||||
|
|
||||||
Rectangle {
|
MouseArea {
|
||||||
Layout.preferredWidth: 44
|
id: cardMouse
|
||||||
Layout.preferredHeight: 44
|
anchors.fill: parent
|
||||||
radius: 8
|
hoverEnabled: true
|
||||||
color: "#123154"
|
acceptedButtons: Qt.LeftButton
|
||||||
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 ? "★" : "☆"
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
if (!backend) return
|
if (!backend) return
|
||||||
backend.toggleFavoriteById(stationCard.modelData.uuid, stationCard.modelData.urlResolved)
|
if (stationCard.isCurrent) {
|
||||||
toast(stationCard.modelData.isFavorite ? qsTr("Removed from favorites") : qsTr("Added to favorites"))
|
backend.player.togglePlayPause()
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
Layout.preferredWidth: compactMode ? 34 : 40
|
|
||||||
Layout.preferredHeight: 40
|
|
||||||
text: "▶"
|
|
||||||
onClicked: {
|
|
||||||
if (!backend) return
|
|
||||||
if (currentPage === "favorites") {
|
|
||||||
backend.playFavoriteStation(index)
|
|
||||||
} else {
|
} 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
|
visible: stationList.count === 0
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
text: qsTr("No stations loaded yet")
|
text: currentPage === "history" ? qsTr("No playback history") : qsTr("No stations loaded yet")
|
||||||
color: textMain
|
color: textMain
|
||||||
font.bold: true
|
font.bold: true
|
||||||
}
|
}
|
||||||
|
|
||||||
Label {
|
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
|
color: textMuted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ void RadioBackend::setupConnections()
|
|||||||
m_lastStationName = name;
|
m_lastStationName = name;
|
||||||
emit resumeStateChanged();
|
emit resumeStateChanged();
|
||||||
saveState();
|
saveState();
|
||||||
|
} else {
|
||||||
|
m_currentStationUuid.clear();
|
||||||
|
m_currentStationUrl.clear();
|
||||||
|
emit currentStationChanged();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -344,6 +348,28 @@ QVariantList RadioBackend::getRecentStations() const
|
|||||||
return m_recentStations;
|
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 RadioBackend::getFavoriteStations() const
|
||||||
{
|
{
|
||||||
QVariantList result;
|
QVariantList result;
|
||||||
@@ -363,6 +389,9 @@ bool RadioBackend::playRecentByUuid(const QString &uuid, const QString &urlResol
|
|||||||
emit resumeStateChanged();
|
emit resumeStateChanged();
|
||||||
recordRecentStation(toVariantMap(station));
|
recordRecentStation(toVariantMap(station));
|
||||||
saveState();
|
saveState();
|
||||||
|
m_currentStationUuid = station->uuid();
|
||||||
|
m_currentStationUrl = url;
|
||||||
|
emit currentStationChanged();
|
||||||
m_player->playUrl(url, station->name());
|
m_player->playUrl(url, station->name());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -381,6 +410,9 @@ bool RadioBackend::playRecentByUuid(const QString &uuid, const QString &urlResol
|
|||||||
emit resumeStateChanged();
|
emit resumeStateChanged();
|
||||||
recordRecentStation(station);
|
recordRecentStation(station);
|
||||||
saveState();
|
saveState();
|
||||||
|
m_currentStationUuid = station.value(QStringLiteral("uuid")).toString();
|
||||||
|
m_currentStationUrl = url;
|
||||||
|
emit currentStationChanged();
|
||||||
m_player->playUrl(url, m_lastStationName);
|
m_player->playUrl(url, m_lastStationName);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -410,6 +442,26 @@ void RadioBackend::resumeLastStation()
|
|||||||
station.insert(QStringLiteral("urlResolved"), m_lastStationUrl);
|
station.insert(QStringLiteral("urlResolved"), m_lastStationUrl);
|
||||||
recordRecentStation(station);
|
recordRecentStation(station);
|
||||||
saveState();
|
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);
|
m_player->playUrl(m_lastStationUrl, m_lastStationName.isEmpty() ? tr("Last played") : m_lastStationName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,6 +686,9 @@ void RadioBackend::playCurrentSelection()
|
|||||||
emit resumeStateChanged();
|
emit resumeStateChanged();
|
||||||
recordRecentStation(toVariantMap(station));
|
recordRecentStation(toVariantMap(station));
|
||||||
saveState();
|
saveState();
|
||||||
|
m_currentStationUuid = station->uuid();
|
||||||
|
m_currentStationUrl = url;
|
||||||
|
emit currentStationChanged();
|
||||||
m_player->playUrl(url, station->name());
|
m_player->playUrl(url, station->name());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,12 +24,18 @@ class RadioBackend : public QObject
|
|||||||
Q_PROPERTY(bool canResumeLastStation READ canResumeLastStation NOTIFY resumeStateChanged)
|
Q_PROPERTY(bool canResumeLastStation READ canResumeLastStation NOTIFY resumeStateChanged)
|
||||||
Q_PROPERTY(QString lastStationName READ lastStationName NOTIFY resumeStateChanged)
|
Q_PROPERTY(QString lastStationName READ lastStationName NOTIFY resumeStateChanged)
|
||||||
Q_PROPERTY(QString filterQuery READ filterQuery WRITE setFilterQuery NOTIFY filterQueryChanged)
|
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:
|
public:
|
||||||
explicit RadioBackend(QObject *parent = nullptr);
|
explicit RadioBackend(QObject *parent = nullptr);
|
||||||
|
|
||||||
QList<QObject*> stations() const;
|
QList<QObject*> stations() const;
|
||||||
QList<QObject*> favoriteStations() 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; }
|
BearPlayer* player() const { return m_player; }
|
||||||
bool loading() const { return m_loading; }
|
bool loading() const { return m_loading; }
|
||||||
QString lastError() const { return m_lastError; }
|
QString lastError() const { return m_lastError; }
|
||||||
@@ -70,6 +76,7 @@ signals:
|
|||||||
void lastErrorChanged();
|
void lastErrorChanged();
|
||||||
void resumeStateChanged();
|
void resumeStateChanged();
|
||||||
void filterQueryChanged();
|
void filterQueryChanged();
|
||||||
|
void currentStationChanged();
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void onStationsLoaded(const QList<RadioStation*> &stations);
|
void onStationsLoaded(const QList<RadioStation*> &stations);
|
||||||
@@ -88,6 +95,8 @@ private:
|
|||||||
QString m_lastStationUrl;
|
QString m_lastStationUrl;
|
||||||
QString m_filterQuery;
|
QString m_filterQuery;
|
||||||
QVariantList m_recentStations;
|
QVariantList m_recentStations;
|
||||||
|
QString m_currentStationUuid;
|
||||||
|
QString m_currentStationUrl;
|
||||||
|
|
||||||
void setupConnections();
|
void setupConnections();
|
||||||
void loadFavorites();
|
void loadFavorites();
|
||||||
|
|||||||
@@ -211,6 +211,18 @@
|
|||||||
<source>Copyright (c) 2026</source>
|
<source>Copyright (c) 2026</source>
|
||||||
<translation>Copyright (c) 2026</translation>
|
<translation>Copyright (c) 2026</translation>
|
||||||
</message>
|
</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>
|
||||||
<context>
|
<context>
|
||||||
<name>RadioBackend</name>
|
<name>RadioBackend</name>
|
||||||
|
|||||||
Reference in New Issue
Block a user