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/HeaderNavigation.qml</file>
<file>qml/components/SearchToolbar.qml</file> <file>qml/components/SearchToolbar.qml</file>
<file>qml/components/QuickFilters.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> <file>qml/utils/FlagUtils.js</file>
</qresource> </qresource>
<qresource prefix="/"> <qresource prefix="/">

View File

@@ -7,7 +7,6 @@ import QtQuick.Layouts 1.15
import theme 1.0 import theme 1.0
import components 1.0 import components 1.0
import "utils/FlagUtils.js" as FlagUtils
ApplicationWindow { ApplicationWindow {
id: root id: root
@@ -187,30 +186,10 @@ ApplicationWindow {
anchors.margins: 10 anchors.margins: 10
spacing: 10 spacing: 10
RowLayout { WorldCategoryHeader {
id: worldHeader
Layout.fillWidth: true Layout.fillWidth: true
visible: currentPage === "world" && selectedWorldCategory !== "" visible: currentPage === "world" && selectedWorldCategory !== ""
spacing: 12 app: root
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
}
} }
ScrollView { ScrollView {
@@ -228,435 +207,20 @@ ApplicationWindow {
model: activeModel() model: activeModel()
visible: count > 0 visible: count > 0
delegate: Rectangle { delegate: StationCard {
id: stationCard app: root
required property int index compactMode: root.compactMode
required property var modelData listWidth: stationScrollView.availableWidth
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 { WorldCategories {
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
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
visible: currentPage === "world" && selectedWorldCategory === "" visible: currentPage === "world" && selectedWorldCategory === ""
clip: true app: root
ScrollBar.vertical.policy: ScrollBar.AsNeeded compactMode: root.compactMode
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
}
}
}
}
}
}
}
} }
} }
@@ -678,203 +242,9 @@ ApplicationWindow {
} }
} }
Rectangle { PlayerBar {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: 108 app: root
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)
}
}
}
}
}
}
} }
} }
@@ -989,204 +359,9 @@ ApplicationWindow {
} }
} }
Dialog { AboutDialog {
id: aboutDialog id: aboutDialog
modal: true compactMode: root.compactMode
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/>."
}
}
}
}
}
} }
Popup { 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 HeaderNavigation 1.0 HeaderNavigation.qml
SearchToolbar 1.0 SearchToolbar.qml SearchToolbar 1.0 SearchToolbar.qml
QuickFilters 1.0 QuickFilters.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