From 480bd483e83430fc69a0a7275de01bfff8d35c14 Mon Sep 17 00:00:00 2001 From: Sebastian Palencsar Date: Sat, 20 Jun 2026 14:10:51 +0200 Subject: [PATCH] refactor(qml): extract header, search, and theme from Main.qml Split the duplicated desktop/compact header UI into reusable QML components (HeaderNavigation, SearchToolbar, QuickFilters), centralize colors and world tags in BearTheme singleton, and move flag emoji logic to FlagUtils.js. Register modules via qrc import path qrc:/qml. Main.qml shrinks from ~1816 to ~1240 lines; behavior unchanged. --- src/main.cpp | 3 +- src/qml.qrc | 13 +- src/qml/Main.qml | 729 +++--------------------- src/qml/components/HeaderNavigation.qml | 205 +++++++ src/qml/components/QuickFilters.qml | 190 ++++++ src/qml/components/SearchToolbar.qml | 166 ++++++ src/qml/components/qmldir | 3 + src/qml/theme/BearTheme.qml | 33 ++ src/qml/theme/qmldir | 1 + src/qml/utils/FlagUtils.js | 16 + 10 files changed, 703 insertions(+), 656 deletions(-) create mode 100644 src/qml/components/HeaderNavigation.qml create mode 100644 src/qml/components/QuickFilters.qml create mode 100644 src/qml/components/SearchToolbar.qml create mode 100644 src/qml/components/qmldir create mode 100644 src/qml/theme/BearTheme.qml create mode 100644 src/qml/theme/qmldir create mode 100644 src/qml/utils/FlagUtils.js diff --git a/src/main.cpp b/src/main.cpp index e8c9708..cfbc488 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -49,6 +49,7 @@ int main(int argc, char *argv[]) } QQmlApplicationEngine engine; + engine.addImportPath(QStringLiteral("qrc:/qml")); RadioBackend backend; engine.rootContext()->setContextProperty("radioBackend", &backend); @@ -70,7 +71,7 @@ int main(int argc, char *argv[]) mprisPlayer->publishState(); } - const QUrl url(QStringLiteral("qrc:/Main.qml")); + const QUrl url(QStringLiteral("qrc:/qml/Main.qml")); QObject::connect(&engine, &QQmlApplicationEngine::objectCreated, &app, [&app, url](QObject *obj, const QUrl &objUrl) { if (!obj && url == objUrl) { diff --git a/src/qml.qrc b/src/qml.qrc index 396f25a..03d5461 100644 --- a/src/qml.qrc +++ b/src/qml.qrc @@ -1,10 +1,19 @@ + + qml/Main.qml + qml/theme/qmldir + qml/theme/BearTheme.qml + qml/components/qmldir + qml/components/HeaderNavigation.qml + qml/components/SearchToolbar.qml + qml/components/QuickFilters.qml + qml/utils/FlagUtils.js + - qml/Main.qml ../assets/app/bearwave.png ../assets/app/bearwave_line.png ../assets/ui/globe.svg ../assets/ui/github.svg ../assets/ui/linkedin.svg - + \ No newline at end of file diff --git a/src/qml/Main.qml b/src/qml/Main.qml index 361d587..9a5f7f1 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -5,6 +5,10 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 +import theme 1.0 +import components 1.0 +import "utils/FlagUtils.js" as FlagUtils + ApplicationWindow { id: root title: qsTr("BearWave") @@ -27,32 +31,7 @@ ApplicationWindow { property string selectedWorldCategory: "" property string selectedWorldType: "" property string countrySearchText: "" - - readonly property color bgA: "#0f141b" - readonly property color bgB: "#131b25" - readonly property color panel: "#182433" - readonly property color card: "#1b2a3d" - readonly property color cardHover: "#223654" - readonly property color cardBorder: "#2d4566" - readonly property color accent: "#2bb0ff" - readonly property color textMain: "#eaf1fb" - 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: "🎺" } - ] + property alias searchField: searchToolbar.searchField function toast(message) { toastLabel.text = message @@ -84,16 +63,6 @@ ApplicationWindow { 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 [] @@ -139,8 +108,8 @@ ApplicationWindow { Rectangle { anchors.fill: parent gradient: Gradient { - GradientStop { position: 0.0; color: bgA } - GradientStop { position: 1.0; color: bgB } + GradientStop { position: 0.0; color: BearTheme.bgA } + GradientStop { position: 1.0; color: BearTheme.bgB } } } @@ -153,8 +122,8 @@ ApplicationWindow { Layout.fillWidth: true Layout.preferredHeight: headerContent.implicitHeight + 20 radius: 12 - color: panel - border.color: cardBorder + color: BearTheme.panel + border.color: BearTheme.cardBorder ColumnLayout { id: headerContent @@ -162,584 +131,38 @@ ApplicationWindow { anchors.margins: 10 spacing: 8 - RowLayout { - visible: !compactMode + HeaderNavigation { Layout.fillWidth: true - spacing: 8 - - Image { - Layout.preferredWidth: 112 - Layout.preferredHeight: 40 - source: "qrc:/assets/app/bearwave_line.png" - fillMode: Image.PreserveAspectFit - smooth: true - mipmap: true - } - - Rectangle { - Layout.preferredWidth: 1 - Layout.fillHeight: true - color: cardBorder - opacity: 0.6 - } - - Button { - text: qsTr("Top") - highlighted: currentPage === "top" - onClicked: { - if (!backend) return - currentPage = "top" - activeQuickFilter = "" - backend.loadTopStations() - } - } - - Button { - text: qsTr("DE") - highlighted: currentPage === "german" - onClicked: { - if (!backend) return - currentPage = "german" - activeQuickFilter = "" - backend.loadGermanStations() - } - } - - Button { - text: qsTr("NL") - highlighted: currentPage === "dutch" - onClicked: { - if (!backend) return - currentPage = "dutch" - activeQuickFilter = "" - backend.loadDutchStations() - } - } - - Button { - text: qsTr("Favorites") - highlighted: currentPage === "favorites" - onClicked: { - currentPage = "favorites" - activeQuickFilter = "" - } - } - - Button { - text: qsTr("History") - highlighted: currentPage === "history" - onClicked: { - currentPage = "history" - activeQuickFilter = "" - } - } - - Item { Layout.fillWidth: true } - - Button { - text: compactMode ? "+" : qsTr("Manual +") - onClicked: addDialog.open() - } - - Button { - text: qsTr("About") - onClicked: aboutDialog.open() - } - - Button { - visible: backend && backend.canResumeLastStation - text: compactMode ? "β†Ί" : qsTr("Resume") - onClicked: { - if (backend) { - backend.resumeLastStation() - } - } - } - } - - ColumnLayout { - visible: compactMode - Layout.fillWidth: true - spacing: 8 - - RowLayout { - Layout.fillWidth: true - spacing: 8 - - Image { - Layout.preferredWidth: 96 - Layout.preferredHeight: 34 - source: "qrc:/assets/app/bearwave_line.png" - fillMode: Image.PreserveAspectFit - smooth: true - mipmap: true - } - - Item { Layout.fillWidth: true } - - Button { - text: "+" - onClicked: addDialog.open() - } - - Button { - text: qsTr("About") - onClicked: aboutDialog.open() - } - - Button { - visible: backend && backend.canResumeLastStation - text: "β†Ί" - onClicked: { - if (backend) { - backend.resumeLastStation() - } - } - } - } - - Flow { - Layout.fillWidth: true - width: parent.width - spacing: 8 - - Button { - text: qsTr("Top") - highlighted: currentPage === "top" - onClicked: { - if (!backend) return - currentPage = "top" - activeQuickFilter = "" - backend.loadTopStations() - } - } - - Button { - text: qsTr("DE") - highlighted: currentPage === "german" - onClicked: { - if (!backend) return - currentPage = "german" - activeQuickFilter = "" - backend.loadGermanStations() - } - } - - Button { - text: qsTr("NL") - highlighted: currentPage === "dutch" - onClicked: { - if (!backend) return - currentPage = "dutch" - activeQuickFilter = "" - backend.loadDutchStations() - } - } - - Button { - text: qsTr("Favorites") - highlighted: currentPage === "favorites" - onClicked: { - currentPage = "favorites" - activeQuickFilter = "" - } - } - - Button { - text: qsTr("History") - highlighted: currentPage === "history" - onClicked: { - currentPage = "history" - activeQuickFilter = "" - } - } - } + app: root + compactMode: root.compactMode } Rectangle { Layout.fillWidth: true Layout.preferredHeight: 1 - color: cardBorder + color: BearTheme.cardBorder opacity: 0.6 } - RowLayout { - visible: !compactMode + SearchToolbar { + id: searchToolbar Layout.fillWidth: true - spacing: 8 - - TextField { - id: searchField - Layout.fillWidth: true - placeholderText: qsTr("Search stations (name, genre, country)") - onTextChanged: { - if (backend) { - backend.filterQuery = text - } - searchTimer.restart() - } - onAccepted: { - if (text.length < 2 || !backend) return - currentPage = "search" - backend.searchStations(text) - } - } - - Button { - text: qsTr("Search") - highlighted: true - onClicked: { - if (searchField.text.length < 2 || !backend) return - currentPage = "search" - backend.searchStations(searchField.text) - } - } - - Button { - text: compactMode ? "A-Z" : qsTr("Sort A-Z") - onClicked: { - if (backend && currentPage !== "favorites") { - backend.sortStations("name") - } - } - } - - Button { - text: compactMode ? "kb" : qsTr("Sort Bitrate") - onClicked: { - if (backend && currentPage !== "favorites") { - backend.sortStations("bitrate") - } - } - } - - Button { - text: compactMode ? "❀" : qsTr("Sort Votes") - onClicked: { - if (backend && currentPage !== "favorites") { - backend.sortStations("votes") - } - } - } + app: root + compactMode: root.compactMode + searchTimer: searchTimer } - ColumnLayout { - visible: compactMode + QuickFilters { Layout.fillWidth: true - spacing: 8 - - TextField { - id: compactSearchField - Layout.fillWidth: true - placeholderText: qsTr("Search stations (name, genre, country)") - text: searchField.text - onTextChanged: { - if (searchField.text !== text) { - searchField.text = text - } - if (backend) { - backend.filterQuery = text - } - searchTimer.restart() - } - onAccepted: { - if (text.length < 2 || !backend) return - currentPage = "search" - backend.searchStations(text) - } - } - - RowLayout { - Layout.fillWidth: true - spacing: 8 - - Item { Layout.fillWidth: true } - - Button { - text: qsTr("Search") - highlighted: true - onClicked: { - if (compactSearchField.text.length < 2 || !backend) return - currentPage = "search" - backend.searchStations(compactSearchField.text) - } - } - } - - Flow { - Layout.fillWidth: true - width: parent.width - spacing: 8 - - Button { - text: "A-Z" - onClicked: { - if (backend && currentPage !== "favorites") { - backend.sortStations("name") - } - } - } - - Button { - text: "kb" - onClicked: { - if (backend && currentPage !== "favorites") { - backend.sortStations("bitrate") - } - } - } - - Button { - text: "❀" - onClicked: { - if (backend && currentPage !== "favorites") { - backend.sortStations("votes") - } - } - } - } - } - - Label { - Layout.fillWidth: true - text: qsTr("Tip: you are not limited to DE/NL. Search worldwide by country, genre, or station name.") - color: textMuted - font.pixelSize: 11 - wrapMode: compactMode ? Text.WordWrap : Text.NoWrap - elide: compactMode ? Text.ElideNone : Text.ElideRight - } - - RowLayout { - visible: !compactMode - Layout.fillWidth: true - spacing: 6 - - Label { - text: qsTr("Genre:") - color: textMuted - font.pixelSize: 11 - rightPadding: 10 - } - - Button { - text: qsTr("Rock") - highlighted: activeQuickFilter === "tag:rock" - onClicked: { - if (!backend) return - currentPage = "genre-rock" - activeQuickFilter = "tag:rock" - backend.loadByTag("rock") - } - } - - Button { - text: qsTr("News") - highlighted: activeQuickFilter === "tag:news" - onClicked: { - if (!backend) return - currentPage = "genre-news" - activeQuickFilter = "tag:news" - backend.loadByTag("news") - } - } - - Button { - text: qsTr("Jazz") - highlighted: activeQuickFilter === "tag:jazz" - onClicked: { - if (!backend) return - currentPage = "genre-jazz" - activeQuickFilter = "tag:jazz" - backend.loadByTag("jazz") - } - } - - Item { Layout.preferredWidth: 20 } - - Label { - text: qsTr("Country:") - color: textMuted - font.pixelSize: 11 - rightPadding: 10 - } - - Button { - text: qsTr("US") - highlighted: activeQuickFilter === "cc:US" - onClicked: { - if (!backend) return - currentPage = "country-us" - activeQuickFilter = "cc:US" - backend.loadByCountryCode("US") - } - } - - Button { - text: qsTr("UK") - highlighted: activeQuickFilter === "cc:GB" - onClicked: { - if (!backend) return - currentPage = "country-gb" - activeQuickFilter = "cc:GB" - backend.loadByCountryCode("GB") - } - } - - Button { - text: qsTr("FR") - highlighted: activeQuickFilter === "cc:FR" - onClicked: { - if (!backend) return - currentPage = "country-fr" - activeQuickFilter = "cc:FR" - backend.loadByCountryCode("FR") - } - } - - Button { - text: qsTr("WORLD") - highlighted: activeQuickFilter === "world" - onClicked: { - if (!backend) return - currentPage = "world" - activeQuickFilter = "world" - selectedWorldCategory = "" - selectedWorldType = "" - if (backend.countries.length === 0) { - backend.loadCountries() - } - } - } - - Item { Layout.fillWidth: true } - } - - ColumnLayout { - visible: compactMode - Layout.fillWidth: true - spacing: 8 - - Flow { - Layout.fillWidth: true - width: parent.width - spacing: 8 - - Label { - text: qsTr("Genre:") - color: textMuted - font.pixelSize: 11 - verticalAlignment: Text.AlignVCenter - leftPadding: 8 - rightPadding: 16 - topPadding: 8 - bottomPadding: 8 - } - - Button { - text: qsTr("Rock") - highlighted: activeQuickFilter === "tag:rock" - onClicked: { - if (!backend) return - currentPage = "genre-rock" - activeQuickFilter = "tag:rock" - backend.loadByTag("rock") - } - } - - Button { - text: qsTr("News") - highlighted: activeQuickFilter === "tag:news" - onClicked: { - if (!backend) return - currentPage = "genre-news" - activeQuickFilter = "tag:news" - backend.loadByTag("news") - } - } - - Button { - text: qsTr("Jazz") - highlighted: activeQuickFilter === "tag:jazz" - onClicked: { - if (!backend) return - currentPage = "genre-jazz" - activeQuickFilter = "tag:jazz" - backend.loadByTag("jazz") - } - } - } - - Flow { - Layout.fillWidth: true - width: parent.width - spacing: 8 - - Label { - text: qsTr("Country:") - color: textMuted - font.pixelSize: 11 - verticalAlignment: Text.AlignVCenter - leftPadding: 8 - rightPadding: 16 - topPadding: 8 - bottomPadding: 8 - } - - Button { - text: qsTr("US") - highlighted: activeQuickFilter === "cc:US" - onClicked: { - if (!backend) return - currentPage = "country-us" - activeQuickFilter = "cc:US" - backend.loadByCountryCode("US") - } - } - - Button { - text: qsTr("UK") - highlighted: activeQuickFilter === "cc:GB" - onClicked: { - if (!backend) return - currentPage = "country-gb" - activeQuickFilter = "cc:GB" - backend.loadByCountryCode("GB") - } - } - - Button { - text: qsTr("FR") - highlighted: activeQuickFilter === "cc:FR" - onClicked: { - if (!backend) return - currentPage = "country-fr" - activeQuickFilter = "cc:FR" - backend.loadByCountryCode("FR") - } - } - - Button { - text: qsTr("WORLD") - highlighted: activeQuickFilter === "world" - onClicked: { - if (!backend) return - currentPage = "world" - activeQuickFilter = "world" - selectedWorldCategory = "" - selectedWorldType = "" - if (backend.countries.length === 0) { - backend.loadCountries() - } - } - } - } + app: root + compactMode: root.compactMode } Label { Layout.fillWidth: true visible: backend && backend.lastError.length > 0 text: backend ? (qsTr("Error: ") + backend.lastError) : "" - color: warn + color: BearTheme.warn elide: Text.ElideRight font.pixelSize: 12 } @@ -750,8 +173,8 @@ ApplicationWindow { Layout.fillWidth: true Layout.fillHeight: true radius: 12 - color: panel - border.color: cardBorder + color: BearTheme.panel + border.color: BearTheme.cardBorder clip: true opacity: contentOpacity @@ -782,7 +205,7 @@ ApplicationWindow { text: selectedWorldType === "country" ? (qsTr("World > Country: ") + selectedWorldCategory) : (qsTr("World > Genre: ") + selectedWorldCategory) - color: textMain + color: BearTheme.textMain font.bold: true font.pixelSize: 13 Layout.fillWidth: true @@ -828,8 +251,8 @@ ApplicationWindow { color: stationCard.isCurrent ? (cardMouse.containsMouse ? "#1d3350" : "#16283e") - : (cardMouse.containsMouse ? cardHover : card) - border.color: stationCard.isCurrent ? accent : cardBorder + : (cardMouse.containsMouse ? BearTheme.cardHover : BearTheme.card) + border.color: stationCard.isCurrent ? BearTheme.accent : BearTheme.cardBorder border.width: stationCard.isCurrent ? 2 : 1 MouseArea { @@ -863,7 +286,7 @@ ApplicationWindow { Layout.preferredHeight: 44 radius: 8 color: "#123154" - border.color: accent + border.color: BearTheme.accent Image { id: stationFavicon @@ -899,7 +322,7 @@ ApplicationWindow { Label { Layout.fillWidth: true text: stationCard.modelData.name - color: stationCard.isCurrent ? accent : textMain + color: stationCard.isCurrent ? BearTheme.accent : BearTheme.textMain font.bold: true font.pixelSize: compactMode ? 13 : 14 elide: Text.ElideRight @@ -915,7 +338,7 @@ ApplicationWindow { id: bar1 width: 2 height: 12 - color: accent + color: BearTheme.accent radius: 1 Behavior on height { NumberAnimation { duration: 120 } @@ -925,7 +348,7 @@ ApplicationWindow { id: bar2 width: 2 height: 12 - color: accent + color: BearTheme.accent radius: 1 Behavior on height { NumberAnimation { duration: 120 } @@ -935,7 +358,7 @@ ApplicationWindow { id: bar3 width: 2 height: 12 - color: accent + color: BearTheme.accent radius: 1 Behavior on height { NumberAnimation { duration: 120 } @@ -968,7 +391,7 @@ ApplicationWindow { 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 + color: BearTheme.textMuted font.pixelSize: 11 elide: Text.ElideRight } @@ -1042,7 +465,7 @@ ApplicationWindow { text: qsTr("Explore World Stations") font.bold: true font.pixelSize: 16 - color: textMain + color: BearTheme.textMain Layout.fillWidth: true } @@ -1059,7 +482,7 @@ ApplicationWindow { Label { text: qsTr("Choose a country or music style to find radio stations from all over the world.") font.pixelSize: 11 - color: textMuted + color: BearTheme.textMuted wrapMode: Text.WordWrap width: parent.width visible: countrySearchText === "" @@ -1068,7 +491,7 @@ ApplicationWindow { Rectangle { width: parent.width height: 1 - color: cardBorder + color: BearTheme.cardBorder opacity: 0.4 visible: countrySearchText === "" } @@ -1077,7 +500,7 @@ ApplicationWindow { text: countrySearchText === "" ? qsTr("Countries") : qsTr("Filtered Countries") font.bold: true font.pixelSize: 13 - color: accent + color: BearTheme.accent } Flow { @@ -1092,8 +515,8 @@ ApplicationWindow { 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 + color: countryMouse.containsMouse ? BearTheme.cardHover : BearTheme.card + border.color: countryMouse.containsMouse ? BearTheme.accent : BearTheme.cardBorder border.width: 1 scale: countryMouse.containsMouse ? 1.02 : 1.0 @@ -1123,7 +546,7 @@ ApplicationWindow { spacing: 12 Label { - text: modelData.code === "GLOBAL" ? "🌎" : getFlagEmoji(modelData.code) + text: modelData.code === "GLOBAL" ? "🌎" : FlagUtils.flagEmoji(modelData.code) font.pixelSize: 26 anchors.verticalCenter: parent.verticalCenter } @@ -1136,7 +559,7 @@ ApplicationWindow { Label { width: parent.width text: modelData.name - color: textMain + color: BearTheme.textMain font.bold: true font.pixelSize: 13 elide: Text.ElideRight @@ -1146,7 +569,7 @@ ApplicationWindow { width: parent.width text: modelData.code === "GLOBAL" ? qsTr("Top 200") : (modelData.count ? modelData.count + " " + qsTr("stations") : modelData.code) - color: textMuted + color: BearTheme.textMuted font.pixelSize: 10 elide: Text.ElideRight } @@ -1164,7 +587,7 @@ ApplicationWindow { Rectangle { width: parent.width height: 1 - color: cardBorder + color: BearTheme.cardBorder opacity: 0.4 } @@ -1172,7 +595,7 @@ ApplicationWindow { text: qsTr("Popular Music Styles") font.bold: true font.pixelSize: 13 - color: accent + color: BearTheme.accent } Flow { @@ -1181,14 +604,14 @@ ApplicationWindow { spacing: 8 Repeater { - model: worldTags + model: BearTheme.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 + color: tagMouse.containsMouse ? BearTheme.cardHover : BearTheme.card + border.color: tagMouse.containsMouse ? BearTheme.accent : BearTheme.cardBorder border.width: 1 scale: tagMouse.containsMouse ? 1.02 : 1.0 @@ -1222,7 +645,7 @@ ApplicationWindow { Label { width: parent.width - 34 // 22 (icon) + 12 (spacing) text: modelData.name - color: textMain + color: BearTheme.textMain font.bold: true font.pixelSize: 13 elide: Text.ElideRight @@ -1244,13 +667,13 @@ ApplicationWindow { Label { text: currentPage === "history" ? qsTr("No playback history") : qsTr("No stations loaded yet") - color: textMain + color: BearTheme.textMain font.bold: true } Label { text: currentPage === "history" ? qsTr("Play some stations to build history") : qsTr("Load DE/NL stations or use search") - color: textMuted + color: BearTheme.textMuted } } } @@ -1259,8 +682,8 @@ ApplicationWindow { Layout.fillWidth: true Layout.preferredHeight: 108 radius: 12 - color: panel - border.color: cardBorder + color: BearTheme.panel + border.color: BearTheme.cardBorder RowLayout { anchors.fill: parent @@ -1273,7 +696,7 @@ ApplicationWindow { radius: 8 color: "#182637" clip: true - border.color: cardBorder + border.color: BearTheme.cardBorder Image { id: coverImage @@ -1314,7 +737,7 @@ ApplicationWindow { Label { Layout.fillWidth: true text: backend && backend.player ? (backend.player.currentStationName || qsTr("No station selected")) : qsTr("No station selected") - color: textMain + color: BearTheme.textMain font.pixelSize: 16 font.bold: true elide: Text.ElideRight @@ -1327,14 +750,14 @@ ApplicationWindow { width: codecLabel.implicitWidth + 12 radius: 4 color: "transparent" - border.color: accent + border.color: BearTheme.accent border.width: 1 Label { id: codecLabel anchors.centerIn: parent text: (backend && backend.currentStation && backend.currentStation.codec) ? backend.currentStation.codec.toUpperCase() : "" - color: accent + color: BearTheme.accent font.pixelSize: 9 font.bold: true } @@ -1346,8 +769,8 @@ ApplicationWindow { height: 18 width: bitrateLabel.implicitWidth + 12 radius: 4 - color: accent - border.color: accent + color: BearTheme.accent + border.color: BearTheme.accent border.width: 1 Label { @@ -1366,7 +789,7 @@ ApplicationWindow { text: backend && backend.player && backend.player.currentNowPlaying.length > 0 ? (qsTr("Now playing: ") + backend.player.currentNowPlaying) : qsTr("Now playing: No track info") - color: textMuted + color: BearTheme.textMuted font.pixelSize: 12 elide: Text.ElideRight } @@ -1466,8 +889,8 @@ ApplicationWindow { width: 210 height: 110 radius: 12 - color: panel - border.color: cardBorder + color: BearTheme.panel + border.color: BearTheme.cardBorder Column { anchors.centerIn: parent @@ -1482,7 +905,7 @@ ApplicationWindow { Label { text: qsTr("Loading stations...") - color: textMain + color: BearTheme.textMain anchors.horizontalCenter: parent.horizontalCenter } } @@ -1596,7 +1019,7 @@ ApplicationWindow { Label { Layout.alignment: Qt.AlignHCenter text: qsTr("BearWave") - color: textMain + color: BearTheme.textMain font.bold: true font.pixelSize: 26 } @@ -1604,21 +1027,21 @@ ApplicationWindow { Label { Layout.alignment: Qt.AlignHCenter text: qsTr("Internet Radio Player for KDE") - color: textMuted + color: BearTheme.textMuted font.pixelSize: 14 } Label { Layout.alignment: Qt.AlignHCenter text: qsTr("Version: %1").arg(Qt.application.version) - color: textMuted + color: BearTheme.textMuted font.pixelSize: 12 } Label { Layout.alignment: Qt.AlignHCenter text: qsTr("Public beta") - color: accent + color: BearTheme.accent font.pixelSize: 12 font.bold: true } @@ -1634,7 +1057,7 @@ ApplicationWindow { Label { Layout.alignment: Qt.AlignHCenter text: qsTr("Author: Sebastian PalencsΓ‘r") - color: textMain + color: BearTheme.textMain font.bold: true font.pixelSize: 14 } @@ -1662,7 +1085,7 @@ ApplicationWindow { background: Rectangle { radius: 20 color: parent.hovered ? "#274261" : "#1f3147" - border.color: cardBorder + border.color: BearTheme.cardBorder border.width: 1 } } @@ -1686,7 +1109,7 @@ ApplicationWindow { background: Rectangle { radius: 20 color: parent.hovered ? "#274261" : "#1f3147" - border.color: cardBorder + border.color: BearTheme.cardBorder border.width: 1 } } @@ -1710,7 +1133,7 @@ ApplicationWindow { background: Rectangle { radius: 20 color: parent.hovered ? "#274261" : "#1f3147" - border.color: cardBorder + border.color: BearTheme.cardBorder border.width: 1 } } @@ -1728,12 +1151,12 @@ ApplicationWindow { Label { Layout.fillWidth: true text: qsTr("GNU GPLv3 License") - color: textMain + color: BearTheme.textMain font.bold: true } Label { text: qsTr("Copyright (c) 2026") - color: textMuted + color: BearTheme.textMuted font.pixelSize: 11 } } @@ -1744,7 +1167,7 @@ ApplicationWindow { Layout.minimumHeight: 140 radius: 8 color: "#101a26" - border.color: cardBorder + border.color: BearTheme.cardBorder ScrollView { id: licenseScroll @@ -1755,7 +1178,7 @@ ApplicationWindow { Label { width: licenseScroll.availableWidth - color: textMain + color: BearTheme.textMain font.pixelSize: 11 wrapMode: Text.WordWrap text: "This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.\n\nThis program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.\n\nYou should have received a copy of the GNU General Public License along with this program. If not, see ." @@ -1775,12 +1198,12 @@ ApplicationWindow { background: Rectangle { radius: 10 color: "#24364e" - border.color: accent + border.color: BearTheme.accent } contentItem: Label { id: toastLabel - color: textMain + color: BearTheme.textMain font.pixelSize: 12 } diff --git a/src/qml/components/HeaderNavigation.qml b/src/qml/components/HeaderNavigation.qml new file mode 100644 index 0000000..6d2dceb --- /dev/null +++ b/src/qml/components/HeaderNavigation.qml @@ -0,0 +1,205 @@ +// Copyright (c) 2026 Sebastian Palencsar +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import theme 1.0 + +Item { + id: root + + required property var app + required property bool compactMode + + implicitWidth: compactMode ? compactNav.implicitWidth : desktopNav.implicitWidth + implicitHeight: compactMode ? compactNav.implicitHeight : desktopNav.implicitHeight + + function loadTop() { + if (!app.backend) return + app.currentPage = "top" + app.activeQuickFilter = "" + app.backend.loadTopStations() + } + + function loadGerman() { + if (!app.backend) return + app.currentPage = "german" + app.activeQuickFilter = "" + app.backend.loadGermanStations() + } + + function loadDutch() { + if (!app.backend) return + app.currentPage = "dutch" + app.activeQuickFilter = "" + app.backend.loadDutchStations() + } + + function showFavorites() { + app.currentPage = "favorites" + app.activeQuickFilter = "" + } + + function showHistory() { + app.currentPage = "history" + app.activeQuickFilter = "" + } + + RowLayout { + id: desktopNav + anchors.left: parent.left + anchors.right: parent.right + visible: !root.compactMode + spacing: 8 + + Image { + Layout.preferredWidth: 112 + Layout.preferredHeight: 40 + source: "qrc:/assets/app/bearwave_line.png" + fillMode: Image.PreserveAspectFit + smooth: true + mipmap: true + } + + Rectangle { + Layout.preferredWidth: 1 + Layout.fillHeight: true + color: BearTheme.cardBorder + opacity: 0.6 + } + + Button { + text: qsTr("Top") + highlighted: app.currentPage === "top" + onClicked: loadTop() + } + + Button { + text: qsTr("DE") + highlighted: app.currentPage === "german" + onClicked: loadGerman() + } + + Button { + text: qsTr("NL") + highlighted: app.currentPage === "dutch" + onClicked: loadDutch() + } + + Button { + text: qsTr("Favorites") + highlighted: app.currentPage === "favorites" + onClicked: showFavorites() + } + + Button { + text: qsTr("History") + highlighted: app.currentPage === "history" + onClicked: showHistory() + } + + Item { Layout.fillWidth: true } + + Button { + text: qsTr("Manual +") + onClicked: app.addDialog.open() + } + + Button { + text: qsTr("About") + onClicked: app.aboutDialog.open() + } + + Button { + visible: app.backend && app.backend.canResumeLastStation + text: qsTr("Resume") + onClicked: { + if (app.backend) { + app.backend.resumeLastStation() + } + } + } + } + + ColumnLayout { + id: compactNav + anchors.left: parent.left + anchors.right: parent.right + visible: root.compactMode + spacing: 8 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Image { + Layout.preferredWidth: 96 + Layout.preferredHeight: 34 + source: "qrc:/assets/app/bearwave_line.png" + fillMode: Image.PreserveAspectFit + smooth: true + mipmap: true + } + + Item { Layout.fillWidth: true } + + Button { + text: "+" + onClicked: app.addDialog.open() + } + + Button { + text: qsTr("About") + onClicked: app.aboutDialog.open() + } + + Button { + visible: app.backend && app.backend.canResumeLastStation + text: "β†Ί" + onClicked: { + if (app.backend) { + app.backend.resumeLastStation() + } + } + } + } + + Flow { + Layout.fillWidth: true + width: parent.width + spacing: 8 + + Button { + text: qsTr("Top") + highlighted: app.currentPage === "top" + onClicked: loadTop() + } + + Button { + text: qsTr("DE") + highlighted: app.currentPage === "german" + onClicked: loadGerman() + } + + Button { + text: qsTr("NL") + highlighted: app.currentPage === "dutch" + onClicked: loadDutch() + } + + Button { + text: qsTr("Favorites") + highlighted: app.currentPage === "favorites" + onClicked: showFavorites() + } + + Button { + text: qsTr("History") + highlighted: app.currentPage === "history" + onClicked: showHistory() + } + } + } +} \ No newline at end of file diff --git a/src/qml/components/QuickFilters.qml b/src/qml/components/QuickFilters.qml new file mode 100644 index 0000000..8e58392 --- /dev/null +++ b/src/qml/components/QuickFilters.qml @@ -0,0 +1,190 @@ +// Copyright (c) 2026 Sebastian Palencsar +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import theme 1.0 + +ColumnLayout { + id: root + + required property var app + required property bool compactMode + + spacing: 8 + + function loadTag(tag, page) { + if (!app.backend) return + app.currentPage = page + app.activeQuickFilter = "tag:" + tag + app.backend.loadByTag(tag) + } + + function loadCountry(code, page) { + if (!app.backend) return + app.currentPage = page + app.activeQuickFilter = "cc:" + code + app.backend.loadByCountryCode(code) + } + + function openWorld() { + if (!app.backend) return + app.currentPage = "world" + app.activeQuickFilter = "world" + app.selectedWorldCategory = "" + app.selectedWorldType = "" + if (app.backend.countries.length === 0) { + app.backend.loadCountries() + } + } + + RowLayout { + visible: !root.compactMode + Layout.fillWidth: true + spacing: 6 + + Label { + text: qsTr("Genre:") + color: BearTheme.textMuted + font.pixelSize: 11 + rightPadding: 10 + } + + Button { + text: qsTr("Rock") + highlighted: app.activeQuickFilter === "tag:rock" + onClicked: loadTag("rock", "genre-rock") + } + + Button { + text: qsTr("News") + highlighted: app.activeQuickFilter === "tag:news" + onClicked: loadTag("news", "genre-news") + } + + Button { + text: qsTr("Jazz") + highlighted: app.activeQuickFilter === "tag:jazz" + onClicked: loadTag("jazz", "genre-jazz") + } + + Item { Layout.preferredWidth: 20 } + + Label { + text: qsTr("Country:") + color: BearTheme.textMuted + font.pixelSize: 11 + rightPadding: 10 + } + + Button { + text: qsTr("US") + highlighted: app.activeQuickFilter === "cc:US" + onClicked: loadCountry("US", "country-us") + } + + Button { + text: qsTr("UK") + highlighted: app.activeQuickFilter === "cc:GB" + onClicked: loadCountry("GB", "country-gb") + } + + Button { + text: qsTr("FR") + highlighted: app.activeQuickFilter === "cc:FR" + onClicked: loadCountry("FR", "country-fr") + } + + Button { + text: qsTr("WORLD") + highlighted: app.activeQuickFilter === "world" + onClicked: openWorld() + } + + Item { Layout.fillWidth: true } + } + + ColumnLayout { + visible: root.compactMode + Layout.fillWidth: true + spacing: 8 + + Flow { + Layout.fillWidth: true + width: parent.width + spacing: 8 + + Label { + text: qsTr("Genre:") + color: BearTheme.textMuted + font.pixelSize: 11 + verticalAlignment: Text.AlignVCenter + leftPadding: 8 + rightPadding: 16 + topPadding: 8 + bottomPadding: 8 + } + + Button { + text: qsTr("Rock") + highlighted: app.activeQuickFilter === "tag:rock" + onClicked: loadTag("rock", "genre-rock") + } + + Button { + text: qsTr("News") + highlighted: app.activeQuickFilter === "tag:news" + onClicked: loadTag("news", "genre-news") + } + + Button { + text: qsTr("Jazz") + highlighted: app.activeQuickFilter === "tag:jazz" + onClicked: loadTag("jazz", "genre-jazz") + } + } + + Flow { + Layout.fillWidth: true + width: parent.width + spacing: 8 + + Label { + text: qsTr("Country:") + color: BearTheme.textMuted + font.pixelSize: 11 + verticalAlignment: Text.AlignVCenter + leftPadding: 8 + rightPadding: 16 + topPadding: 8 + bottomPadding: 8 + } + + Button { + text: qsTr("US") + highlighted: app.activeQuickFilter === "cc:US" + onClicked: loadCountry("US", "country-us") + } + + Button { + text: qsTr("UK") + highlighted: app.activeQuickFilter === "cc:GB" + onClicked: loadCountry("GB", "country-gb") + } + + Button { + text: qsTr("FR") + highlighted: app.activeQuickFilter === "cc:FR" + onClicked: loadCountry("FR", "country-fr") + } + + Button { + text: qsTr("WORLD") + highlighted: app.activeQuickFilter === "world" + onClicked: openWorld() + } + } + } +} \ No newline at end of file diff --git a/src/qml/components/SearchToolbar.qml b/src/qml/components/SearchToolbar.qml new file mode 100644 index 0000000..c55f1ab --- /dev/null +++ b/src/qml/components/SearchToolbar.qml @@ -0,0 +1,166 @@ +// Copyright (c) 2026 Sebastian Palencsar +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import theme 1.0 + +ColumnLayout { + id: root + + required property var app + required property var searchTimer + required property bool compactMode + + property alias searchField: searchField + + spacing: 8 + + RowLayout { + visible: !root.compactMode + Layout.fillWidth: true + spacing: 8 + + TextField { + id: searchField + Layout.fillWidth: true + placeholderText: qsTr("Search stations (name, genre, country)") + onTextChanged: { + if (app.backend) { + app.backend.filterQuery = text + } + searchTimer.restart() + } + onAccepted: { + if (text.length < 2 || !app.backend) return + app.currentPage = "search" + app.backend.searchStations(text) + } + } + + Button { + text: qsTr("Search") + highlighted: true + onClicked: { + if (searchField.text.length < 2 || !app.backend) return + app.currentPage = "search" + app.backend.searchStations(searchField.text) + } + } + + Button { + text: qsTr("Sort A-Z") + onClicked: { + if (app.backend && app.currentPage !== "favorites") { + app.backend.sortStations("name") + } + } + } + + Button { + text: qsTr("Sort Bitrate") + onClicked: { + if (app.backend && app.currentPage !== "favorites") { + app.backend.sortStations("bitrate") + } + } + } + + Button { + text: qsTr("Sort Votes") + onClicked: { + if (app.backend && app.currentPage !== "favorites") { + app.backend.sortStations("votes") + } + } + } + } + + ColumnLayout { + visible: root.compactMode + Layout.fillWidth: true + spacing: 8 + + TextField { + id: compactSearchField + Layout.fillWidth: true + placeholderText: qsTr("Search stations (name, genre, country)") + text: searchField.text + onTextChanged: { + if (searchField.text !== text) { + searchField.text = text + } + if (app.backend) { + app.backend.filterQuery = text + } + searchTimer.restart() + } + onAccepted: { + if (text.length < 2 || !app.backend) return + app.currentPage = "search" + app.backend.searchStations(text) + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Item { Layout.fillWidth: true } + + Button { + text: qsTr("Search") + highlighted: true + onClicked: { + if (compactSearchField.text.length < 2 || !app.backend) return + app.currentPage = "search" + app.backend.searchStations(compactSearchField.text) + } + } + } + + Flow { + Layout.fillWidth: true + width: parent.width + spacing: 8 + + Button { + text: "A-Z" + onClicked: { + if (app.backend && app.currentPage !== "favorites") { + app.backend.sortStations("name") + } + } + } + + Button { + text: "kb" + onClicked: { + if (app.backend && app.currentPage !== "favorites") { + app.backend.sortStations("bitrate") + } + } + } + + Button { + text: "❀" + onClicked: { + if (app.backend && app.currentPage !== "favorites") { + app.backend.sortStations("votes") + } + } + } + } + } + + Label { + Layout.fillWidth: true + text: qsTr("Tip: you are not limited to DE/NL. Search worldwide by country, genre, or station name.") + color: BearTheme.textMuted + font.pixelSize: 11 + wrapMode: root.compactMode ? Text.WordWrap : Text.NoWrap + elide: root.compactMode ? Text.ElideNone : Text.ElideRight + } +} \ No newline at end of file diff --git a/src/qml/components/qmldir b/src/qml/components/qmldir new file mode 100644 index 0000000..905d1c1 --- /dev/null +++ b/src/qml/components/qmldir @@ -0,0 +1,3 @@ +HeaderNavigation 1.0 HeaderNavigation.qml +SearchToolbar 1.0 SearchToolbar.qml +QuickFilters 1.0 QuickFilters.qml \ No newline at end of file diff --git a/src/qml/theme/BearTheme.qml b/src/qml/theme/BearTheme.qml new file mode 100644 index 0000000..2fe0fb4 --- /dev/null +++ b/src/qml/theme/BearTheme.qml @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Sebastian Palencsar +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma Singleton +import QtQuick 2.15 + +QtObject { + readonly property color bgA: "#0f141b" + readonly property color bgB: "#131b25" + readonly property color panel: "#182433" + readonly property color card: "#1b2a3d" + readonly property color cardHover: "#223654" + readonly property color cardBorder: "#2d4566" + readonly property color accent: "#2bb0ff" + readonly property color textMain: "#eaf1fb" + 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: "🎺" } + ] +} \ No newline at end of file diff --git a/src/qml/theme/qmldir b/src/qml/theme/qmldir new file mode 100644 index 0000000..6d86625 --- /dev/null +++ b/src/qml/theme/qmldir @@ -0,0 +1 @@ +singleton BearTheme 1.0 BearTheme.qml \ No newline at end of file diff --git a/src/qml/utils/FlagUtils.js b/src/qml/utils/FlagUtils.js new file mode 100644 index 0000000..f5360fa --- /dev/null +++ b/src/qml/utils/FlagUtils.js @@ -0,0 +1,16 @@ +// Copyright (c) 2026 Sebastian Palencsar +// SPDX-License-Identifier: GPL-3.0-or-later + +.pragma library + +function flagEmoji(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) +} \ No newline at end of file