diff --git a/CHANGELOG.md b/CHANGELOG.md index ec1245c..6ebf6be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.0.3] - 2026-05-30 + +### Added + +- Dynamic World Categories dashboard showing country flags and popular genre tags. +- Dynamic country name localization mapping for German translation support. +- Local JSON caching for countries retrieved from the Radio Browser API. + +### Changed + +- Optimized Categories view layout to prevent text clipping (dynamic grid columns and scroll margins). ## [1.0.2] - 2026-05-28 diff --git a/CMakeLists.txt b/CMakeLists.txt index a3e3071..8d03160 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.2 LANGUAGES CXX) +project(bearwave VERSION 1.0.3 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/README.md b/README.md index a499a83..57c0149 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ BearWave is designed for fast station browsing, simple playback controls, favori | --- | --- | | ![Main Window](screenshots/screen01.png) | ![Station Browser](screenshots/screen02.png) | | ![About Dialog](screenshots/screen03.png) | ![Additional View](screenshots/screen04.png) | +| ![World View](screenshots/screen05.png) | | Screenshots: KDE Plasma on Linux. @@ -71,7 +72,8 @@ BearWave intentionally does not aim to be: ## Features - internet radio via the Radio Browser API with local JSON caching -- station pages for Top, Germany, Netherlands, and quick world/genre filters +- station pages for Top, Germany, Netherlands, and a dynamic World Categories dashboard +- interactive World View to search/browse stations by country flags and popular genre tags - local search and filtering by name, genre, and country - sorting by name, bitrate, and votes - favorites with persistent local storage diff --git a/de.nerdbear.bearwave.metainfo.xml b/de.nerdbear.bearwave.metainfo.xml index e68e593..5708bac 100644 --- a/de.nerdbear.bearwave.metainfo.xml +++ b/de.nerdbear.bearwave.metainfo.xml @@ -59,6 +59,16 @@ + + +
    +
  • Added dynamic World Categories dashboard showing country flags and popular genre tags
  • +
  • Added country name localization support for German systems
  • +
  • Added local API caching for countries retrieved from Radio Browser
  • +
  • Optimized Categories view layout to prevent text clipping
  • +
