Bump version to 1.0.1, implement automatic online search fallback with debounce, cover art notifications, and stream details edit

This commit is contained in:
Sebastian Palencsar
2026-05-22 16:44:21 +02:00
parent 79e2278a54
commit 8f49573c8a
9 changed files with 306 additions and 33 deletions

View File

@@ -1,7 +1,7 @@
cmake_minimum_required(VERSION 3.16) cmake_minimum_required(VERSION 3.16)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
project(bearwave VERSION 1.0.0 LANGUAGES CXX) project(bearwave VERSION 1.0.1 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)

View File

@@ -1,5 +1,5 @@
pkgname=bearwave-git pkgname=bearwave-git
pkgver=1.0.0 pkgver=1.0.1
pkgrel=1 pkgrel=1
pkgdesc="A KDE-focused desktop internet radio app" pkgdesc="A KDE-focused desktop internet radio app"
arch=('x86_64') arch=('x86_64')
@@ -14,7 +14,7 @@ md5sums=('SKIP')
pkgver() { pkgver() {
cd "${srcdir}/${pkgname%-git}" cd "${srcdir}/${pkgname%-git}"
printf "1.0.0.r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" printf "1.0.1.r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
} }
build() { build() {

View File

@@ -17,7 +17,7 @@ int main(int argc, char *argv[])
QApplication::setApplicationName(QStringLiteral("BearWave")); QApplication::setApplicationName(QStringLiteral("BearWave"));
QApplication::setDesktopFileName(QStringLiteral("org.kde.bearwave.desktop")); QApplication::setDesktopFileName(QStringLiteral("org.kde.bearwave.desktop"));
QApplication::setOrganizationName(QStringLiteral("BearWave")); QApplication::setOrganizationName(QStringLiteral("BearWave"));
QApplication::setApplicationVersion(QStringLiteral("1.0.0")); QApplication::setApplicationVersion(QStringLiteral("1.0.1"));
QApplication app(argc, argv); QApplication app(argc, argv);
app.setWindowIcon(QIcon::fromTheme(QStringLiteral("org.kde.bearwave"))); app.setWindowIcon(QIcon::fromTheme(QStringLiteral("org.kde.bearwave")));

View File

@@ -217,8 +217,19 @@ QVariantMap MprisPlayerAdaptor::metadata() const
if (!artist.isEmpty()) { if (!artist.isEmpty()) {
map.insert(QStringLiteral("xesam:artist"), QStringList{artist}); map.insert(QStringLiteral("xesam:artist"), QStringList{artist});
} }
if (!coverUrl.isEmpty()) { QString artUrl = coverUrl;
map.insert(QStringLiteral("mpris:artUrl"), coverUrl); if (artUrl.isEmpty() && m_backend) {
QObject *stationObj = m_backend->currentStation();
if (stationObj) {
QString fav = stationObj->property("favicon").toString();
if (fav.startsWith(QLatin1String("https://"))) {
artUrl = fav;
}
}
}
if (!artUrl.isEmpty()) {
map.insert(QStringLiteral("mpris:artUrl"), artUrl);
} else { } else {
QString iconPath = QStandardPaths::locate(QStandardPaths::GenericDataLocation, QStringLiteral("icons/hicolor/256x256/apps/org.kde.bearwave.png")); QString iconPath = QStandardPaths::locate(QStandardPaths::GenericDataLocation, QStringLiteral("icons/hicolor/256x256/apps/org.kde.bearwave.png"));
if (!iconPath.isEmpty()) { if (!iconPath.isEmpty()) {

View File

@@ -80,20 +80,31 @@ void NotificationManager::onTrackInfoChanged()
return; return;
} }
if (coverUrl.isEmpty()) { QString targetUrl = coverUrl;
if (targetUrl.isEmpty() && m_backend) {
QObject *stationObj = m_backend->currentStation();
if (stationObj) {
targetUrl = stationObj->property("favicon").toString();
if (!targetUrl.startsWith(QLatin1String("https://"))) {
targetUrl.clear();
}
}
}
if (targetUrl.isEmpty()) {
if (!m_notifyTimer.isActive()) { if (!m_notifyTimer.isActive()) {
m_notifyTimer.start(800); m_notifyTimer.start(800);
} }
} else { } else {
m_notifiedCoverUrl = coverUrl; m_notifiedCoverUrl = targetUrl;
const QString hash = QString(QCryptographicHash::hash(coverUrl.toUtf8(), QCryptographicHash::Md5).toHex()); const QString hash = QString(QCryptographicHash::hash(targetUrl.toUtf8(), QCryptographicHash::Md5).toHex());
const QString filePath = m_coversDir + "/" + hash + ".jpg"; const QString filePath = m_coversDir + "/" + hash + ".jpg";
if (QFile::exists(filePath)) { if (QFile::exists(filePath)) {
m_activeCoverPath = filePath; m_activeCoverPath = filePath;
triggerNotification(); triggerNotification();
} else { } else {
downloadCover(coverUrl); downloadCover(targetUrl);
if (!m_notifyTimer.isActive()) { if (!m_notifyTimer.isActive()) {
m_notifyTimer.start(800); m_notifyTimer.start(800);
} }

View File

@@ -133,7 +133,7 @@ ApplicationWindow {
Rectangle { Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: compactMode ? (headerContent.implicitHeight + 20) : 176 Layout.preferredHeight: headerContent.implicitHeight + 20
radius: 12 radius: 12
color: panel color: panel
border.color: cardBorder border.color: cardBorder
@@ -338,6 +338,13 @@ ApplicationWindow {
} }
} }
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 1
color: cardBorder
opacity: 0.6
}
RowLayout { RowLayout {
visible: !compactMode visible: !compactMode
Layout.fillWidth: true Layout.fillWidth: true
@@ -351,6 +358,7 @@ ApplicationWindow {
if (backend) { if (backend) {
backend.filterQuery = text backend.filterQuery = text
} }
searchTimer.restart()
} }
onAccepted: { onAccepted: {
if (text.length < 2 || !backend) return if (text.length < 2 || !backend) return
@@ -491,6 +499,7 @@ ApplicationWindow {
text: qsTr("Genre:") text: qsTr("Genre:")
color: textMuted color: textMuted
font.pixelSize: 11 font.pixelSize: 11
rightPadding: 10
} }
Button { Button {
@@ -526,12 +535,13 @@ ApplicationWindow {
} }
} }
Item { Layout.preferredWidth: 10 } Item { Layout.preferredWidth: 20 }
Label { Label {
text: qsTr("Country:") text: qsTr("Country:")
color: textMuted color: textMuted
font.pixelSize: 11 font.pixelSize: 11
rightPadding: 10
} }
Button { Button {
@@ -596,7 +606,10 @@ ApplicationWindow {
color: textMuted color: textMuted
font.pixelSize: 11 font.pixelSize: 11
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
padding: 8 leftPadding: 8
rightPadding: 16
topPadding: 8
bottomPadding: 8
} }
Button { Button {
@@ -643,7 +656,10 @@ ApplicationWindow {
color: textMuted color: textMuted
font.pixelSize: 11 font.pixelSize: 11
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
padding: 8 leftPadding: 8
rightPadding: 16
topPadding: 8
bottomPadding: 8
} }
Button { Button {
@@ -791,6 +807,7 @@ ApplicationWindow {
border.color: accent border.color: accent
Image { Image {
id: stationFavicon
anchors.fill: parent anchors.fill: parent
anchors.margins: 3 anchors.margins: 3
source: (stationCard.modelData.favicon && stationCard.modelData.favicon.startsWith("https://")) source: (stationCard.modelData.favicon && stationCard.modelData.favicon.startsWith("https://"))
@@ -802,12 +819,13 @@ ApplicationWindow {
visible: source !== "" && status === Image.Ready visible: source !== "" && status === Image.Ready
} }
Label { Image {
anchors.centerIn: parent anchors.fill: parent
text: qsTr("FM") anchors.margins: 6
color: "#d8ecff" source: "qrc:/assets/app/bearwave.png"
font.bold: true fillMode: Image.PreserveAspectFit
visible: !parent.children[0].visible smooth: true
visible: !stationFavicon.visible
} }
} }
@@ -889,6 +907,7 @@ ApplicationWindow {
Label { Label {
Layout.fillWidth: true Layout.fillWidth: true
text: stationCard.modelData.country + " • " text: stationCard.modelData.country + " • "
+ (stationCard.modelData.codec && stationCard.modelData.codec !== "unknown" && stationCard.modelData.codec !== "" ? stationCard.modelData.codec.toUpperCase() + " • " : "")
+ (stationCard.modelData.bitrate > 0 ? stationCard.modelData.bitrate + " kbps" : qsTr("Stream")) + (stationCard.modelData.bitrate > 0 ? stationCard.modelData.bitrate + " kbps" : qsTr("Stream"))
color: textMuted color: textMuted
font.pixelSize: 11 font.pixelSize: 11
@@ -896,6 +915,19 @@ ApplicationWindow {
} }
} }
Button {
visible: !stationCard.modelData.uuid
Layout.preferredWidth: compactMode ? 34 : 40
Layout.preferredHeight: 40
text: "✏"
ToolTip.visible: hovered
ToolTip.text: qsTr("Edit Station")
onClicked: {
editDialog.stationObject = stationCard.modelData
editDialog.setupAndOpen()
}
}
Button { Button {
Layout.preferredWidth: compactMode ? 34 : 40 Layout.preferredWidth: compactMode ? 34 : 40
Layout.preferredHeight: 40 Layout.preferredHeight: 40
@@ -972,11 +1004,27 @@ ApplicationWindow {
Image { Image {
id: coverImage id: coverImage
anchors.fill: parent anchors.fill: parent
source: backend && backend.player && backend.player.currentCoverArtUrl ? backend.player.currentCoverArtUrl : "qrc:/assets/app/bearwave.png" source: {
fillMode: backend && backend.player && backend.player.currentCoverArtUrl ? Image.PreserveAspectCrop : Image.PreserveAspectFit if (backend && backend.player && backend.player.currentCoverArtUrl && backend.player.currentCoverArtUrl !== "") {
return backend.player.currentCoverArtUrl;
}
if (backend && backend.currentStation && backend.currentStation.favicon && backend.currentStation.favicon.startsWith("https://")) {
return backend.currentStation.favicon;
}
return "qrc:/assets/app/bearwave.png";
}
fillMode: (backend && backend.player && backend.player.currentCoverArtUrl && backend.player.currentCoverArtUrl !== "") ? Image.PreserveAspectCrop : Image.PreserveAspectFit
smooth: true smooth: true
asynchronous: true asynchronous: true
anchors.margins: backend && backend.player && backend.player.currentCoverArtUrl ? 0 : 16 anchors.margins: {
if (backend && backend.player && backend.player.currentCoverArtUrl && backend.player.currentCoverArtUrl !== "") {
return 0;
}
if (backend && backend.currentStation && backend.currentStation.favicon && backend.currentStation.favicon.startsWith("https://")) {
return 8;
}
return 16;
}
} }
} }
@@ -985,13 +1033,58 @@ ApplicationWindow {
Layout.fillHeight: true Layout.fillHeight: true
spacing: 6 spacing: 6
Label { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
text: backend && backend.player ? (backend.player.currentStationName || qsTr("No station selected")) : qsTr("No station selected") spacing: 8
color: textMain
font.pixelSize: 16 Label {
font.bold: true Layout.fillWidth: true
elide: Text.ElideRight text: backend && backend.player ? (backend.player.currentStationName || qsTr("No station selected")) : qsTr("No station selected")
color: textMain
font.pixelSize: 16
font.bold: true
elide: Text.ElideRight
}
Rectangle {
id: codecBadge
visible: backend && backend.currentStation && backend.currentStation.codec && backend.currentStation.codec !== "unknown" && backend.currentStation.codec !== ""
height: 18
width: codecLabel.implicitWidth + 12
radius: 4
color: "transparent"
border.color: accent
border.width: 1
Label {
id: codecLabel
anchors.centerIn: parent
text: (backend && backend.currentStation && backend.currentStation.codec) ? backend.currentStation.codec.toUpperCase() : ""
color: accent
font.pixelSize: 9
font.bold: true
}
}
Rectangle {
id: bitrateBadge
visible: backend && backend.currentStation && backend.currentStation.bitrate > 0
height: 18
width: bitrateLabel.implicitWidth + 12
radius: 4
color: accent
border.color: accent
border.width: 1
Label {
id: bitrateLabel
anchors.centerIn: parent
text: (backend && backend.currentStation) ? backend.currentStation.bitrate + " kbps" : ""
color: "#ffffff"
font.pixelSize: 9
font.bold: true
}
}
} }
Label { Label {
@@ -1050,9 +1143,25 @@ ApplicationWindow {
} }
} }
Label { Button {
text: qsTr("Volume") id: muteButton
color: textMuted text: (backend && backend.player && backend.player.volume > 0) ? "🔊" : "🔇"
Layout.preferredWidth: 44
property real lastVolume: 0.5
ToolTip.visible: hovered
ToolTip.text: (backend && backend.player && backend.player.volume > 0) ? qsTr("Mute") : qsTr("Unmute")
onClicked: {
if (backend && backend.player) {
if (backend.player.volume > 0) {
lastVolume = backend.player.volume
backend.player.setVolume(0)
} else {
backend.player.setVolume(lastVolume > 0 ? lastVolume : 0.5)
}
}
}
} }
Slider { Slider {
@@ -1138,6 +1247,51 @@ ApplicationWindow {
} }
} }
Dialog {
id: editDialog
title: qsTr("Edit station")
modal: true
anchors.centerIn: parent
width: compactMode ? 320 : 420
standardButtons: Dialog.Ok | Dialog.Cancel
property var stationObject: null
contentItem: ColumnLayout {
spacing: 8
TextField { id: editName; Layout.fillWidth: true; placeholderText: qsTr("Name") }
TextField { id: editUrl; Layout.fillWidth: true; placeholderText: qsTr("Stream URL (http/https)") }
TextField { id: editCountry; Layout.fillWidth: true; placeholderText: qsTr("Country (optional)") }
}
function setupAndOpen() {
if (stationObject) {
editName.text = stationObject.name || ""
editUrl.text = stationObject.url || ""
editCountry.text = (stationObject.country === qsTr("Manual") ? "" : (stationObject.country || ""))
open()
}
}
onAccepted: {
if (backend && stationObject) {
backend.editManualStation(stationObject, editName.text, editUrl.text, editCountry.text)
toast(qsTr("Station updated"))
}
stationObject = null
editName.text = ""
editUrl.text = ""
editCountry.text = ""
}
onRejected: {
stationObject = null
editName.text = ""
editUrl.text = ""
editCountry.text = ""
}
}
Dialog { Dialog {
id: aboutDialog id: aboutDialog
modal: true modal: true
@@ -1357,6 +1511,21 @@ ApplicationWindow {
} }
} }
Timer {
id: searchTimer
interval: 600
repeat: false
onTriggered: {
var text = searchField.text.trim()
if (text.length < 2 || !backend) return
if (currentPage === "search" || stationList.count === 0) {
currentPage = "search"
backend.searchStations(text)
}
}
}
Timer { Timer {
id: contentFadeRestart id: contentFadeRestart
interval: 120 interval: 120

View File

@@ -148,6 +148,7 @@ void RadioBackend::searchStations(const QString &query)
if (query.isEmpty()) { if (query.isEmpty()) {
return; return;
} }
qDebug() << "RadioBackend: searchStations called with query:" << query;
setLoading(true); setLoading(true);
m_radioBrowser->search(query); m_radioBrowser->search(query);
} }
@@ -692,6 +693,74 @@ void RadioBackend::playCurrentSelection()
m_player->playUrl(url, station->name()); m_player->playUrl(url, station->name());
} }
QObject* RadioBackend::currentStation() const
{
for (RadioStation *s : m_favorites) {
if (matchesStation(s, m_currentStationUuid, m_currentStationUrl)) {
return s;
}
}
for (RadioStation *s : m_stations) {
if (matchesStation(s, m_currentStationUuid, m_currentStationUrl)) {
return s;
}
}
return nullptr;
}
void RadioBackend::editManualStation(QObject *stationObj, const QString &name, const QString &url, const QString &country)
{
RadioStation *station = qobject_cast<RadioStation*>(stationObj);
if (!station || name.trimmed().isEmpty() || url.trimmed().isEmpty()) {
return;
}
QString oldUrl = station->url();
QString oldUrlResolved = station->urlResolved();
station->setName(name.trimmed());
station->setUrl(url.trimmed());
station->setUrlResolved(url.trimmed());
station->setCountry(country.trimmed().isEmpty() ? tr("Manual") : country.trimmed());
// Update in favorites list if present
for (RadioStation *fav : m_favorites) {
if (fav == station || (fav->uuid().isEmpty() && (fav->url() == oldUrl || fav->urlResolved() == oldUrlResolved))) {
fav->setName(station->name());
fav->setUrl(station->url());
fav->setUrlResolved(station->urlResolved());
fav->setCountry(station->country());
}
}
// Save updated favorites
saveFavorites();
// Update in main stations list if present
for (RadioStation *s : m_stations) {
if (s == station || (s->uuid().isEmpty() && (s->url() == oldUrl || s->urlResolved() == oldUrlResolved))) {
s->setName(station->name());
s->setUrl(station->url());
s->setUrlResolved(station->urlResolved());
s->setCountry(station->country());
}
}
// If it's the currently playing station, update player stream info
if (m_currentStationUrl == oldUrl || m_currentStationUrl == oldUrlResolved) {
m_currentStationUrl = station->urlResolved();
m_lastStationUrl = station->urlResolved();
m_lastStationName = station->name();
saveState();
emit resumeStateChanged();
emit currentStationChanged();
}
emit stationsChanged();
emit favoritesChanged();
emit listsChanged();
}
QVariantMap RadioBackend::toVariantMap(const RadioStation *station) QVariantMap RadioBackend::toVariantMap(const RadioStation *station)
{ {
QVariantMap map; QVariantMap map;
@@ -705,6 +774,7 @@ QVariantMap RadioBackend::toVariantMap(const RadioStation *station)
map.insert(QStringLiteral("isFavorite"), station->isFavorite()); map.insert(QStringLiteral("isFavorite"), station->isFavorite());
map.insert(QStringLiteral("favicon"), station->favicon()); map.insert(QStringLiteral("favicon"), station->favicon());
map.insert(QStringLiteral("urlResolved"), station->urlResolved()); map.insert(QStringLiteral("urlResolved"), station->urlResolved());
map.insert(QStringLiteral("codec"), station->codec());
return map; return map;
} }

View File

@@ -27,6 +27,7 @@ class RadioBackend : public QObject
Q_PROPERTY(QVariantList recentStations READ recentStations NOTIFY listsChanged) Q_PROPERTY(QVariantList recentStations READ recentStations NOTIFY listsChanged)
Q_PROPERTY(QString currentStationUuid READ currentStationUuid NOTIFY currentStationChanged) Q_PROPERTY(QString currentStationUuid READ currentStationUuid NOTIFY currentStationChanged)
Q_PROPERTY(QString currentStationUrl READ currentStationUrl NOTIFY currentStationChanged) Q_PROPERTY(QString currentStationUrl READ currentStationUrl NOTIFY currentStationChanged)
Q_PROPERTY(QObject* currentStation READ currentStation NOTIFY currentStationChanged)
public: public:
explicit RadioBackend(QObject *parent = nullptr); explicit RadioBackend(QObject *parent = nullptr);
@@ -34,6 +35,7 @@ public:
QList<QObject*> stations() const; QList<QObject*> stations() const;
QList<QObject*> favoriteStations() const; QList<QObject*> favoriteStations() const;
QVariantList recentStations() const; QVariantList recentStations() const;
QObject* currentStation() const;
QString currentStationUuid() const { return m_currentStationUuid; } QString currentStationUuid() const { return m_currentStationUuid; }
QString currentStationUrl() const { return m_currentStationUrl; } QString currentStationUrl() const { return m_currentStationUrl; }
BearPlayer* player() const { return m_player; } BearPlayer* player() const { return m_player; }
@@ -61,6 +63,7 @@ public:
Q_INVOKABLE void toggleFavorite(int index); Q_INVOKABLE void toggleFavorite(int index);
Q_INVOKABLE void toggleFavoriteById(const QString &uuid, const QString &urlResolved); Q_INVOKABLE void toggleFavoriteById(const QString &uuid, const QString &urlResolved);
Q_INVOKABLE void addManualStation(const QString &name, const QString &url, const QString &country); Q_INVOKABLE void addManualStation(const QString &name, const QString &url, const QString &country);
Q_INVOKABLE void editManualStation(QObject *stationObj, const QString &name, const QString &url, const QString &country);
Q_INVOKABLE void sortStations(const QString &mode); Q_INVOKABLE void sortStations(const QString &mode);
Q_INVOKABLE QVariantList getRecentStations() const; Q_INVOKABLE QVariantList getRecentStations() const;
Q_INVOKABLE QVariantList getFavoriteStations() const; Q_INVOKABLE QVariantList getFavoriteStations() const;

View File

@@ -9,6 +9,7 @@
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QCryptographicHash> #include <QCryptographicHash>
#include <QDebug>
namespace { namespace {
QString apiCacheDir() QString apiCacheDir()
@@ -25,7 +26,15 @@ RadioBrowser::RadioBrowser(QObject *parent)
void RadioBrowser::search(const QString &query) void RadioBrowser::search(const QString &query)
{ {
QString endpoint = "/stations/byname/" + QUrl::toPercentEncoding(query); QUrlQuery queryParams;
queryParams.addQueryItem("name", query);
queryParams.addQueryItem("hidebroken", "true");
queryParams.addQueryItem("limit", "50");
queryParams.addQueryItem("order", "votes");
queryParams.addQueryItem("reverse", "true");
QString endpoint = "/stations/search?" + queryParams.toString(QUrl::FullyEncoded);
qDebug() << "RadioBrowser: Searching with endpoint:" << endpoint;
makeRequest(endpoint); makeRequest(endpoint);
} }