diff --git a/CMakeLists.txt b/CMakeLists.txt index b566038..ac2d141 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.16) 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_REQUIRED ON) diff --git a/PKGBUILD b/PKGBUILD index 94edfd8..c1e7174 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,5 +1,5 @@ pkgname=bearwave-git -pkgver=1.0.0 +pkgver=1.0.1 pkgrel=1 pkgdesc="A KDE-focused desktop internet radio app" arch=('x86_64') @@ -14,7 +14,7 @@ md5sums=('SKIP') pkgver() { 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() { diff --git a/src/main.cpp b/src/main.cpp index 1f02f0c..23b0391 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -17,7 +17,7 @@ int main(int argc, char *argv[]) QApplication::setApplicationName(QStringLiteral("BearWave")); QApplication::setDesktopFileName(QStringLiteral("org.kde.bearwave.desktop")); QApplication::setOrganizationName(QStringLiteral("BearWave")); - QApplication::setApplicationVersion(QStringLiteral("1.0.0")); + QApplication::setApplicationVersion(QStringLiteral("1.0.1")); QApplication app(argc, argv); app.setWindowIcon(QIcon::fromTheme(QStringLiteral("org.kde.bearwave"))); diff --git a/src/mprisadaptor.cpp b/src/mprisadaptor.cpp index e6c3d9b..4be7fc7 100644 --- a/src/mprisadaptor.cpp +++ b/src/mprisadaptor.cpp @@ -217,8 +217,19 @@ QVariantMap MprisPlayerAdaptor::metadata() const if (!artist.isEmpty()) { map.insert(QStringLiteral("xesam:artist"), QStringList{artist}); } - if (!coverUrl.isEmpty()) { - map.insert(QStringLiteral("mpris:artUrl"), coverUrl); + QString 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 { QString iconPath = QStandardPaths::locate(QStandardPaths::GenericDataLocation, QStringLiteral("icons/hicolor/256x256/apps/org.kde.bearwave.png")); if (!iconPath.isEmpty()) { diff --git a/src/notificationmanager.cpp b/src/notificationmanager.cpp index bfa7fb9..21dd378 100644 --- a/src/notificationmanager.cpp +++ b/src/notificationmanager.cpp @@ -80,20 +80,31 @@ void NotificationManager::onTrackInfoChanged() 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()) { m_notifyTimer.start(800); } } else { - m_notifiedCoverUrl = coverUrl; - const QString hash = QString(QCryptographicHash::hash(coverUrl.toUtf8(), QCryptographicHash::Md5).toHex()); + m_notifiedCoverUrl = targetUrl; + const QString hash = QString(QCryptographicHash::hash(targetUrl.toUtf8(), QCryptographicHash::Md5).toHex()); const QString filePath = m_coversDir + "/" + hash + ".jpg"; if (QFile::exists(filePath)) { m_activeCoverPath = filePath; triggerNotification(); } else { - downloadCover(coverUrl); + downloadCover(targetUrl); if (!m_notifyTimer.isActive()) { m_notifyTimer.start(800); } diff --git a/src/qml/Main.qml b/src/qml/Main.qml index 567e4b9..5f10fc3 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -133,7 +133,7 @@ ApplicationWindow { Rectangle { Layout.fillWidth: true - Layout.preferredHeight: compactMode ? (headerContent.implicitHeight + 20) : 176 + Layout.preferredHeight: headerContent.implicitHeight + 20 radius: 12 color: panel border.color: cardBorder @@ -338,6 +338,13 @@ ApplicationWindow { } } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: cardBorder + opacity: 0.6 + } + RowLayout { visible: !compactMode Layout.fillWidth: true @@ -351,6 +358,7 @@ ApplicationWindow { if (backend) { backend.filterQuery = text } + searchTimer.restart() } onAccepted: { if (text.length < 2 || !backend) return @@ -491,6 +499,7 @@ ApplicationWindow { text: qsTr("Genre:") color: textMuted font.pixelSize: 11 + rightPadding: 10 } Button { @@ -526,12 +535,13 @@ ApplicationWindow { } } - Item { Layout.preferredWidth: 10 } + Item { Layout.preferredWidth: 20 } Label { text: qsTr("Country:") color: textMuted font.pixelSize: 11 + rightPadding: 10 } Button { @@ -596,7 +606,10 @@ ApplicationWindow { color: textMuted font.pixelSize: 11 verticalAlignment: Text.AlignVCenter - padding: 8 + leftPadding: 8 + rightPadding: 16 + topPadding: 8 + bottomPadding: 8 } Button { @@ -643,7 +656,10 @@ ApplicationWindow { color: textMuted font.pixelSize: 11 verticalAlignment: Text.AlignVCenter - padding: 8 + leftPadding: 8 + rightPadding: 16 + topPadding: 8 + bottomPadding: 8 } Button { @@ -791,6 +807,7 @@ ApplicationWindow { border.color: accent Image { + id: stationFavicon anchors.fill: parent anchors.margins: 3 source: (stationCard.modelData.favicon && stationCard.modelData.favicon.startsWith("https://")) @@ -802,12 +819,13 @@ ApplicationWindow { visible: source !== "" && status === Image.Ready } - Label { - anchors.centerIn: parent - text: qsTr("FM") - color: "#d8ecff" - font.bold: true - visible: !parent.children[0].visible + Image { + anchors.fill: parent + anchors.margins: 6 + source: "qrc:/assets/app/bearwave.png" + fillMode: Image.PreserveAspectFit + smooth: true + visible: !stationFavicon.visible } } @@ -889,6 +907,7 @@ ApplicationWindow { Label { Layout.fillWidth: true 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")) color: textMuted 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 { Layout.preferredWidth: compactMode ? 34 : 40 Layout.preferredHeight: 40 @@ -972,11 +1004,27 @@ ApplicationWindow { Image { id: coverImage anchors.fill: parent - source: backend && backend.player && backend.player.currentCoverArtUrl ? backend.player.currentCoverArtUrl : "qrc:/assets/app/bearwave.png" - fillMode: backend && backend.player && backend.player.currentCoverArtUrl ? Image.PreserveAspectCrop : Image.PreserveAspectFit + source: { + 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 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 spacing: 6 - Label { + RowLayout { Layout.fillWidth: true - 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 + spacing: 8 + + Label { + Layout.fillWidth: true + 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 { @@ -1050,9 +1143,25 @@ ApplicationWindow { } } - Label { - text: qsTr("Volume") - color: textMuted + Button { + id: muteButton + 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 { @@ -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 { id: aboutDialog 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 { id: contentFadeRestart interval: 120 diff --git a/src/radiobackend.cpp b/src/radiobackend.cpp index d9a282f..e801878 100644 --- a/src/radiobackend.cpp +++ b/src/radiobackend.cpp @@ -148,6 +148,7 @@ void RadioBackend::searchStations(const QString &query) if (query.isEmpty()) { return; } + qDebug() << "RadioBackend: searchStations called with query:" << query; setLoading(true); m_radioBrowser->search(query); } @@ -692,6 +693,74 @@ void RadioBackend::playCurrentSelection() 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(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 map; @@ -705,6 +774,7 @@ QVariantMap RadioBackend::toVariantMap(const RadioStation *station) map.insert(QStringLiteral("isFavorite"), station->isFavorite()); map.insert(QStringLiteral("favicon"), station->favicon()); map.insert(QStringLiteral("urlResolved"), station->urlResolved()); + map.insert(QStringLiteral("codec"), station->codec()); return map; } diff --git a/src/radiobackend.h b/src/radiobackend.h index 4d1990e..5f127a5 100644 --- a/src/radiobackend.h +++ b/src/radiobackend.h @@ -27,6 +27,7 @@ class RadioBackend : public QObject Q_PROPERTY(QVariantList recentStations READ recentStations NOTIFY listsChanged) Q_PROPERTY(QString currentStationUuid READ currentStationUuid NOTIFY currentStationChanged) Q_PROPERTY(QString currentStationUrl READ currentStationUrl NOTIFY currentStationChanged) + Q_PROPERTY(QObject* currentStation READ currentStation NOTIFY currentStationChanged) public: explicit RadioBackend(QObject *parent = nullptr); @@ -34,6 +35,7 @@ public: QList stations() const; QList favoriteStations() const; QVariantList recentStations() const; + QObject* currentStation() const; QString currentStationUuid() const { return m_currentStationUuid; } QString currentStationUrl() const { return m_currentStationUrl; } BearPlayer* player() const { return m_player; } @@ -61,6 +63,7 @@ public: Q_INVOKABLE void toggleFavorite(int index); 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 editManualStation(QObject *stationObj, const QString &name, const QString &url, const QString &country); Q_INVOKABLE void sortStations(const QString &mode); Q_INVOKABLE QVariantList getRecentStations() const; Q_INVOKABLE QVariantList getFavoriteStations() const; diff --git a/src/radiobrowser.cpp b/src/radiobrowser.cpp index cc9c1d7..6d1f7af 100644 --- a/src/radiobrowser.cpp +++ b/src/radiobrowser.cpp @@ -9,6 +9,7 @@ #include #include #include +#include namespace { QString apiCacheDir() @@ -25,7 +26,15 @@ RadioBrowser::RadioBrowser(QObject *parent) 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); }