+
+
    diff --git a/screenshots/screen05.png b/screenshots/screen05.png new file mode 100644 index 0000000..4f7b1b8 Binary files /dev/null and b/screenshots/screen05.png differ diff --git a/src/main.cpp b/src/main.cpp index 0a5f968..c8b6753 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -22,7 +22,7 @@ int main(int argc, char *argv[]) QApplication::setApplicationName(QStringLiteral("BearWave")); QApplication::setDesktopFileName(QStringLiteral("de.nerdbear.bearwave")); QApplication::setOrganizationName(QStringLiteral("BearWave")); - QApplication::setApplicationVersion(QStringLiteral("1.0.2")); + QApplication::setApplicationVersion(QStringLiteral("1.0.3")); QApplication app(argc, argv); diff --git a/src/qml/Main.qml b/src/qml/Main.qml index 326b0c4..6de13e8 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -81,6 +81,9 @@ ApplicationWindow { property bool compactMode: width < 780 property real contentOpacity: 1.0 property string activeQuickFilter: "" + property string selectedWorldCategory: "" + property string selectedWorldType: "" + property string countrySearchText: "" readonly property color bgA: "#0f141b" readonly property color bgB: "#131b25" @@ -93,11 +96,52 @@ ApplicationWindow { readonly property color textMuted: "#9eb1c9" readonly property color warn: "#ff8b8b" + readonly property var worldTags: [ + { name: qsTr("Pop"), tag: "pop", icon: "🎵" }, + { name: qsTr("Rock"), tag: "rock", icon: "🎸" }, + { name: qsTr("Electronic"), tag: "electronic", icon: "🎹" }, + { name: qsTr("Classical"), tag: "classical", icon: "🎻" }, + { name: qsTr("Jazz"), tag: "jazz", icon: "🎷" }, + { name: qsTr("Metal"), tag: "metal", icon: "🤘" }, + { name: qsTr("Hip Hop"), tag: "hiphop", icon: "🎤" }, + { name: qsTr("Chillout"), tag: "chillout", icon: "🌴" }, + { name: qsTr("News / Talk"), tag: "news", icon: "📻" }, + { name: qsTr("Soundtracks"), tag: "soundtrack", icon: "🎬" }, + { name: qsTr("Ambient"), tag: "ambient", icon: "🌌" }, + { name: qsTr("Blues / Soul"), tag: "blues", icon: "🎺" } + ] + function toast(message) { toastLabel.text = message toastPopup.open() } + function getFilteredCountries() { + if (!backend || !backend.countries) return []; + var query = countrySearchText.toLowerCase().trim(); + var list = []; + if (query === "") { + list.push({ name: qsTr("Top Global"), code: "GLOBAL" }); + } + for (var i = 0; i < backend.countries.length; i++) { + var c = backend.countries[i]; + if (query === "" || c.name.toLowerCase().indexOf(query) !== -1 || c.code.toLowerCase().indexOf(query) !== -1) { + list.push(c); + } + } + return list; + } + + function getFlagEmoji(countryCode) { + if (!countryCode || countryCode.length !== 2) return "🌎"; + var codeUpper = countryCode.toUpperCase(); + var codePoints = []; + for (var i = 0; i < codeUpper.length; i++) { + codePoints.push(127397 + codeUpper.charCodeAt(i)); + } + return String.fromCodePoint.apply(null, codePoints); + } + function activeModel() { if (!backend) { return [] @@ -106,6 +150,8 @@ ApplicationWindow { return backend.favoriteStations } else if (currentPage === "history") { return backend.recentStations + } else if (currentPage === "world" && selectedWorldCategory === "") { + return [] } else { return backend.stations } @@ -114,6 +160,11 @@ ApplicationWindow { onCurrentPageChanged: { contentOpacity = 0.85 contentFadeRestart.restart() + if (currentPage !== "world") { + selectedWorldCategory = "" + selectedWorldType = "" + countrySearchText = "" + } } Shortcut { @@ -596,7 +647,11 @@ ApplicationWindow { if (!backend) return currentPage = "world" activeQuickFilter = "world" - backend.loadWorldStations() + selectedWorldCategory = "" + selectedWorldType = "" + if (backend.countries.length === 0) { + backend.loadCountries() + } } } @@ -714,7 +769,11 @@ ApplicationWindow { if (!backend) return currentPage = "world" activeQuickFilter = "world" - backend.loadWorldStations() + selectedWorldCategory = "" + selectedWorldType = "" + if (backend.countries.length === 0) { + backend.loadCountries() + } } } } @@ -744,217 +803,84 @@ ApplicationWindow { NumberAnimation { duration: 180; easing.type: Easing.OutCubic } } - ScrollView { - id: stationScrollView + ColumnLayout { anchors.fill: parent anchors.margins: 10 - clip: true - ScrollBar.vertical.policy: ScrollBar.AlwaysOn + spacing: 10 - ListView { - id: stationList - width: stationScrollView.availableWidth - spacing: 8 - model: activeModel() - visible: count > 0 + RowLayout { + id: worldHeader + Layout.fillWidth: true + visible: currentPage === "world" && selectedWorldCategory !== "" + spacing: 12 - delegate: Rectangle { - id: stationCard - required property int index - required property var modelData + Button { + text: qsTr("← Back to Categories") + onClicked: { + selectedWorldCategory = "" + selectedWorldType = "" + } + } + + Label { + text: selectedWorldType === "country" + ? (qsTr("World > Country: ") + selectedWorldCategory) + : (qsTr("World > Genre: ") + selectedWorldCategory) + color: textMain + font.bold: true + font.pixelSize: 13 + Layout.fillWidth: true + elide: Text.ElideRight + } + } + + ScrollView { + id: stationScrollView + Layout.fillWidth: true + Layout.fillHeight: true + visible: !(currentPage === "world" && selectedWorldCategory === "") + clip: true + ScrollBar.vertical.policy: ScrollBar.AlwaysOn + + ListView { + id: stationList width: stationScrollView.availableWidth - height: compactMode ? 72 : 78 - radius: 10 + spacing: 8 + model: activeModel() + visible: count > 0 - 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; + delegate: Rectangle { + id: stationCard + required property int index + required property var modelData + width: stationScrollView.availableWidth + height: compactMode ? 72 : 78 + radius: 10 + + 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; } - return currentUrl !== "" && currentUrl === cardUrl; - } - readonly property bool isPlaying: isCurrent && backend && backend.player && backend.player.playing + readonly property bool isPlaying: isCurrent && backend && backend.player && backend.player.playing - color: stationCard.isCurrent - ? (cardMouse.containsMouse ? "#1d3350" : "#16283e") - : (cardMouse.containsMouse ? cardHover : card) - border.color: stationCard.isCurrent ? accent : cardBorder - border.width: stationCard.isCurrent ? 2 : 1 + color: stationCard.isCurrent + ? (cardMouse.containsMouse ? "#1d3350" : "#16283e") + : (cardMouse.containsMouse ? cardHover : card) + border.color: stationCard.isCurrent ? accent : cardBorder + border.width: stationCard.isCurrent ? 2 : 1 - MouseArea { - id: cardMouse - anchors.fill: parent - hoverEnabled: true - acceptedButtons: Qt.LeftButton - onClicked: { - if (!backend) return - if (stationCard.isCurrent) { - backend.player.togglePlayPause() - } else { - if (currentPage === "favorites") { - backend.playFavoriteStation(index) - } else if (currentPage === "history") { - backend.playRecentByUuid(modelData.uuid, modelData.urlResolved) - } else { - backend.playStation(index) - } - } - } - } - - RowLayout { - anchors.fill: parent - anchors.margins: 10 - spacing: 10 - - Rectangle { - Layout.preferredWidth: 44 - Layout.preferredHeight: 44 - radius: 8 - color: "#123154" - border.color: accent - - Image { - id: stationFavicon - 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 - } - - Image { - anchors.fill: parent - anchors.margins: 6 - source: "qrc:/assets/app/bearwave.png" - fillMode: Image.PreserveAspectFit - smooth: true - visible: !stationFavicon.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.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 - elide: Text.ElideRight - } - } - - 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 - 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) ? "⏸" : "▶" + MouseArea { + id: cardMouse + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton onClicked: { if (!backend) return if (stationCard.isCurrent) { @@ -970,15 +896,395 @@ ApplicationWindow { } } } + + RowLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 10 + + Rectangle { + Layout.preferredWidth: 44 + Layout.preferredHeight: 44 + radius: 8 + color: "#123154" + border.color: accent + + Image { + id: stationFavicon + 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 + } + + Image { + anchors.fill: parent + anchors.margins: 6 + source: "qrc:/assets/app/bearwave.png" + fillMode: Image.PreserveAspectFit + smooth: true + visible: !stationFavicon.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.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 + elide: Text.ElideRight + } + } + + 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 + 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) + } + } + } + } + } } } - } + } + + ScrollView { + id: categoriesScrollView + Layout.fillWidth: true + Layout.fillHeight: true + visible: currentPage === "world" && selectedWorldCategory === "" + clip: true + ScrollBar.vertical.policy: ScrollBar.AsNeeded + + Column { + width: categoriesScrollView.availableWidth - 12 + spacing: 16 + + RowLayout { + width: parent.width + spacing: 10 + + Label { + text: qsTr("Explore World Stations") + font.bold: true + font.pixelSize: 16 + color: textMain + Layout.fillWidth: true + } + + TextField { + id: countryFilterInput + placeholderText: qsTr("Filter countries...") + font.pixelSize: 11 + Layout.preferredWidth: compactMode ? 140 : 200 + text: countrySearchText + onTextChanged: countrySearchText = text + } + } + + Label { + text: qsTr("Choose a country or music style to find radio stations from all over the world.") + font.pixelSize: 11 + color: textMuted + wrapMode: Text.WordWrap + width: parent.width + visible: countrySearchText === "" + } + + Rectangle { + width: parent.width + height: 1 + color: cardBorder + opacity: 0.4 + visible: countrySearchText === "" + } + + Label { + text: countrySearchText === "" ? qsTr("Countries") : qsTr("Filtered Countries") + font.bold: true + font.pixelSize: 13 + color: accent + } + + Flow { + id: countryFlow + width: parent.width + spacing: 8 + + Repeater { + model: getFilteredCountries() + delegate: Rectangle { + id: countryCard + width: compactMode ? (countryFlow.width - 8) / 2 : (countryFlow.width - 16) / 3 + height: 62 + radius: 8 + color: countryMouse.containsMouse ? cardHover : card + border.color: countryMouse.containsMouse ? accent : cardBorder + border.width: 1 + scale: countryMouse.containsMouse ? 1.02 : 1.0 + + Behavior on color { ColorAnimation { duration: 100 } } + Behavior on border.color { ColorAnimation { duration: 100 } } + Behavior on scale { NumberAnimation { duration: 100; easing.type: Easing.OutQuad } } + + MouseArea { + id: countryMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + selectedWorldCategory = modelData.name + selectedWorldType = "country" + if (modelData.code === "GLOBAL") { + backend.loadWorldStations() + } else { + backend.loadByCountryCode(modelData.code) + } + } + } + + Row { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 12 + + Label { + text: modelData.code === "GLOBAL" ? "🌎" : getFlagEmoji(modelData.code) + font.pixelSize: 26 + anchors.verticalCenter: parent.verticalCenter + } + + Column { + width: parent.width - 38 // 26 (emoji) + 12 (spacing) + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + Label { + width: parent.width + text: modelData.name + color: textMain + font.bold: true + font.pixelSize: 13 + elide: Text.ElideRight + } + + Label { + width: parent.width + text: modelData.code === "GLOBAL" ? qsTr("Top 200") : + (modelData.count ? modelData.count + " " + qsTr("stations") : modelData.code) + color: textMuted + font.pixelSize: 10 + elide: Text.ElideRight + } + } + } + } + } + } + + Column { + width: parent.width + spacing: 16 + visible: countrySearchText === "" + + Rectangle { + width: parent.width + height: 1 + color: cardBorder + opacity: 0.4 + } + + Label { + text: qsTr("Popular Music Styles") + font.bold: true + font.pixelSize: 13 + color: accent + } + + Flow { + id: tagFlow + width: parent.width + spacing: 8 + + Repeater { + model: worldTags + delegate: Rectangle { + id: tagCard + width: compactMode ? (tagFlow.width - 8) / 2 : (tagFlow.width - 16) / 3 + height: 54 + radius: 8 + color: tagMouse.containsMouse ? cardHover : card + border.color: tagMouse.containsMouse ? accent : cardBorder + border.width: 1 + scale: tagMouse.containsMouse ? 1.02 : 1.0 + + Behavior on color { ColorAnimation { duration: 100 } } + Behavior on border.color { ColorAnimation { duration: 100 } } + Behavior on scale { NumberAnimation { duration: 100; easing.type: Easing.OutQuad } } + + MouseArea { + id: tagMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + selectedWorldCategory = modelData.name + selectedWorldType = "tag" + backend.loadByTag(modelData.tag) + } + } + + Row { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 12 + + Label { + text: modelData.icon + font.pixelSize: 22 + anchors.verticalCenter: parent.verticalCenter + } + + Label { + width: parent.width - 34 // 22 (icon) + 12 (spacing) + text: modelData.name + color: textMain + font.bold: true + font.pixelSize: 13 + elide: Text.ElideRight + anchors.verticalCenter: parent.verticalCenter + } + } + } + } + } + } + } + } } Column { anchors.centerIn: parent spacing: 8 - visible: stationList.count === 0 + visible: stationList.count === 0 && !(currentPage === "world" && selectedWorldCategory === "") Label { text: currentPage === "history" ? qsTr("No playback history") : qsTr("No stations loaded yet") diff --git a/src/radiobackend.cpp b/src/radiobackend.cpp index 96f0331..9a7b6bf 100644 --- a/src/radiobackend.cpp +++ b/src/radiobackend.cpp @@ -11,6 +11,7 @@ #include #include #include +#include namespace { constexpr int kRecentLimit = 20; @@ -53,6 +54,8 @@ void RadioBackend::setupConnections() { connect(m_radioBrowser, &RadioBrowser::stationsLoaded, this, &RadioBackend::onStationsLoaded); + connect(m_radioBrowser, &RadioBrowser::countriesLoaded, + this, &RadioBackend::onCountriesLoaded); connect(m_radioBrowser, &RadioBrowser::error, this, [this](const QString &error) { qWarning() << "RadioBrowser error:" << error; @@ -128,6 +131,30 @@ void RadioBackend::loadWorldStations() m_radioBrowser->getWorldStations(200); } +void RadioBackend::loadCountries() +{ + setLoading(true); + m_radioBrowser->getCountries(); +} + +void RadioBackend::onCountriesLoaded(const QVariantList &countries) +{ + m_countries.clear(); + for (const QVariant &item : countries) { + QVariantMap map = item.toMap(); + QString code = map["code"].toString(); + QString englishName = map["name"].toString(); + map["name"] = localizeCountry(code, englishName); + m_countries.append(map); + } + + std::sort(m_countries.begin(), m_countries.end(), [](const QVariant &a, const QVariant &b) { + return QString::localeAwareCompare(a.toMap()["name"].toString(), b.toMap()["name"].toString()) < 0; + }); + emit countriesChanged(); + setLoading(false); +} + void RadioBackend::loadByTag(const QString &tag) { if (tag.trimmed().isEmpty()) { @@ -794,3 +821,100 @@ bool RadioBackend::matchesStation(const RadioStation *station, const QString &uu } return false; } + +QString RadioBackend::localizeCountry(const QString &code, const QString &englishName) +{ + QLocale locale; + if (locale.language() != QLocale::German) { + return englishName; + } + + static const QHash germanCountries = { + {"AD", "Andorra"}, {"AE", "Vereinigte Arabische Emirate"}, {"AF", "Afghanistan"}, + {"AG", "Antigua und Barbuda"}, {"AI", "Anguilla"}, {"AL", "Albanien"}, + {"AM", "Armenien"}, {"AO", "Angola"}, {"AQ", "Antarktis"}, {"AR", "Argentinien"}, + {"AS", "Amerikanisch-Samoa"}, {"AT", "Österreich"}, {"AU", "Australien"}, + {"AW", "Aruba"}, {"AX", "Åland-Inseln"}, {"AZ", "Aserbaidschan"}, + {"BA", "Bosnien und Herzegowina"}, {"BB", "Barbados"}, {"BD", "Bangladesch"}, + {"BE", "Belgien"}, {"BF", "Burkina Faso"}, {"BG", "Bulgarien"}, + {"BH", "Bahrain"}, {"BI", "Burundi"}, {"BJ", "Benin"}, + {"BL", "St. Bartholomäus"}, {"BM", "Bermuda"}, {"BN", "Brunei"}, + {"BO", "Bolivien"}, {"BQ", "Bonaire"}, {"BR", "Brasilien"}, + {"BS", "Bahamas"}, {"BT", "Bhutan"}, {"BV", "Bouvetinsel"}, + {"BW", "Botswana"}, {"BY", "Belarus"}, {"BZ", "Belize"}, + {"CA", "Kanada"}, {"CC", "Kokosinseln"}, {"CD", "Kongo-Kinshasa"}, + {"CF", "Zentralafrikanische Republik"}, {"CG", "Kongo-Brazzaville"}, {"CH", "Schweiz"}, + {"CI", "Elfenbeinküste"}, {"CK", "Cookinseln"}, {"CL", "Chile"}, + {"CM", "Kamerun"}, {"CN", "China"}, {"CO", "Kolumbien"}, + {"CR", "Costa Rica"}, {"CU", "Kuba"}, {"CV", "Cabo Verde"}, + {"CW", "Curaçao"}, {"CX", "Weihnachtsinsel"}, {"CY", "Zypern"}, + {"CZ", "Tschechien"}, {"DE", "Deutschland"}, {"DJ", "Dschibuti"}, + {"DK", "Dänemark"}, {"DM", "Dominica"}, {"DO", "Dominikanische Republik"}, + {"DZ", "Algerien"}, {"EC", "Ecuador"}, {"EE", "Estland"}, + {"EG", "Ägypten"}, {"EH", "Westsahara"}, {"ER", "Eritrea"}, + {"ES", "Spanien"}, {"ET", "Äthiopien"}, {"FI", "Finnland"}, + {"FJ", "Fidschi"}, {"FK", "Falklandinseln"}, {"FM", "Mikronesien"}, + {"FO", "Färöer"}, {"FR", "Frankreich"}, {"GA", "Gabun"}, + {"GB", "Großbritannien"}, {"GD", "Grenada"}, {"GE", "Georgien"}, + {"GF", "Französisch-Guayana"}, {"GG", "Guernsey"}, {"GH", "Ghana"}, + {"GI", "Gibraltar"}, {"GL", "Grönland"}, {"GM", "Gambia"}, + {"GN", "Guinea"}, {"GP", "Guadeloupe"}, {"GQ", "Äquatorialguinea"}, + {"GR", "Griechenland"}, {"GS", "Südgeorgien"}, {"GT", "Guatemala"}, + {"GU", "Guam"}, {"GW", "Guinea-Bissau"}, {"GY", "Guyana"}, + {"HK", "Hongkong"}, {"HM", "Heard und McDonaldinseln"}, {"HN", "Honduras"}, + {"HR", "Kroatien"}, {"HT", "Haiti"}, {"HU", "Ungarn"}, + {"ID", "Indonesien"}, {"IE", "Irland"}, {"IL", "Israel"}, + {"IM", "Isle of Man"}, {"IN", "Indien"}, {"IO", "Britisches Territorium im Indischen Ozean"}, + {"IQ", "Irak"}, {"IR", "Iran"}, {"IS", "Island"}, + {"IT", "Italien"}, {"JE", "Jersey"}, {"JM", "Jamaika"}, + {"JO", "Jordanien"}, {"JP", "Japan"}, {"KE", "Kenia"}, + {"KG", "Kirgisistan"}, {"KH", "Kambodscha"}, {"KI", "Kiribati"}, + {"KM", "Komoren"}, {"KN", "St. Kitts und Nevis"}, {"KP", "Nordkorea"}, + {"KR", "Südkorea"}, {"KW", "Kuwait"}, {"KY", "Kaimaninseln"}, + {"KZ", "Kasachstan"}, {"LA", "Laos"}, {"LB", "Libanon"}, + {"LC", "St. Lucia"}, {"LI", "Liechtenstein"}, {"LK", "Sri Lanka"}, + {"LR", "Liberia"}, {"LS", "Lesotho"}, {"LT", "Litauen"}, + {"LU", "Luxemburg"}, {"LV", "Lettland"}, {"LY", "Libyen"}, + {"MA", "Marokko"}, {"MC", "Monaco"}, {"MD", "Moldawien"}, + {"ME", "Montenegro"}, {"MF", "St. Martin"}, {"MG", "Madagaskar"}, + {"MH", "Marshallinseln"}, {"MK", "Nordmazedonien"}, {"ML", "Mali"}, + {"MM", "Myanmar"}, {"MN", "Mongolei"}, {"MO", "Macau"}, + {"MP", "Nördliche Marianen"}, {"MQ", "Martinique"}, {"MR", "Mauretanien"}, + {"MS", "Montserrat"}, {"MT", "Malta"}, {"MU", "Mauritius"}, + {"MV", "Malediven"}, {"MW", "Malawi"}, {"MX", "Mexiko"}, + {"MY", "Malaysia"}, {"MZ", "Mosambik"}, {"NA", "Namibia"}, + {"NC", "Neukaledonien"}, {"NE", "Niger"}, {"NF", "Norfolkinsel"}, + {"NG", "Nigeria"}, {"NI", "Nicaragua"}, {"NL", "Niederlande"}, + {"NO", "Norwegen"}, {"NP", "Nepal"}, {"NR", "Nauru"}, + {"NU", "Niue"}, {"NZ", "Neuseeland"}, {"OM", "Oman"}, + {"PA", "Panama"}, {"PE", "Peru"}, {"PF", "Französisch-Polynesien"}, + {"PG", "Papua-Neuguinea"}, {"PH", "Philippinen"}, {"PK", "Pakistan"}, + {"PL", "Polen"}, {"PM", "St. Pierre und Miquelon"}, {"PN", "Pitcairninseln"}, + {"PR", "Puerto Rico"}, {"PS", "Palästina"}, {"PT", "Portugal"}, + {"PW", "Palau"}, {"PY", "Paraguay"}, {"QA", "Katar"}, + {"RE", "Réunion"}, {"RO", "Rumänien"}, {"RS", "Serbien"}, + {"RU", "Russland"}, {"RW", "Ruanda"}, {"SA", "Saudi-Arabien"}, + {"SB", "Salomonen"}, {"SC", "Seychellen"}, {"SD", "Sudan"}, + {"SE", "Schweden"}, {"SG", "Singapur"}, {"SH", "St. Helena"}, + {"SI", "Slowenien"}, {"SJ", "Svalbard und Jan Mayen"}, {"SK", "Slowakei"}, + {"SL", "Sierra Leone"}, {"SM", "San Marino"}, {"SN", "Senegal"}, + {"SO", "Somalia"}, {"SR", "Suriname"}, {"SS", "Südsudan"}, + {"ST", "São Tomé und Príncipe"}, {"SV", "El Salvador"}, {"SX", "Sint Maarten"}, + {"SY", "Syrien"}, {"SZ", "Eswatini"}, {"TC", "Turks- und Caicosinseln"}, + {"TD", "Tschad"}, {"TF", "Französische Süd- und Antarktisgebiete"}, {"TG", "Togo"}, + {"TH", "Thailand"}, {"TJ", "Tadschikistan"}, {"TK", "Tokelau"}, + {"TL", "Osttimor"}, {"TM", "Turkmenistan"}, {"TN", "Tunesien"}, + {"TO", "Tonga"}, {"TR", "Türkei"}, {"TT", "Trinidad und Tobago"}, + {"TV", "Tuvalu"}, {"TW", "Taiwan"}, {"TZ", "Tansania"}, + {"UA", "Ukraine"}, {"UG", "Uganda"}, {"UM", "Amerikanische Überseeinseln"}, + {"US", "Vereinigte Staaten"}, {"UY", "Uruguay"}, {"UZ", "Usbekistan"}, + {"VA", "Vatikanstadt"}, {"VC", "St. Vincent und die Grenadinen"}, {"VE", "Venezuela"}, + {"VG", "Britische Jungferninseln"}, {"VI", "Amerikanische Jungferninseln"}, {"VN", "Vietnam"}, + {"VU", "Vanuatu"}, {"WF", "Wallis und Futuna"}, {"WS", "Samoa"}, + {"YE", "Jemen"}, {"YT", "Mayotte"}, {"ZA", "Südafrika"}, + {"ZM", "Sambia"}, {"ZW", "Simbabwe"} + }; + + QString result = germanCountries.value(code.toUpper()); + return result.isEmpty() ? englishName : result; +} diff --git a/src/radiobackend.h b/src/radiobackend.h index 12b01f1..1455d01 100644 --- a/src/radiobackend.h +++ b/src/radiobackend.h @@ -31,6 +31,7 @@ class RadioBackend : public QObject Q_PROPERTY(QString currentStationUuid READ currentStationUuid NOTIFY currentStationChanged) Q_PROPERTY(QString currentStationUrl READ currentStationUrl NOTIFY currentStationChanged) Q_PROPERTY(QObject* currentStation READ currentStation NOTIFY currentStationChanged) + Q_PROPERTY(QVariantList countries READ countries NOTIFY countriesChanged) public: explicit RadioBackend(QObject *parent = nullptr); @@ -54,6 +55,7 @@ public: Q_INVOKABLE void loadDutchStations(); Q_INVOKABLE void loadTopStations(); Q_INVOKABLE void loadWorldStations(); + Q_INVOKABLE void loadCountries(); Q_INVOKABLE void loadByTag(const QString &tag); Q_INVOKABLE void loadByCountryCode(const QString &countryCode); Q_INVOKABLE void searchStations(const QString &query); @@ -74,6 +76,8 @@ public: Q_INVOKABLE bool playFavoriteByUuid(const QString &uuid, const QString &urlResolved = QString()); Q_INVOKABLE void resumeLastStation(); + QVariantList countries() const { return m_countries; } + signals: void stationsChanged(); void favoritesChanged(); @@ -84,9 +88,11 @@ signals: void filterQueryChanged(); void currentStationChanged(); void raiseRequested(); + void countriesChanged(); private slots: void onStationsLoaded(const QList &stations); + void onCountriesLoaded(const QVariantList &countries); private: RadioBrowser *m_radioBrowser = nullptr; @@ -104,6 +110,7 @@ private: QVariantList m_recentStations; QString m_currentStationUuid; QString m_currentStationUrl; + QVariantList m_countries; void setupConnections(); void loadFavorites(); @@ -119,6 +126,7 @@ private: void playCurrentSelection(); static QVariantMap toVariantMap(const RadioStation *station); static bool matchesStation(const RadioStation *station, const QString &uuid, const QString &urlResolved); + static QString localizeCountry(const QString &code, const QString &englishName); }; #endif diff --git a/src/radiobrowser.cpp b/src/radiobrowser.cpp index 6b6361e..8e77d37 100644 --- a/src/radiobrowser.cpp +++ b/src/radiobrowser.cpp @@ -77,6 +77,11 @@ void RadioBrowser::getDutchStations() makeRequest(endpoint); } +void RadioBrowser::getCountries() +{ + makeRequest("/countries"); +} + void RadioBrowser::makeRequest(const QString &endpoint) { const QString cacheDir = apiCacheDir(); @@ -87,9 +92,16 @@ void RadioBrowser::makeRequest(const QString &endpoint) QFile cacheFile(cachePath); if (cacheFile.exists() && cacheFile.open(QIODevice::ReadOnly)) { QByteArray cachedData = cacheFile.readAll(); - QList stations = parseJsonResponse(cachedData); - if (!stations.isEmpty()) { - emit stationsLoaded(stations); + if (endpoint == "/countries") { + QVariantList list = parseCountriesJson(cachedData); + if (!list.isEmpty()) { + emit countriesLoaded(list); + } + } else { + QList stations = parseJsonResponse(cachedData); + if (!stations.isEmpty()) { + emit stationsLoaded(stations); + } } cacheFile.close(); } @@ -122,19 +134,61 @@ void RadioBrowser::onReplyFinished() } QByteArray data = reply->readAll(); - QList stations = parseJsonResponse(data); - QString cachePath = reply->property("cachePath").toString(); - if (!cachePath.isEmpty() && !stations.isEmpty()) { - QFile file(cachePath); - if (file.open(QIODevice::WriteOnly)) { - file.write(data); - file.close(); + + if (reply->url().path().endsWith("/countries")) { + QVariantList list = parseCountriesJson(data); + if (!cachePath.isEmpty() && !list.isEmpty()) { + QFile file(cachePath); + if (file.open(QIODevice::WriteOnly)) { + file.write(data); + file.close(); + } + } + emit countriesLoaded(list); + } else { + QList stations = parseJsonResponse(data); + if (!cachePath.isEmpty() && !stations.isEmpty()) { + QFile file(cachePath); + if (file.open(QIODevice::WriteOnly)) { + file.write(data); + file.close(); + } + } + emit stationsLoaded(stations); + } + reply->deleteLater(); +} + +QVariantList RadioBrowser::parseCountriesJson(const QByteArray &jsonData) +{ + QVariantList countries; + QJsonDocument doc = QJsonDocument::fromJson(jsonData); + if (!doc.isArray()) { + return countries; + } + + QJsonArray array = doc.array(); + for (const QJsonValue &value : array) { + if (value.isObject()) { + QJsonObject obj = value.toObject(); + QString name = obj.value("name").toString(); + QString code = obj.value("iso_3166_1").toString(); + int count = obj.value("stationcount").toInt(); + + if (name.isEmpty() || code.isEmpty() || count <= 0) { + continue; + } + + QVariantMap map; + map["name"] = name; + map["code"] = code; + map["count"] = count; + countries.append(map); } } - emit stationsLoaded(stations); - reply->deleteLater(); + return countries; } QList RadioBrowser::parseJsonResponse(const QByteArray &jsonData) diff --git a/src/radiobrowser.h b/src/radiobrowser.h index dfdadfe..520c2f4 100644 --- a/src/radiobrowser.h +++ b/src/radiobrowser.h @@ -8,6 +8,7 @@ #include #include #include +#include #include "radiostation.h" @@ -25,9 +26,11 @@ public: void getWorldStations(int count = 200); void getGermanStations(); void getDutchStations(); + void getCountries(); signals: void stationsLoaded(const QList &stations); + void countriesLoaded(const QVariantList &countries); void error(const QString &message); private slots: @@ -39,6 +42,7 @@ private: void makeRequest(const QString &endpoint); QList parseJsonResponse(const QByteArray &jsonData); + QVariantList parseCountriesJson(const QByteArray &jsonData); }; #endif diff --git a/translations/bearwave_de.ts b/translations/bearwave_de.ts index 4929b4c..771af49 100644 --- a/translations/bearwave_de.ts +++ b/translations/bearwave_de.ts @@ -223,6 +223,50 @@ Play some stations to build history Spiele Sender ab, um einen Verlauf aufzubauen + + ← Back to Categories + ← Zurück zu den Kategorien + + + World > Country: + Welt > Land: + + + World > Genre: + Welt > Genre: + + + Explore World Stations + Weltweite Sender entdecken + + + Filter countries... + Länder filtern... + + + Choose a country or music style to find radio stations from all over the world. + Wähle ein Land oder eine Musikrichtung, um Radiosender aus der ganzen Welt zu finden. + + + Countries + Länder + + + Filtered Countries + Gefilterte Länder + + + Popular Music Styles + Beliebte Musikrichtungen + + + Top Global + Weltweite Top-Sender + + + stations + Sender + RadioBackend