From 44597ec08956db6283d47993fb10e6ca648125e6 Mon Sep 17 00:00:00 2001 From: Sebastian Palencsar Date: Sat, 20 Jun 2026 14:21:26 +0200 Subject: [PATCH] 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. --- src/qml.qrc | 5 + src/qml/Main.qml | 851 +-------------------- src/qml/components/AboutDialog.qml | 208 +++++ src/qml/components/PlayerBar.qml | 221 ++++++ src/qml/components/StationCard.qml | 228 ++++++ src/qml/components/WorldCategories.qml | 225 ++++++ src/qml/components/WorldCategoryHeader.qml | 35 + src/qml/components/qmldir | 7 +- 8 files changed, 941 insertions(+), 839 deletions(-) create mode 100644 src/qml/components/AboutDialog.qml create mode 100644 src/qml/components/PlayerBar.qml create mode 100644 src/qml/components/StationCard.qml create mode 100644 src/qml/components/WorldCategories.qml create mode 100644 src/qml/components/WorldCategoryHeader.qml 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