diff --git a/src/qml.qrc b/src/qml.qrc
index 03d5461..bdcaac2 100644
--- a/src/qml.qrc
+++ b/src/qml.qrc
@@ -7,6 +7,11 @@
qml/components/HeaderNavigation.qml
qml/components/SearchToolbar.qml
qml/components/QuickFilters.qml
+ qml/components/StationCard.qml
+ qml/components/WorldCategoryHeader.qml
+ qml/components/WorldCategories.qml
+ qml/components/PlayerBar.qml
+ qml/components/AboutDialog.qml
qml/utils/FlagUtils.js
diff --git a/src/qml/Main.qml b/src/qml/Main.qml
index 9a5f7f1..0ccddbf 100644
--- a/src/qml/Main.qml
+++ b/src/qml/Main.qml
@@ -7,7 +7,6 @@ import QtQuick.Layouts 1.15
import theme 1.0
import components 1.0
-import "utils/FlagUtils.js" as FlagUtils
ApplicationWindow {
id: root
@@ -187,30 +186,10 @@ ApplicationWindow {
anchors.margins: 10
spacing: 10
- RowLayout {
- id: worldHeader
+ WorldCategoryHeader {
Layout.fillWidth: true
visible: currentPage === "world" && selectedWorldCategory !== ""
- spacing: 12
-
- Button {
- text: qsTr("← Back to Categories")
- onClicked: {
- selectedWorldCategory = ""
- selectedWorldType = ""
- }
- }
-
- Label {
- text: selectedWorldType === "country"
- ? (qsTr("World > Country: ") + selectedWorldCategory)
- : (qsTr("World > Genre: ") + selectedWorldCategory)
- color: BearTheme.textMain
- font.bold: true
- font.pixelSize: 13
- Layout.fillWidth: true
- elide: Text.ElideRight
- }
+ app: root
}
ScrollView {
@@ -228,435 +207,20 @@ ApplicationWindow {
model: activeModel()
visible: count > 0
- 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;
- }
- readonly property bool isPlaying: isCurrent && backend && backend.player && backend.player.playing
-
- color: stationCard.isCurrent
- ? (cardMouse.containsMouse ? "#1d3350" : "#16283e")
- : (cardMouse.containsMouse ? BearTheme.cardHover : BearTheme.card)
- border.color: stationCard.isCurrent ? BearTheme.accent : BearTheme.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: BearTheme.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 ? BearTheme.accent : BearTheme.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: BearTheme.accent
- radius: 1
- Behavior on height {
- NumberAnimation { duration: 120 }
- }
- }
- Rectangle {
- id: bar2
- width: 2
- height: 12
- color: BearTheme.accent
- radius: 1
- Behavior on height {
- NumberAnimation { duration: 120 }
- }
- }
- Rectangle {
- id: bar3
- width: 2
- height: 12
- color: BearTheme.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: BearTheme.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)
- }
- }
- }
- }
- }
+ delegate: StationCard {
+ app: root
+ compactMode: root.compactMode
+ listWidth: stationScrollView.availableWidth
}
}
}
- ScrollView {
- id: categoriesScrollView
+ WorldCategories {
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: BearTheme.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: BearTheme.textMuted
- wrapMode: Text.WordWrap
- width: parent.width
- visible: countrySearchText === ""
- }
-
- Rectangle {
- width: parent.width
- height: 1
- color: BearTheme.cardBorder
- opacity: 0.4
- visible: countrySearchText === ""
- }
-
- Label {
- text: countrySearchText === "" ? qsTr("Countries") : qsTr("Filtered Countries")
- font.bold: true
- font.pixelSize: 13
- color: BearTheme.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 ? BearTheme.cardHover : BearTheme.card
- border.color: countryMouse.containsMouse ? BearTheme.accent : BearTheme.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" ? "🌎" : FlagUtils.flagEmoji(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: BearTheme.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: BearTheme.textMuted
- font.pixelSize: 10
- elide: Text.ElideRight
- }
- }
- }
- }
- }
- }
-
- Column {
- width: parent.width
- spacing: 16
- visible: countrySearchText === ""
-
- Rectangle {
- width: parent.width
- height: 1
- color: BearTheme.cardBorder
- opacity: 0.4
- }
-
- Label {
- text: qsTr("Popular Music Styles")
- font.bold: true
- font.pixelSize: 13
- color: BearTheme.accent
- }
-
- Flow {
- id: tagFlow
- width: parent.width
- spacing: 8
-
- Repeater {
- model: BearTheme.worldTags
- delegate: Rectangle {
- id: tagCard
- width: compactMode ? (tagFlow.width - 8) / 2 : (tagFlow.width - 16) / 3
- height: 54
- radius: 8
- 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
-
- 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: BearTheme.textMain
- font.bold: true
- font.pixelSize: 13
- elide: Text.ElideRight
- anchors.verticalCenter: parent.verticalCenter
- }
- }
- }
- }
- }
- }
- }
+ app: root
+ compactMode: root.compactMode
}
}
@@ -678,203 +242,9 @@ ApplicationWindow {
}
}
- Rectangle {
+ PlayerBar {
Layout.fillWidth: true
- Layout.preferredHeight: 108
- radius: 12
- color: BearTheme.panel
- border.color: BearTheme.cardBorder
-
- RowLayout {
- anchors.fill: parent
- anchors.margins: 10
- spacing: 12
-
- Rectangle {
- Layout.preferredWidth: 88
- Layout.preferredHeight: 88
- radius: 8
- color: "#182637"
- clip: true
- border.color: BearTheme.cardBorder
-
- Image {
- id: coverImage
- anchors.fill: parent
- 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: {
- 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;
- }
- }
- }
-
- ColumnLayout {
- Layout.fillWidth: true
- Layout.fillHeight: true
- spacing: 6
-
- RowLayout {
- Layout.fillWidth: true
- spacing: 8
-
- Label {
- Layout.fillWidth: true
- text: backend && backend.player ? (backend.player.currentStationName || qsTr("No station selected")) : qsTr("No station selected")
- color: BearTheme.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: BearTheme.accent
- border.width: 1
-
- Label {
- id: codecLabel
- anchors.centerIn: parent
- text: (backend && backend.currentStation && backend.currentStation.codec) ? backend.currentStation.codec.toUpperCase() : ""
- color: BearTheme.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: BearTheme.accent
- border.color: BearTheme.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 {
- Layout.fillWidth: true
- text: backend && backend.player && backend.player.currentNowPlaying.length > 0
- ? (qsTr("Now playing: ") + backend.player.currentNowPlaying)
- : qsTr("Now playing: No track info")
- color: BearTheme.textMuted
- font.pixelSize: 12
- elide: Text.ElideRight
- }
-
- RowLayout {
- Layout.fillWidth: true
- spacing: 8
-
- Button {
- text: "⏮"
- Layout.preferredWidth: 44
- enabled: backend ? backend.hasPreviousStation() : false
- onClicked: {
- if (backend) {
- backend.playPreviousStation()
- }
- }
- }
-
- Button {
- text: backend && backend.player && backend.player.playing ? "⏸" : "▶"
- Layout.preferredWidth: 44
- onClicked: {
- if (backend && backend.player) {
- backend.player.togglePlayPause()
- }
- }
- }
-
- Button {
- text: "⏹"
- Layout.preferredWidth: 44
- onClicked: {
- if (backend && backend.player) {
- backend.player.stop()
- }
- }
- }
-
- Button {
- text: "⏭"
- Layout.preferredWidth: 44
- enabled: backend ? backend.hasNextStation() : false
- onClicked: {
- if (backend) {
- backend.playNextStation()
- }
- }
- }
-
- 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 {
- Layout.fillWidth: true
- from: 0
- to: 1
- value: backend && backend.player ? backend.player.volume : 0.5
- onMoved: {
- if (backend && backend.player) {
- backend.player.setVolume(value)
- }
- }
- }
- }
- }
- }
+ app: root
}
}
@@ -989,204 +359,9 @@ ApplicationWindow {
}
}
- Dialog {
+ AboutDialog {
id: aboutDialog
- modal: true
- anchors.centerIn: Overlay.overlay
- width: compactMode ? 360 : 520
- height: compactMode ? 560 : 680
- standardButtons: Dialog.Ok
-
- contentItem: ColumnLayout {
- spacing: 16
-
- // --- HEADER ---
- ColumnLayout {
- Layout.alignment: Qt.AlignHCenter
- spacing: 6
-
- Image {
- Layout.alignment: Qt.AlignHCenter
- Layout.preferredWidth: 72
- Layout.preferredHeight: 72
- sourceSize.width: 72
- sourceSize.height: 72
- source: "qrc:/assets/app/bearwave.png"
- fillMode: Image.PreserveAspectFit
- smooth: true
- }
-
- Label {
- Layout.alignment: Qt.AlignHCenter
- text: qsTr("BearWave")
- color: BearTheme.textMain
- font.bold: true
- font.pixelSize: 26
- }
-
- Label {
- Layout.alignment: Qt.AlignHCenter
- text: qsTr("Internet Radio Player for KDE")
- color: BearTheme.textMuted
- font.pixelSize: 14
- }
-
- Label {
- Layout.alignment: Qt.AlignHCenter
- text: qsTr("Version: %1").arg(Qt.application.version)
- color: BearTheme.textMuted
- font.pixelSize: 12
- }
-
- Label {
- Layout.alignment: Qt.AlignHCenter
- text: qsTr("Public beta")
- color: BearTheme.accent
- font.pixelSize: 12
- font.bold: true
- }
- }
-
- // --- CREDITS & LINKS ---
- ColumnLayout {
- Layout.alignment: Qt.AlignHCenter
- spacing: 8
- Layout.topMargin: 8
- Layout.bottomMargin: 8
-
- Label {
- Layout.alignment: Qt.AlignHCenter
- text: qsTr("Author: Sebastian Palencsár")
- color: BearTheme.textMain
- font.bold: true
- font.pixelSize: 14
- }
-
- RowLayout {
- Layout.alignment: Qt.AlignHCenter
- spacing: 16
-
- ToolButton {
- Layout.preferredWidth: 40
- Layout.preferredHeight: 40
- onClicked: Qt.openUrlExternally("https://palencsar.pro")
- ToolTip.visible: hovered
- ToolTip.text: qsTr("Open website")
- contentItem: Item {
- Image {
- anchors.centerIn: parent
- width: 19
- height: 19
- source: "qrc:/assets/ui/globe.svg"
- fillMode: Image.PreserveAspectFit
- smooth: true
- }
- }
- background: Rectangle {
- radius: 20
- color: parent.hovered ? "#274261" : "#1f3147"
- border.color: BearTheme.cardBorder
- border.width: 1
- }
- }
-
- ToolButton {
- Layout.preferredWidth: 40
- Layout.preferredHeight: 40
- onClicked: Qt.openUrlExternally("https://github.com/spalencsar")
- ToolTip.visible: hovered
- ToolTip.text: qsTr("Open GitHub")
- contentItem: Item {
- Image {
- anchors.centerIn: parent
- width: 19
- height: 19
- source: "qrc:/assets/ui/github.svg"
- fillMode: Image.PreserveAspectFit
- smooth: true
- }
- }
- background: Rectangle {
- radius: 20
- color: parent.hovered ? "#274261" : "#1f3147"
- border.color: BearTheme.cardBorder
- border.width: 1
- }
- }
-
- ToolButton {
- Layout.preferredWidth: 40
- Layout.preferredHeight: 40
- onClicked: Qt.openUrlExternally("https://www.linkedin.com/in/spalencsar/")
- ToolTip.visible: hovered
- ToolTip.text: qsTr("Open LinkedIn")
- contentItem: Item {
- Image {
- anchors.centerIn: parent
- width: 19
- height: 19
- source: "qrc:/assets/ui/linkedin.svg"
- fillMode: Image.PreserveAspectFit
- smooth: true
- }
- }
- background: Rectangle {
- radius: 20
- color: parent.hovered ? "#274261" : "#1f3147"
- border.color: BearTheme.cardBorder
- border.width: 1
- }
- }
- }
- }
-
- // --- LICENSE ---
- ColumnLayout {
- Layout.fillWidth: true
- Layout.fillHeight: true
- spacing: 8
-
- RowLayout {
- Layout.fillWidth: true
- Label {
- Layout.fillWidth: true
- text: qsTr("GNU GPLv3 License")
- color: BearTheme.textMain
- font.bold: true
- }
- Label {
- text: qsTr("Copyright (c) 2026")
- color: BearTheme.textMuted
- font.pixelSize: 11
- }
- }
-
- Rectangle {
- Layout.fillWidth: true
- Layout.fillHeight: true
- Layout.minimumHeight: 140
- radius: 8
- color: "#101a26"
- border.color: BearTheme.cardBorder
-
- ScrollView {
- id: licenseScroll
- anchors.fill: parent
- anchors.margins: 10
- clip: true
- ScrollBar.vertical.policy: ScrollBar.AlwaysOn
-
- Label {
- width: licenseScroll.availableWidth
- 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 ."
- }
- }
- }
- }
- }
+ compactMode: root.compactMode
}
Popup {
diff --git a/src/qml/components/AboutDialog.qml b/src/qml/components/AboutDialog.qml
new file mode 100644
index 0000000..4d9fefb
--- /dev/null
+++ b/src/qml/components/AboutDialog.qml
@@ -0,0 +1,208 @@
+// 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
+
+Dialog {
+ id: root
+
+ required property bool compactMode
+
+ modal: true
+ anchors.centerIn: Overlay.overlay
+ width: compactMode ? 360 : 520
+ height: compactMode ? 560 : 680
+ standardButtons: Dialog.Ok
+
+ contentItem: ColumnLayout {
+ spacing: 16
+
+ ColumnLayout {
+ Layout.alignment: Qt.AlignHCenter
+ spacing: 6
+
+ Image {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.preferredWidth: 72
+ Layout.preferredHeight: 72
+ sourceSize.width: 72
+ sourceSize.height: 72
+ source: "qrc:/assets/app/bearwave.png"
+ fillMode: Image.PreserveAspectFit
+ smooth: true
+ }
+
+ Label {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("BearWave")
+ color: BearTheme.textMain
+ font.bold: true
+ font.pixelSize: 26
+ }
+
+ Label {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("Internet Radio Player for KDE")
+ color: BearTheme.textMuted
+ font.pixelSize: 14
+ }
+
+ Label {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("Version: %1").arg(Qt.application.version)
+ color: BearTheme.textMuted
+ font.pixelSize: 12
+ }
+
+ Label {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("Public beta")
+ color: BearTheme.accent
+ font.pixelSize: 12
+ font.bold: true
+ }
+ }
+
+ ColumnLayout {
+ Layout.alignment: Qt.AlignHCenter
+ spacing: 8
+ Layout.topMargin: 8
+ Layout.bottomMargin: 8
+
+ Label {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("Author: Sebastian Palencsár")
+ color: BearTheme.textMain
+ font.bold: true
+ font.pixelSize: 14
+ }
+
+ RowLayout {
+ Layout.alignment: Qt.AlignHCenter
+ spacing: 16
+
+ ToolButton {
+ Layout.preferredWidth: 40
+ Layout.preferredHeight: 40
+ onClicked: Qt.openUrlExternally("https://palencsar.pro")
+ ToolTip.visible: hovered
+ ToolTip.text: qsTr("Open website")
+ contentItem: Item {
+ Image {
+ anchors.centerIn: parent
+ width: 19
+ height: 19
+ source: "qrc:/assets/ui/globe.svg"
+ fillMode: Image.PreserveAspectFit
+ smooth: true
+ }
+ }
+ background: Rectangle {
+ radius: 20
+ color: parent.hovered ? "#274261" : "#1f3147"
+ border.color: BearTheme.cardBorder
+ border.width: 1
+ }
+ }
+
+ ToolButton {
+ Layout.preferredWidth: 40
+ Layout.preferredHeight: 40
+ onClicked: Qt.openUrlExternally("https://github.com/spalencsar")
+ ToolTip.visible: hovered
+ ToolTip.text: qsTr("Open GitHub")
+ contentItem: Item {
+ Image {
+ anchors.centerIn: parent
+ width: 19
+ height: 19
+ source: "qrc:/assets/ui/github.svg"
+ fillMode: Image.PreserveAspectFit
+ smooth: true
+ }
+ }
+ background: Rectangle {
+ radius: 20
+ color: parent.hovered ? "#274261" : "#1f3147"
+ border.color: BearTheme.cardBorder
+ border.width: 1
+ }
+ }
+
+ ToolButton {
+ Layout.preferredWidth: 40
+ Layout.preferredHeight: 40
+ onClicked: Qt.openUrlExternally("https://www.linkedin.com/in/spalencsar/")
+ ToolTip.visible: hovered
+ ToolTip.text: qsTr("Open LinkedIn")
+ contentItem: Item {
+ Image {
+ anchors.centerIn: parent
+ width: 19
+ height: 19
+ source: "qrc:/assets/ui/linkedin.svg"
+ fillMode: Image.PreserveAspectFit
+ smooth: true
+ }
+ }
+ background: Rectangle {
+ radius: 20
+ color: parent.hovered ? "#274261" : "#1f3147"
+ border.color: BearTheme.cardBorder
+ border.width: 1
+ }
+ }
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ spacing: 8
+
+ RowLayout {
+ Layout.fillWidth: true
+ Label {
+ Layout.fillWidth: true
+ text: qsTr("GNU GPLv3 License")
+ color: BearTheme.textMain
+ font.bold: true
+ }
+ Label {
+ text: qsTr("Copyright (c) 2026")
+ color: BearTheme.textMuted
+ font.pixelSize: 11
+ }
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.minimumHeight: 140
+ radius: 8
+ color: "#101a26"
+ border.color: BearTheme.cardBorder
+
+ ScrollView {
+ id: licenseScroll
+ anchors.fill: parent
+ anchors.margins: 10
+ clip: true
+ ScrollBar.vertical.policy: ScrollBar.AlwaysOn
+
+ Label {
+ width: licenseScroll.availableWidth
+ 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 ."
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/qml/components/PlayerBar.qml b/src/qml/components/PlayerBar.qml
new file mode 100644
index 0000000..6137ddc
--- /dev/null
+++ b/src/qml/components/PlayerBar.qml
@@ -0,0 +1,221 @@
+// 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
+
+Rectangle {
+ id: root
+
+ required property var app
+
+ implicitHeight: 108
+ radius: 12
+ color: BearTheme.panel
+ border.color: BearTheme.cardBorder
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.margins: 10
+ spacing: 12
+
+ Rectangle {
+ Layout.preferredWidth: 88
+ Layout.preferredHeight: 88
+ radius: 8
+ color: "#182637"
+ clip: true
+ border.color: BearTheme.cardBorder
+
+ Image {
+ id: coverImage
+ anchors.fill: parent
+ source: {
+ if (app.backend && app.backend.player && app.backend.player.currentCoverArtUrl
+ && app.backend.player.currentCoverArtUrl !== "") {
+ return app.backend.player.currentCoverArtUrl
+ }
+ if (app.backend && app.backend.currentStation && app.backend.currentStation.favicon
+ && app.backend.currentStation.favicon.startsWith("https://")) {
+ return app.backend.currentStation.favicon
+ }
+ return "qrc:/assets/app/bearwave.png"
+ }
+ fillMode: (app.backend && app.backend.player && app.backend.player.currentCoverArtUrl
+ && app.backend.player.currentCoverArtUrl !== "")
+ ? Image.PreserveAspectCrop : Image.PreserveAspectFit
+ smooth: true
+ asynchronous: true
+ anchors.margins: {
+ if (app.backend && app.backend.player && app.backend.player.currentCoverArtUrl
+ && app.backend.player.currentCoverArtUrl !== "") {
+ return 0
+ }
+ if (app.backend && app.backend.currentStation && app.backend.currentStation.favicon
+ && app.backend.currentStation.favicon.startsWith("https://")) {
+ return 8
+ }
+ return 16
+ }
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ spacing: 6
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 8
+
+ Label {
+ Layout.fillWidth: true
+ text: app.backend && app.backend.player
+ ? (app.backend.player.currentStationName || qsTr("No station selected"))
+ : qsTr("No station selected")
+ color: BearTheme.textMain
+ font.pixelSize: 16
+ font.bold: true
+ elide: Text.ElideRight
+ }
+
+ Rectangle {
+ visible: app.backend && app.backend.currentStation && app.backend.currentStation.codec
+ && app.backend.currentStation.codec !== "unknown"
+ && app.backend.currentStation.codec !== ""
+ height: 18
+ width: codecLabel.implicitWidth + 12
+ radius: 4
+ color: "transparent"
+ border.color: BearTheme.accent
+ border.width: 1
+
+ Label {
+ id: codecLabel
+ anchors.centerIn: parent
+ text: (app.backend && app.backend.currentStation && app.backend.currentStation.codec)
+ ? app.backend.currentStation.codec.toUpperCase() : ""
+ color: BearTheme.accent
+ font.pixelSize: 9
+ font.bold: true
+ }
+ }
+
+ Rectangle {
+ visible: app.backend && app.backend.currentStation && app.backend.currentStation.bitrate > 0
+ height: 18
+ width: bitrateLabel.implicitWidth + 12
+ radius: 4
+ color: BearTheme.accent
+ border.color: BearTheme.accent
+ border.width: 1
+
+ Label {
+ id: bitrateLabel
+ anchors.centerIn: parent
+ text: (app.backend && app.backend.currentStation)
+ ? app.backend.currentStation.bitrate + " kbps" : ""
+ color: "#ffffff"
+ font.pixelSize: 9
+ font.bold: true
+ }
+ }
+ }
+
+ Label {
+ Layout.fillWidth: true
+ text: app.backend && app.backend.player && app.backend.player.currentNowPlaying.length > 0
+ ? (qsTr("Now playing: ") + app.backend.player.currentNowPlaying)
+ : qsTr("Now playing: No track info")
+ color: BearTheme.textMuted
+ font.pixelSize: 12
+ elide: Text.ElideRight
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 8
+
+ Button {
+ text: "⏮"
+ Layout.preferredWidth: 44
+ enabled: app.backend ? app.backend.hasPreviousStation() : false
+ onClicked: {
+ if (app.backend) {
+ app.backend.playPreviousStation()
+ }
+ }
+ }
+
+ Button {
+ text: app.backend && app.backend.player && app.backend.player.playing ? "⏸" : "▶"
+ Layout.preferredWidth: 44
+ onClicked: {
+ if (app.backend && app.backend.player) {
+ app.backend.player.togglePlayPause()
+ }
+ }
+ }
+
+ Button {
+ text: "⏹"
+ Layout.preferredWidth: 44
+ onClicked: {
+ if (app.backend && app.backend.player) {
+ app.backend.player.stop()
+ }
+ }
+ }
+
+ Button {
+ text: "⏭"
+ Layout.preferredWidth: 44
+ enabled: app.backend ? app.backend.hasNextStation() : false
+ onClicked: {
+ if (app.backend) {
+ app.backend.playNextStation()
+ }
+ }
+ }
+
+ Button {
+ id: muteButton
+ text: (app.backend && app.backend.player && app.backend.player.volume > 0) ? "🔊" : "🔇"
+ Layout.preferredWidth: 44
+ property real lastVolume: 0.5
+
+ ToolTip.visible: hovered
+ ToolTip.text: (app.backend && app.backend.player && app.backend.player.volume > 0)
+ ? qsTr("Mute") : qsTr("Unmute")
+
+ onClicked: {
+ if (app.backend && app.backend.player) {
+ if (app.backend.player.volume > 0) {
+ lastVolume = app.backend.player.volume
+ app.backend.player.setVolume(0)
+ } else {
+ app.backend.player.setVolume(lastVolume > 0 ? lastVolume : 0.5)
+ }
+ }
+ }
+ }
+
+ Slider {
+ Layout.fillWidth: true
+ from: 0
+ to: 1
+ value: app.backend && app.backend.player ? app.backend.player.volume : 0.5
+ onMoved: {
+ if (app.backend && app.backend.player) {
+ app.backend.player.setVolume(value)
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/qml/components/StationCard.qml b/src/qml/components/StationCard.qml
new file mode 100644
index 0000000..1a3c27c
--- /dev/null
+++ b/src/qml/components/StationCard.qml
@@ -0,0 +1,228 @@
+// 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
+
+Rectangle {
+ id: root
+
+ required property int index
+ required property var modelData
+ required property var app
+ required property bool compactMode
+ required property real listWidth
+
+ height: compactMode ? 72 : 78
+ width: listWidth
+ radius: 10
+
+ readonly property bool isCurrent: {
+ if (!app.backend || !modelData) return false
+ var currentUuid = app.backend.currentStationUuid
+ var currentUrl = app.backend.currentStationUrl
+ var cardUuid = modelData.uuid || ""
+ var cardUrl = modelData.urlResolved || modelData.url || ""
+ if (currentUuid !== "" && cardUuid !== "") {
+ return currentUuid === cardUuid
+ }
+ return currentUrl !== "" && currentUrl === cardUrl
+ }
+ readonly property bool isPlaying: isCurrent && app.backend && app.backend.player && app.backend.player.playing
+
+ color: isCurrent
+ ? (cardMouse.containsMouse ? "#1d3350" : "#16283e")
+ : (cardMouse.containsMouse ? BearTheme.cardHover : BearTheme.card)
+ border.color: isCurrent ? BearTheme.accent : BearTheme.cardBorder
+ border.width: isCurrent ? 2 : 1
+
+ function playStation() {
+ if (!app.backend) return
+ if (app.currentPage === "favorites") {
+ app.backend.playFavoriteStation(index)
+ } else if (app.currentPage === "history") {
+ app.backend.playRecentByUuid(modelData.uuid, modelData.urlResolved)
+ } else {
+ app.backend.playStation(index)
+ }
+ }
+
+ MouseArea {
+ id: cardMouse
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.LeftButton
+ onClicked: {
+ if (!app.backend) return
+ if (root.isCurrent) {
+ app.backend.player.togglePlayPause()
+ } else {
+ playStation()
+ }
+ }
+ }
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.margins: 10
+ spacing: 10
+
+ Rectangle {
+ Layout.preferredWidth: 44
+ Layout.preferredHeight: 44
+ radius: 8
+ color: "#123154"
+ border.color: BearTheme.accent
+
+ Image {
+ id: stationFavicon
+ anchors.fill: parent
+ anchors.margins: 3
+ source: (root.modelData.favicon && root.modelData.favicon.startsWith("https://"))
+ ? root.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: root.modelData.name
+ color: root.isCurrent ? BearTheme.accent : BearTheme.textMain
+ font.bold: true
+ font.pixelSize: compactMode ? 13 : 14
+ elide: Text.ElideRight
+ }
+
+ Row {
+ id: eqAnimation
+ spacing: 2
+ Layout.alignment: Qt.AlignVCenter
+ visible: root.isPlaying
+
+ Rectangle {
+ id: bar1
+ width: 2
+ height: 12
+ color: BearTheme.accent
+ radius: 1
+ Behavior on height {
+ NumberAnimation { duration: 120 }
+ }
+ }
+ Rectangle {
+ id: bar2
+ width: 2
+ height: 12
+ color: BearTheme.accent
+ radius: 1
+ Behavior on height {
+ NumberAnimation { duration: 120 }
+ }
+ }
+ Rectangle {
+ id: bar3
+ width: 2
+ height: 12
+ color: BearTheme.accent
+ radius: 1
+ Behavior on height {
+ NumberAnimation { duration: 120 }
+ }
+ }
+
+ Timer {
+ interval: 150
+ running: root.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: root.modelData.country + " • "
+ + (root.modelData.codec && root.modelData.codec !== "unknown" && root.modelData.codec !== ""
+ ? root.modelData.codec.toUpperCase() + " • " : "")
+ + (root.modelData.bitrate > 0 ? root.modelData.bitrate + " kbps" : qsTr("Stream"))
+ color: BearTheme.textMuted
+ font.pixelSize: 11
+ elide: Text.ElideRight
+ }
+ }
+
+ Button {
+ visible: !root.modelData.uuid
+ Layout.preferredWidth: compactMode ? 34 : 40
+ Layout.preferredHeight: 40
+ text: "✏"
+ ToolTip.visible: hovered
+ ToolTip.text: qsTr("Edit Station")
+ onClicked: {
+ app.editDialog.stationObject = root.modelData
+ app.editDialog.setupAndOpen()
+ }
+ }
+
+ Button {
+ Layout.preferredWidth: compactMode ? 34 : 40
+ Layout.preferredHeight: 40
+ text: root.modelData.isFavorite ? "★" : "☆"
+ onClicked: {
+ if (!app.backend) return
+ app.backend.toggleFavoriteById(root.modelData.uuid, root.modelData.urlResolved)
+ app.toast(root.modelData.isFavorite ? qsTr("Removed from favorites") : qsTr("Added to favorites"))
+ }
+ }
+
+ Button {
+ Layout.preferredWidth: compactMode ? 34 : 40
+ Layout.preferredHeight: 40
+ text: (root.isCurrent && app.backend && app.backend.player && app.backend.player.playing) ? "⏸" : "▶"
+ onClicked: {
+ if (!app.backend) return
+ if (root.isCurrent) {
+ app.backend.player.togglePlayPause()
+ } else {
+ playStation()
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/qml/components/WorldCategories.qml b/src/qml/components/WorldCategories.qml
new file mode 100644
index 0000000..ae1b901
--- /dev/null
+++ b/src/qml/components/WorldCategories.qml
@@ -0,0 +1,225 @@
+// 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
+import "../utils/FlagUtils.js" as FlagUtils
+
+ScrollView {
+ id: root
+
+ required property var app
+ required property bool compactMode
+
+ clip: true
+ ScrollBar.vertical.policy: ScrollBar.AsNeeded
+
+ Column {
+ width: root.availableWidth - 12
+ spacing: 16
+
+ RowLayout {
+ width: parent.width
+ spacing: 10
+
+ Label {
+ text: qsTr("Explore World Stations")
+ font.bold: true
+ font.pixelSize: 16
+ color: BearTheme.textMain
+ Layout.fillWidth: true
+ }
+
+ TextField {
+ placeholderText: qsTr("Filter countries...")
+ font.pixelSize: 11
+ Layout.preferredWidth: compactMode ? 140 : 200
+ text: app.countrySearchText
+ onTextChanged: app.countrySearchText = text
+ }
+ }
+
+ Label {
+ text: qsTr("Choose a country or music style to find radio stations from all over the world.")
+ font.pixelSize: 11
+ color: BearTheme.textMuted
+ wrapMode: Text.WordWrap
+ width: parent.width
+ visible: app.countrySearchText === ""
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: BearTheme.cardBorder
+ opacity: 0.4
+ visible: app.countrySearchText === ""
+ }
+
+ Label {
+ text: app.countrySearchText === "" ? qsTr("Countries") : qsTr("Filtered Countries")
+ font.bold: true
+ font.pixelSize: 13
+ color: BearTheme.accent
+ }
+
+ Flow {
+ id: countryFlow
+ width: parent.width
+ spacing: 8
+
+ Repeater {
+ model: app.getFilteredCountries()
+ delegate: Rectangle {
+ id: countryCard
+ required property var modelData
+ width: compactMode ? (countryFlow.width - 8) / 2 : (countryFlow.width - 16) / 3
+ height: 62
+ radius: 8
+ 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
+
+ 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: {
+ app.selectedWorldCategory = modelData.name
+ app.selectedWorldType = "country"
+ if (modelData.code === "GLOBAL") {
+ app.backend.loadWorldStations()
+ } else {
+ app.backend.loadByCountryCode(modelData.code)
+ }
+ }
+ }
+
+ Row {
+ anchors.fill: parent
+ anchors.leftMargin: 12
+ anchors.rightMargin: 12
+ spacing: 12
+
+ Label {
+ text: modelData.code === "GLOBAL" ? "🌎" : FlagUtils.flagEmoji(modelData.code)
+ font.pixelSize: 26
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ width: parent.width - 38
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+
+ Label {
+ width: parent.width
+ text: modelData.name
+ color: BearTheme.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: BearTheme.textMuted
+ font.pixelSize: 10
+ elide: Text.ElideRight
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Column {
+ width: parent.width
+ spacing: 16
+ visible: app.countrySearchText === ""
+
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: BearTheme.cardBorder
+ opacity: 0.4
+ }
+
+ Label {
+ text: qsTr("Popular Music Styles")
+ font.bold: true
+ font.pixelSize: 13
+ color: BearTheme.accent
+ }
+
+ Flow {
+ id: tagFlow
+ width: parent.width
+ spacing: 8
+
+ Repeater {
+ model: BearTheme.worldTags
+ delegate: Rectangle {
+ id: tagCard
+ required property var modelData
+ width: compactMode ? (tagFlow.width - 8) / 2 : (tagFlow.width - 16) / 3
+ height: 54
+ radius: 8
+ 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
+
+ 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: {
+ app.selectedWorldCategory = modelData.name
+ app.selectedWorldType = "tag"
+ app.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
+ text: modelData.name
+ color: BearTheme.textMain
+ font.bold: true
+ font.pixelSize: 13
+ elide: Text.ElideRight
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/qml/components/WorldCategoryHeader.qml b/src/qml/components/WorldCategoryHeader.qml
new file mode 100644
index 0000000..e619bdb
--- /dev/null
+++ b/src/qml/components/WorldCategoryHeader.qml
@@ -0,0 +1,35 @@
+// 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
+
+RowLayout {
+ id: root
+
+ required property var app
+
+ spacing: 12
+
+ Button {
+ text: qsTr("← Back to Categories")
+ onClicked: {
+ app.selectedWorldCategory = ""
+ app.selectedWorldType = ""
+ }
+ }
+
+ Label {
+ text: app.selectedWorldType === "country"
+ ? (qsTr("World > Country: ") + app.selectedWorldCategory)
+ : (qsTr("World > Genre: ") + app.selectedWorldCategory)
+ color: BearTheme.textMain
+ font.bold: true
+ font.pixelSize: 13
+ Layout.fillWidth: true
+ elide: Text.ElideRight
+ }
+}
\ No newline at end of file
diff --git a/src/qml/components/qmldir b/src/qml/components/qmldir
index 905d1c1..0cee098 100644
--- a/src/qml/components/qmldir
+++ b/src/qml/components/qmldir
@@ -1,3 +1,8 @@
HeaderNavigation 1.0 HeaderNavigation.qml
SearchToolbar 1.0 SearchToolbar.qml
-QuickFilters 1.0 QuickFilters.qml
\ No newline at end of file
+QuickFilters 1.0 QuickFilters.qml
+StationCard 1.0 StationCard.qml
+WorldCategoryHeader 1.0 WorldCategoryHeader.qml
+WorldCategories 1.0 WorldCategories.qml
+PlayerBar 1.0 PlayerBar.qml
+AboutDialog 1.0 AboutDialog.qml
\ No newline at end of file