refactor(qml): extract station list, player, world, and about UI

Phase 2 splits the remaining large Main.qml blocks into StationCard,
WorldCategories, WorldCategoryHeader, PlayerBar, and AboutDialog.
Main.qml is now ~414 lines (down from ~1240); behavior unchanged.
This commit is contained in:
Sebastian Palencsar
2026-06-20 14:21:26 +02:00
parent 480bd483e8
commit 44597ec089
8 changed files with 941 additions and 839 deletions

View File

@@ -7,6 +7,11 @@
<file>qml/components/HeaderNavigation.qml</file>
<file>qml/components/SearchToolbar.qml</file>
<file>qml/components/QuickFilters.qml</file>
<file>qml/components/StationCard.qml</file>
<file>qml/components/WorldCategoryHeader.qml</file>
<file>qml/components/WorldCategories.qml</file>
<file>qml/components/PlayerBar.qml</file>
<file>qml/components/AboutDialog.qml</file>
<file>qml/utils/FlagUtils.js</file>
</qresource>
<qresource prefix="/">

View File

@@ -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)
}
delegate: StationCard {
app: root
compactMode: root.compactMode
listWidth: stationScrollView.availableWidth
}
}
}
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)
}
}
}
}
}
}
}
}
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 <https://www.gnu.org/licenses/>."
}
}
}
}
}
compactMode: root.compactMode
}
Popup {

View File

@@ -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 <https://www.gnu.org/licenses/>."
}
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,8 @@
HeaderNavigation 1.0 HeaderNavigation.qml
SearchToolbar 1.0 SearchToolbar.qml
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