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.
This commit is contained in:
Sebastian Palencsar
2026-06-20 14:10:51 +02:00
parent 1c736b97a6
commit 480bd483e8
10 changed files with 703 additions and 656 deletions

View File

@@ -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) {

View File

@@ -1,6 +1,15 @@
<RCC>
<qresource prefix="/qml">
<file>qml/Main.qml</file>
<file>qml/theme/qmldir</file>
<file>qml/theme/BearTheme.qml</file>
<file>qml/components/qmldir</file>
<file>qml/components/HeaderNavigation.qml</file>
<file>qml/components/SearchToolbar.qml</file>
<file>qml/components/QuickFilters.qml</file>
<file>qml/utils/FlagUtils.js</file>
</qresource>
<qresource prefix="/">
<file alias="Main.qml">qml/Main.qml</file>
<file alias="assets/app/bearwave.png">../assets/app/bearwave.png</file>
<file alias="assets/app/bearwave_line.png">../assets/app/bearwave_line.png</file>
<file alias="assets/ui/globe.svg">../assets/ui/globe.svg</file>

File diff suppressed because it is too large Load Diff

View File

@@ -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()
}
}
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,3 @@
HeaderNavigation 1.0 HeaderNavigation.qml
SearchToolbar 1.0 SearchToolbar.qml
QuickFilters 1.0 QuickFilters.qml

View File

@@ -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: "🎺" }
]
}

1
src/qml/theme/qmldir Normal file
View File

@@ -0,0 +1 @@
singleton BearTheme 1.0 BearTheme.qml

View File

@@ -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)
}