feat(ui): Mac-inspired layout with sidebar, details panel, and About page

Replace the modal About dialog with an embedded About page and introduce
a three-pane desktop layout: persistent sidebar navigation, flat station
list, right-side details panel, and compact player bar.

Load the GPLv3 license text from C++ and expose it to QML as context
data. Normalize i18n source strings to English, complete German and
Russian translations (133 strings each), and centralize page navigation
helpers in Main.qml. Simplify compact HeaderNavigation and extend
.gitignore for local build artifacts.
This commit is contained in:
Sebastian Palencsar
2026-06-27 13:21:55 +02:00
parent f613f47e98
commit 4806f9306a
24 changed files with 3202 additions and 1793 deletions

9
.gitignore vendored
View File

@@ -4,7 +4,11 @@ build.md
## Build directories
build/
build-about/
build-ci/
build-fix/
build-maclayout/
build-test/
build-arch-ci/
build-dir/
cmake-build-*/
@@ -36,6 +40,11 @@ test-*.json
pkg/
src/bearwave/
bearwave/
flatpak_repo/
.flatpak-builder/
.flatpak-builder-manifest.json
scratch/
spalencsar-README.md
*.pkg.tar.zst
*.pkg.tar.zst.sig
*.pkg.tar.xz

View File

@@ -10,14 +10,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Add Russian language support and translation (thanks to [@aaly11](https://github.com/aaly11)).
- Add a Mac-inspired desktop layout with persistent sidebar navigation, flat station rows, right-side station details, and a compact now-playing bar.
- Add an embedded About page with full GPLv3 license text loaded from the bundled `LICENSE` resource and a third-party technologies section.
### Changed
- Flatpak repository signing key replaced with `Bearwave App <dev@bearwave.app>` (`5BAA384577671E45`).
- Replace the modal About dialog with an in-app About page reachable from sidebar and compact navigation.
- Refresh the QML visual theme toward a neutral dark palette and align the main layout more closely with the macOS BearWave structure.
- Use the BearWave line logo asset in the sidebar and remove duplicate branding from the top search toolbar.
- Make the top search/filter area and bottom player bar more responsive to narrower desktop window sizes.
### Fixed
- Fix German translation contexts broken after the QML refactor (thanks to [@aaly11](https://github.com/aaly11)).
- Fix the embedded GPL text failing to load in QML by loading the resource in C++ and exposing it to QML as context data.
- Fix the About license view formatting so the text uses the available width without an oversized empty box.
- Fix right-side station details and top toolbar clipping in the Mac-style layout.
## Version notes (1.0.3 → 1.0.5)

View File

@@ -37,6 +37,14 @@ If you changed QML:
qmllint src/qml/Main.qml src/qml/components/*.qml src/qml/theme/BearTheme.qml
```
For UI layout work that should be tested separately from the default build tree:
```bash
cmake -S . -B build-maclayout -DCMAKE_BUILD_TYPE=Release
cmake --build build-maclayout -j"$(nproc)"
./build-maclayout/src/bearwave
```
Run unit tests after backend changes:
```bash
@@ -51,6 +59,7 @@ ctest --test-dir build --output-on-failure
- Keep network operations asynchronous.
- Preserve MPRIS behavior and user state compatibility.
- When changing resources, register them in `src/qml.qrc`.
- When changing the About page, keep the full GPL text resource visible and keep third-party technology notes accurate.
## Manual Checks
@@ -63,6 +72,7 @@ Please smoke-test the area you changed when possible:
- favorites persist across restart
- resume restores last station and volume
- tray and MPRIS behavior still work
- About page opens from sidebar/compact navigation, social links work, and the GPL license text is scrollable
## Pull Requests

View File

@@ -20,7 +20,7 @@ Why not 1.0.4 on GitHub? [1.0.4](CHANGELOG.md#104---2026-06-20) exists as a **gi
| Main window | Station browser |
| ----------------------------------------- | -------------------------------------------- |
| ![Main Window](screenshots/screen01.png) | ![Station Browser](screenshots/screen02.png) |
| ![About Dialog](screenshots/screen03.png) | ![Additional View](screenshots/screen04.png) |
| ![About Page](screenshots/screen03.png) | ![Additional View](screenshots/screen04.png) |
| ![World View](screenshots/screen05.png) | |
Screenshots: KDE Plasma on Linux.
@@ -163,7 +163,8 @@ BearWave intentionally does not aim to be:
- MPRIS integration for Plasma media controls and media keys
- system tray integration for background playback
- desktop notifications for song/track changes with local cover art caching
- embedded About dialog with links and GNU GPLv3 license text
- Mac-inspired three-pane desktop layout with sidebar navigation, station list, details panel, and compact now-playing bar
- embedded About page with author links, full GNU GPLv3 license text, and third-party technology notes
## Project Status
@@ -216,14 +217,14 @@ Add distributions here only after explicit testing, not by assumption alone.
### Language Support
BearWave currently supports English and German.
BearWave currently supports English, German, and Russian.
- the application uses the system locale to select its UI language
- English is the base UI language
- German is provided as a bundled translation
- German and Russian are provided as bundled translations
- README, repository metadata, and development-facing material remain in English
If the system language is German, BearWave appears in German. Otherwise it falls back to English.
If the system language matches a bundled translation, BearWave uses that language. Otherwise it falls back to English.
## Installation Status
@@ -290,6 +291,16 @@ cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j"$(nproc)"
```
### UI layout development build
For layout work that should not disturb the normal `build/` directory, a separate build tree can be used:
```bash
cmake -S . -B build-maclayout -DCMAKE_BUILD_TYPE=Release
cmake --build build-maclayout -j"$(nproc)"
./build-maclayout/src/bearwave
```
### Tests
From the build directory:
@@ -425,13 +436,14 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for local build and review expectations.
## Development Notes
- main UI shell: `src/qml/Main.qml`
- QML components: `src/qml/components/` (navigation, search, station cards, player bar, dialogs)
- QML components: `src/qml/components/` (navigation, search, station rows, detail panel, About page, player bar, dialogs)
- theme singleton: `src/qml/theme/BearTheme.qml`
- backend orchestration: `src/radiobackend.cpp`
- stream playback: `src/bearplayer.cpp`
- API layer: `src/radiobrowser.cpp`
- MPRIS adapter: `src/mprisadaptor.cpp`
- desktop notifications: `src/notificationmanager.cpp`
- embedded GPL license resource: `qrc:/assets/legal/gpl-3.0.txt` from `LICENSE`
- unit tests: `tests/`
See [CHANGELOG.md](CHANGELOG.md) for release history. For contributor and agent guardrails, see `AGENTS.md`.

BIN
screenshots/screen6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -6,6 +6,7 @@
#include <QDBusConnectionInterface>
#include <QDBusMessage>
#include <QDebug>
#include <QFile>
#include <QIcon>
#include <QLocale>
#include <QQmlApplicationEngine>
@@ -56,9 +57,17 @@ int main(int argc, char *argv[])
engine.addImportPath(QStringLiteral("qrc:/qml"));
RadioBackend backend;
QFile licenseFile(QStringLiteral(":/assets/legal/gpl-3.0.txt"));
QString licenseText;
if (licenseFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
licenseText = QString::fromUtf8(licenseFile.readAll());
} else {
qWarning() << "Failed to load embedded GPLv3 license text";
}
engine.rootContext()->setContextProperty("radioBackend", &backend);
engine.rootContext()->setContextProperty("bearwaveVersion", QStringLiteral(BEARWAVE_VERSION));
engine.rootContext()->setContextProperty("bearwaveBuildId", QStringLiteral(BEARWAVE_GIT_HASH));
engine.rootContext()->setContextProperty("bearwaveLicenseText", licenseText);
auto *mprisRoot = new MprisRootAdaptor(&backend, &app);
auto *mprisPlayer = new MprisPlayerAdaptor(&backend);

View File

@@ -11,12 +11,15 @@
<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/components/AboutPage.qml</file>
<file>qml/components/AddStationDialog.qml</file>
<file>qml/components/EditStationDialog.qml</file>
<file>qml/components/LoadingOverlay.qml</file>
<file>qml/components/ToastPopup.qml</file>
<file>qml/components/StationListPanel.qml</file>
<file>qml/components/SidebarNavigation.qml</file>
<file>qml/components/SidebarButton.qml</file>
<file>qml/components/StationDetailsPanel.qml</file>
<file>qml/utils/FlagUtils.js</file>
</qresource>
<qresource prefix="/">
@@ -25,5 +28,6 @@
<file alias="assets/ui/globe.svg">../assets/ui/globe.svg</file>
<file alias="assets/ui/github.svg">../assets/ui/github.svg</file>
<file alias="assets/ui/linkedin.svg">../assets/ui/linkedin.svg</file>
<file alias="assets/legal/gpl-3.0.txt">../LICENSE</file>
</qresource>
</RCC>
</RCC>

View File

@@ -11,9 +11,9 @@ import components 1.0
ApplicationWindow {
id: root
title: qsTr("BearWave")
width: 980
height: 700
minimumWidth: 520
width: 1400
height: 720
minimumWidth: 900
minimumHeight: 460
visible: true
@@ -26,7 +26,9 @@ ApplicationWindow {
property var backend: (typeof radioBackend !== "undefined" ? radioBackend : null)
readonly property string appVersion: (typeof bearwaveVersion !== "undefined" ? ("" + bearwaveVersion) : Qt.application.version)
readonly property string appBuildId: (typeof bearwaveBuildId !== "undefined" ? ("" + bearwaveBuildId) : "?")
property bool compactMode: width < 780
readonly property string appLicenseText: (typeof bearwaveLicenseText !== "undefined" ? ("" + bearwaveLicenseText) : "")
property bool compactMode: width < 900
property var selectedStation: null
property real contentOpacity: 1.0
property string activeQuickFilter: ""
property string selectedWorldCategory: ""
@@ -36,12 +38,54 @@ ApplicationWindow {
property alias stationList: stationPanel.stationList
property alias addDialog: addDialogPane
property alias editDialog: editDialogPane
property alias aboutDialog: aboutDialogPane
function toast(message) {
toastPopup.show(message)
}
function navigateToTop() {
if (!backend) return
currentPage = "top"
activeQuickFilter = ""
backend.loadTopStations()
}
function navigateToGerman() {
if (!backend) return
currentPage = "german"
activeQuickFilter = ""
backend.loadGermanStations()
}
function navigateToDutch() {
if (!backend) return
currentPage = "dutch"
activeQuickFilter = ""
backend.loadDutchStations()
}
function navigateToWorld() {
currentPage = "world"
activeQuickFilter = ""
selectedWorldCategory = ""
selectedWorldType = ""
}
function navigateToFavorites() {
currentPage = "favorites"
activeQuickFilter = ""
}
function navigateToHistory() {
currentPage = "history"
activeQuickFilter = ""
}
function navigateToAbout() {
currentPage = "about"
activeQuickFilter = ""
}
function resetSearchFilter() {
searchTimer.stop()
if (searchField.text !== "") {
@@ -85,6 +129,9 @@ ApplicationWindow {
onCurrentPageChanged: {
contentOpacity = 0.85
contentFadeRestart.restart()
if (currentPage !== "about") {
selectedStation = null
}
if (currentPage !== "search") {
resetSearchFilter()
}
@@ -111,34 +158,61 @@ ApplicationWindow {
Rectangle {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.0; color: BearTheme.bgA }
GradientStop { position: 1.0; color: BearTheme.bgB }
}
color: BearTheme.bgA
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 10
SidebarNavigation {
id: sidebarNav
visible: !root.compactMode
width: root.compactMode ? 0 : 200
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.margins: 0
app: root
compactMode: root.compactMode
}
Item {
id: workspace
anchors.left: root.compactMode ? parent.left : sidebarNav.right
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.margins: 0
anchors.leftMargin: root.compactMode ? 0 : 0
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: headerContent.implicitHeight + 20
radius: 12
id: headerPanel
height: currentPage === "about" && !root.compactMode ? 1 : headerContent.implicitHeight + 18
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
radius: 0
color: BearTheme.panel
border.color: BearTheme.cardBorder
border.color: "transparent"
clip: true
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
color: BearTheme.cardBorder
}
ColumnLayout {
id: headerContent
anchors.fill: parent
anchors.margins: 10
anchors.margins: 9
spacing: 8
visible: !(currentPage === "about" && !root.compactMode)
HeaderNavigation {
Layout.fillWidth: true
app: root
compactMode: root.compactMode
visible: root.compactMode
}
Rectangle {
@@ -146,6 +220,7 @@ ApplicationWindow {
Layout.preferredHeight: 1
color: BearTheme.cardBorder
opacity: 0.6
visible: root.compactMode
}
SearchToolbar {
@@ -154,12 +229,14 @@ ApplicationWindow {
app: root
compactMode: root.compactMode
searchTimer: searchTimer
visible: currentPage !== "about"
}
QuickFilters {
Layout.fillWidth: true
app: root
compactMode: root.compactMode
visible: currentPage !== "about"
}
Label {
@@ -173,12 +250,23 @@ ApplicationWindow {
}
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
radius: 12
color: BearTheme.panel
border.color: BearTheme.cardBorder
PlayerBar {
id: playerPanel
height: implicitHeight
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
app: root
}
Item {
id: contentArea
anchors.left: parent.left
anchors.right: parent.right
anchors.top: headerPanel.bottom
anchors.bottom: playerPanel.top
anchors.topMargin: 0
anchors.bottomMargin: 0
clip: true
opacity: contentOpacity
@@ -186,37 +274,74 @@ ApplicationWindow {
NumberAnimation { duration: 180; easing.type: Easing.OutCubic }
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 10
Rectangle {
id: stationPanelFrame
visible: currentPage !== "about"
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.right: root.compactMode ? parent.right : stationDetails.left
anchors.rightMargin: root.compactMode ? 0 : 0
radius: 0
color: BearTheme.panel
border.color: "transparent"
clip: true
WorldCategoryHeader {
Layout.fillWidth: true
visible: currentPage === "world" && selectedWorldCategory !== ""
app: root
Rectangle {
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 1
color: BearTheme.cardBorder
visible: !root.compactMode
}
StationListPanel {
id: stationPanel
Layout.fillWidth: true
Layout.fillHeight: true
app: root
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 0
WorldCategories {
Layout.fillWidth: true
Layout.fillHeight: true
visible: currentPage === "world" && selectedWorldCategory === ""
app: root
compactMode: root.compactMode
WorldCategoryHeader {
Layout.fillWidth: true
visible: currentPage === "world" && selectedWorldCategory !== ""
app: root
}
StationListPanel {
id: stationPanel
Layout.fillWidth: true
Layout.fillHeight: true
app: root
}
WorldCategories {
Layout.fillWidth: true
Layout.fillHeight: true
visible: currentPage === "world" && selectedWorldCategory === ""
app: root
compactMode: root.compactMode
}
}
}
}
PlayerBar {
Layout.fillWidth: true
app: root
StationDetailsPanel {
id: stationDetails
visible: !root.compactMode && currentPage !== "about"
width: 350
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
app: root
}
AboutPage {
visible: currentPage === "about"
anchors.fill: parent
compactMode: root.compactMode
appVersion: root.appVersion
buildId: root.appBuildId
licenseText: root.appLicenseText
}
}
}
@@ -242,13 +367,6 @@ ApplicationWindow {
compactMode: root.compactMode
}
AboutDialog {
id: aboutDialogPane
compactMode: root.compactMode
appVersion: root.appVersion
buildId: root.appBuildId
}
ToastPopup {
id: toastPopup
window: root

View File

@@ -1,212 +0,0 @@
// 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
property string appVersion: Qt.application.version
property string buildId: "?"
readonly property string versionLine: {
var version = root.appVersion
if (version === undefined || version === null || version === "")
version = Qt.application.version
var build = root.buildId
if (build === undefined || build === null || build === "")
build = "?"
return qsTr("Version") + " " + String(version) + " · " + qsTr("build") + " " + String(build)
}
modal: true
anchors.centerIn: parent
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: root.versionLine
color: BearTheme.textMuted
font.pixelSize: 12
}
}
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.AsNeeded
Label {
width: Math.max(licenseScroll.width, 1)
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,347 @@
// Copyright (c) 2026 Sebastian Palencsar
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import theme 1.0
Item {
id: root
required property bool compactMode
property string appVersion: Qt.application.version
property string buildId: "?"
property string licenseText: ""
readonly property string versionLine: {
var version = root.appVersion
if (version === undefined || version === null || version === "")
version = Qt.application.version
var build = root.buildId
if (build === undefined || build === null || build === "")
build = "?"
return qsTr("Version") + " " + String(version) + " · " + qsTr("Build") + " " + String(build)
}
readonly property var thirdPartyItems: [
{
name: qsTr("Qt 6"),
usage: qsTr("Application framework and UI runtime: Core, DBus, Network, Quick, Quick Controls 2, Widgets, Multimedia, and LinguistTools."),
license: qsTr("Subject to the upstream licenses of the installed Qt distribution.")
},
{
name: qsTr("Qt Multimedia backend"),
usage: qsTr("Audio playback runs through Qt Multimedia and the backend available on the system, such as GStreamer or FFmpeg."),
license: qsTr("Backend components are provided by the operating system or Qt build and retain their own licenses.")
},
{
name: qsTr("Radio Browser API"),
usage: qsTr("Public station directory API for station data and stream discovery. BearWave does not bundle any Radio Browser server code."),
license: qsTr("External service or API; subject to the upstream project and service terms.")
},
{
name: qsTr("freedesktop.org integrations"),
usage: qsTr("D-Bus, MPRIS, desktop notifications, desktop entry, and AppStream metadata."),
license: qsTr("Uses standards and interfaces for desktop integration; no third-party implementation is bundled.")
}
]
ScrollView {
id: pageScroll
anchors.fill: parent
clip: true
ScrollBar.vertical.policy: ScrollBar.AsNeeded
ColumnLayout {
width: Math.max(pageScroll.availableWidth, 1)
spacing: root.compactMode ? 18 : 22
RowLayout {
Layout.fillWidth: true
Layout.topMargin: root.compactMode ? 18 : 34
Layout.leftMargin: root.compactMode ? 18 : 46
Layout.rightMargin: root.compactMode ? 18 : 46
spacing: 16
Image {
Layout.preferredWidth: root.compactMode ? 58 : 72
Layout.preferredHeight: root.compactMode ? 58 : 72
sourceSize.width: root.compactMode ? 58 : 72
sourceSize.height: root.compactMode ? 58 : 72
source: "qrc:/assets/app/bearwave.png"
fillMode: Image.PreserveAspectFit
smooth: true
mipmap: true
}
ColumnLayout {
Layout.fillWidth: true
spacing: 5
Image {
Layout.preferredWidth: root.compactMode ? 170 : 210
Layout.preferredHeight: root.compactMode ? 40 : 48
source: "qrc:/assets/app/bearwave_line.png"
fillMode: Image.PreserveAspectFit
horizontalAlignment: Image.AlignLeft
smooth: true
mipmap: true
}
Label {
Layout.fillWidth: true
text: qsTr("Internet radio player for KDE")
color: BearTheme.textMain
font.pixelSize: root.compactMode ? 14 : 16
elide: Text.ElideRight
}
Label {
Layout.fillWidth: true
text: root.versionLine
color: BearTheme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.leftMargin: root.compactMode ? 18 : 46
Layout.rightMargin: root.compactMode ? 18 : 46
Layout.preferredHeight: 1
color: BearTheme.cardBorder
}
ColumnLayout {
Layout.fillWidth: true
Layout.leftMargin: root.compactMode ? 18 : 46
Layout.rightMargin: root.compactMode ? 18 : 46
spacing: 10
Label {
text: qsTr("Author")
color: BearTheme.textMuted
font.bold: true
font.pixelSize: 11
}
RowLayout {
Layout.fillWidth: true
spacing: 14
Label {
Layout.fillWidth: true
text: qsTr("Sebastian Palencsár")
color: BearTheme.textMain
font.bold: true
font.pixelSize: 15
elide: Text.ElideRight
}
ToolButton {
Layout.preferredWidth: 36
Layout.preferredHeight: 30
onClicked: Qt.openUrlExternally("https://palencsar.pro")
ToolTip.visible: hovered
ToolTip.text: qsTr("Open website")
contentItem: Item {
Image {
anchors.centerIn: parent
width: 18
height: 18
source: "qrc:/assets/ui/globe.svg"
fillMode: Image.PreserveAspectFit
smooth: true
}
}
}
ToolButton {
Layout.preferredWidth: 36
Layout.preferredHeight: 30
onClicked: Qt.openUrlExternally("https://github.com/spalencsar")
ToolTip.visible: hovered
ToolTip.text: qsTr("Open GitHub")
contentItem: Item {
Image {
anchors.centerIn: parent
width: 18
height: 18
source: "qrc:/assets/ui/github.svg"
fillMode: Image.PreserveAspectFit
smooth: true
}
}
}
ToolButton {
Layout.preferredWidth: 36
Layout.preferredHeight: 30
onClicked: Qt.openUrlExternally("https://www.linkedin.com/in/spalencsar/")
ToolTip.visible: hovered
ToolTip.text: qsTr("Open LinkedIn")
contentItem: Item {
Image {
anchors.centerIn: parent
width: 18
height: 18
source: "qrc:/assets/ui/linkedin.svg"
fillMode: Image.PreserveAspectFit
smooth: true
}
}
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.leftMargin: root.compactMode ? 18 : 46
Layout.rightMargin: root.compactMode ? 18 : 46
Layout.preferredHeight: 1
color: BearTheme.cardBorder
}
ColumnLayout {
Layout.fillWidth: true
Layout.leftMargin: root.compactMode ? 18 : 46
Layout.rightMargin: root.compactMode ? 18 : 46
spacing: 10
Label {
text: qsTr("Technologies and third-party components")
color: BearTheme.textMain
font.bold: true
font.pixelSize: 14
}
Label {
Layout.fillWidth: true
text: qsTr("BearWave uses the following frameworks, services, and desktop standards. These components are not relicensed by BearWave.")
color: BearTheme.textMuted
font.pixelSize: 12
wrapMode: Text.WordWrap
}
Repeater {
model: root.thirdPartyItems
delegate: Rectangle {
required property var modelData
Layout.fillWidth: true
implicitHeight: thirdPartyRow.implicitHeight + 16
radius: 6
color: "#24252b"
border.color: BearTheme.cardBorder
border.width: 1
RowLayout {
id: thirdPartyRow
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 14
Label {
Layout.preferredWidth: root.compactMode ? 120 : 180
text: modelData.name
color: BearTheme.textMain
font.bold: true
font.pixelSize: 13
wrapMode: Text.WordWrap
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Label {
Layout.fillWidth: true
text: modelData.usage
color: BearTheme.textMain
font.pixelSize: 12
wrapMode: Text.WordWrap
}
Label {
Layout.fillWidth: true
text: modelData.license
color: BearTheme.textMuted
font.pixelSize: 11
wrapMode: Text.WordWrap
}
}
}
}
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.leftMargin: root.compactMode ? 18 : 46
Layout.rightMargin: root.compactMode ? 18 : 46
Layout.bottomMargin: root.compactMode ? 18 : 34
spacing: 10
RowLayout {
Layout.fillWidth: true
Label {
Layout.fillWidth: true
text: qsTr("GNU GPLv3 License")
color: BearTheme.textMain
font.bold: true
font.pixelSize: 14
}
Label {
text: qsTr("Copyright (c) 2026")
color: BearTheme.textMuted
font.pixelSize: 11
}
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: root.compactMode ? 240 : 280
radius: 6
color: "#202127"
border.color: BearTheme.cardBorder
border.width: 1
ScrollView {
id: licenseScroll
anchors.fill: parent
anchors.margins: 10
clip: true
ScrollBar.vertical.policy: ScrollBar.AsNeeded
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
TextArea {
id: licenseTextArea
width: Math.max(licenseScroll.availableWidth, 1)
readOnly: true
selectByMouse: true
textFormat: TextEdit.PlainText
wrapMode: TextEdit.Wrap
text: root.licenseText.length > 0 ? root.licenseText : qsTr("License text could not be loaded.")
color: BearTheme.textMain
font.family: "monospace"
font.pixelSize: 12
background: Rectangle {
color: "transparent"
}
}
}
}
}
}
}
}

View File

@@ -13,121 +13,13 @@ Item {
required property var app
required property bool compactMode
implicitWidth: compactMode ? compactNav.implicitWidth : desktopNav.implicitWidth
implicitHeight: compactMode ? compactNav.implicitHeight : desktopNav.implicitHeight
function loadTop() {
if (!app.backend) return
app.currentPage = "top"
app.activeQuickFilter = ""
app.backend.loadTopStations()
}
function loadGerman() {
if (!app.backend) return
app.currentPage = "german"
app.activeQuickFilter = ""
app.backend.loadGermanStations()
}
function loadDutch() {
if (!app.backend) return
app.currentPage = "dutch"
app.activeQuickFilter = ""
app.backend.loadDutchStations()
}
function showFavorites() {
app.currentPage = "favorites"
app.activeQuickFilter = ""
}
function showHistory() {
app.currentPage = "history"
app.activeQuickFilter = ""
}
RowLayout {
id: desktopNav
anchors.left: parent.left
anchors.right: parent.right
visible: !root.compactMode
spacing: 8
Image {
Layout.preferredWidth: 112
Layout.preferredHeight: 40
source: "qrc:/assets/app/bearwave_line.png"
fillMode: Image.PreserveAspectFit
smooth: true
mipmap: true
}
Rectangle {
Layout.preferredWidth: 1
Layout.fillHeight: true
color: BearTheme.cardBorder
opacity: 0.6
}
Button {
text: qsTr("Top")
highlighted: app.currentPage === "top"
onClicked: loadTop()
}
Button {
text: qsTr("DE")
highlighted: app.currentPage === "german"
onClicked: loadGerman()
}
Button {
text: qsTr("NL")
highlighted: app.currentPage === "dutch"
onClicked: loadDutch()
}
Button {
text: qsTr("Favorites")
highlighted: app.currentPage === "favorites"
onClicked: showFavorites()
}
Button {
text: qsTr("History")
highlighted: app.currentPage === "history"
onClicked: showHistory()
}
Item { Layout.fillWidth: true }
Button {
text: qsTr("Manual +")
onClicked: app.addDialog.open()
}
Button {
text: qsTr("About")
onClicked: app.aboutDialog.open()
}
Button {
visible: app.backend && app.backend.canResumeLastStation
text: qsTr("Resume")
onClicked: {
if (app.backend) {
app.backend.resumeLastStation()
}
}
}
}
implicitWidth: compactNav.implicitWidth
implicitHeight: compactNav.implicitHeight
ColumnLayout {
id: compactNav
anchors.left: parent.left
anchors.right: parent.right
visible: root.compactMode
spacing: 8
RowLayout {
@@ -152,7 +44,8 @@ Item {
Button {
text: qsTr("About")
onClicked: app.aboutDialog.open()
highlighted: app.currentPage === "about"
onClicked: app.navigateToAbout()
}
Button {
@@ -174,31 +67,31 @@ Item {
Button {
text: qsTr("Top")
highlighted: app.currentPage === "top"
onClicked: loadTop()
onClicked: app.navigateToTop()
}
Button {
text: qsTr("DE")
highlighted: app.currentPage === "german"
onClicked: loadGerman()
onClicked: app.navigateToGerman()
}
Button {
text: qsTr("NL")
highlighted: app.currentPage === "dutch"
onClicked: loadDutch()
onClicked: app.navigateToDutch()
}
Button {
text: qsTr("Favorites")
highlighted: app.currentPage === "favorites"
onClicked: showFavorites()
onClicked: app.navigateToFavorites()
}
Button {
text: qsTr("History")
highlighted: app.currentPage === "history"
onClicked: showHistory()
onClicked: app.navigateToHistory()
}
}
}

View File

@@ -12,23 +12,34 @@ Rectangle {
required property var app
implicitHeight: 108
radius: 12
implicitHeight: 76
radius: 0
color: BearTheme.panel
border.color: BearTheme.cardBorder
border.color: "transparent"
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: 1
color: BearTheme.cardBorder
}
RowLayout {
anchors.fill: parent
anchors.margins: 10
anchors.leftMargin: 12
anchors.rightMargin: 12
anchors.topMargin: 9
anchors.bottomMargin: 9
spacing: 12
Rectangle {
Layout.preferredWidth: 88
Layout.preferredHeight: 88
radius: 8
color: "#182637"
Layout.preferredWidth: 56
Layout.preferredHeight: 56
radius: 6
color: "#34353b"
clip: true
border.color: BearTheme.cardBorder
border.color: "transparent"
Image {
id: coverImage
@@ -58,164 +69,122 @@ Rectangle {
&& app.backend.currentStation.favicon.startsWith("https://")) {
return 8
}
return 16
return 10
}
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 6
Layout.alignment: Qt.AlignVCenter
spacing: 3
RowLayout {
Label {
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
}
}
text: app.backend && app.backend.player
? (app.backend.player.currentStationName || qsTr("No station selected"))
: qsTr("No station selected")
color: BearTheme.textMain
font.pixelSize: 14
font.bold: true
elide: Text.ElideRight
}
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")
: qsTr("Live radio")
color: BearTheme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
RowLayout {
Layout.alignment: Qt.AlignVCenter
spacing: 8
Button {
text: "⏮"
Layout.preferredWidth: 44
enabled: app.backend ? app.backend.hasPreviousStation() : false
onClicked: {
if (app.backend) {
app.backend.playPreviousStation()
Button {
text: "⏮"
Layout.preferredWidth: 42
Layout.preferredHeight: 30
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: 42
Layout.preferredHeight: 30
onClicked: {
if (app.backend && app.backend.player) {
app.backend.player.togglePlayPause()
}
}
}
Button {
text: "⏹"
Layout.preferredWidth: 42
Layout.preferredHeight: 30
onClicked: {
if (app.backend && app.backend.player) {
app.backend.player.stop()
}
}
}
Button {
text: "⏭"
Layout.preferredWidth: 42
Layout.preferredHeight: 30
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: 36
Layout.preferredHeight: 30
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)
}
}
}
}
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)
}
Slider {
Layout.preferredWidth: app.width < 1180 ? 115 : 160
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

@@ -22,6 +22,7 @@ ColumnLayout {
app.backend.loadByTag(tag)
}
// Load country by country code
function loadCountry(code, page) {
if (!app.backend) return
app.currentPage = page
@@ -40,72 +41,124 @@ ColumnLayout {
}
}
RowLayout {
Flow {
visible: !root.compactMode
Layout.fillWidth: true
spacing: 6
Layout.preferredHeight: childrenRect.height
width: parent.width
spacing: 8
// Left: Schnellzugriff filters
Label {
text: qsTr("Genre:")
text: qsTr("Quick access")
color: BearTheme.textMuted
font.bold: true
font.pixelSize: 11
rightPadding: 10
width: 86
height: 30
verticalAlignment: Text.AlignVCenter
}
Button {
width: 72
height: 30
text: qsTr("Rock")
highlighted: app.activeQuickFilter === "tag:rock"
onClicked: loadTag("rock", "genre-rock")
}
Button {
width: 72
height: 30
text: qsTr("News")
highlighted: app.activeQuickFilter === "tag:news"
onClicked: loadTag("news", "genre-news")
}
Button {
width: 72
height: 30
text: qsTr("Jazz")
highlighted: app.activeQuickFilter === "tag:jazz"
onClicked: loadTag("jazz", "genre-jazz")
}
Item { Layout.preferredWidth: 20 }
Label {
text: qsTr("Country:")
color: BearTheme.textMuted
font.pixelSize: 11
rightPadding: 10
Rectangle {
width: 1
height: 18
color: BearTheme.cardBorder
opacity: 0.6
}
Button {
width: 62
height: 30
text: qsTr("US")
highlighted: app.activeQuickFilter === "cc:US"
onClicked: loadCountry("US", "country-us")
}
Button {
text: qsTr("UK")
width: 62
height: 30
text: qsTr("GB")
highlighted: app.activeQuickFilter === "cc:GB"
onClicked: loadCountry("GB", "country-gb")
}
Button {
width: 62
height: 30
text: qsTr("FR")
highlighted: app.activeQuickFilter === "cc:FR"
onClicked: loadCountry("FR", "country-fr")
}
Button {
text: qsTr("WORLD")
highlighted: app.activeQuickFilter === "world"
onClicked: openWorld()
Label {
text: qsTr("Sort")
color: BearTheme.textMuted
font.bold: true
font.pixelSize: 11
width: 76
height: 30
verticalAlignment: Text.AlignVCenter
}
Item { Layout.fillWidth: true }
Button {
width: 78
height: 30
text: qsTr("Name")
onClicked: {
if (app.backend && app.currentPage !== "favorites") {
app.backend.sortStations("name")
}
}
}
Button {
width: 78
height: 30
text: qsTr("Bitrate")
onClicked: {
if (app.backend && app.currentPage !== "favorites") {
app.backend.sortStations("bitrate")
}
}
}
Button {
width: 78
height: 30
text: qsTr("Votes")
onClicked: {
if (app.backend && app.currentPage !== "favorites") {
app.backend.sortStations("votes")
}
}
}
}
// Compact Mode (Mobile/Narrow) layout
ColumnLayout {
visible: root.compactMode
Layout.fillWidth: true
@@ -187,4 +240,4 @@ ColumnLayout {
}
}
}
}
}

View File

@@ -21,12 +21,13 @@ ColumnLayout {
RowLayout {
visible: !root.compactMode
Layout.fillWidth: true
spacing: 8
spacing: 10
TextField {
id: searchField
Layout.fillWidth: true
placeholderText: qsTr("Search stations (name, genre, country)")
Layout.minimumWidth: 180
placeholderText: qsTr("Search stations (name, genre, country)...")
onTextChanged: {
if (app.backend) {
app.backend.filterQuery = text
@@ -40,44 +41,35 @@ ColumnLayout {
}
}
Button {
text: qsTr("Search")
highlighted: true
onClicked: {
if (searchField.text.length < 2 || !app.backend) return
app.currentPage = "search"
app.backend.searchStations(searchField.text)
}
}
// Right Search and Clear buttons
RowLayout {
spacing: 6
Button {
text: qsTr("Sort A-Z")
onClicked: {
if (app.backend && app.currentPage !== "favorites") {
app.backend.sortStations("name")
Button {
text: root.app.width < 1180 ? "🔍" : "🔍 " + qsTr("Search")
highlighted: true
Layout.preferredWidth: root.app.width < 1180 ? 44 : 88
onClicked: {
if (searchField.text.length < 2 || !app.backend) return
app.currentPage = "search"
app.backend.searchStations(searchField.text)
}
}
}
Button {
text: qsTr("Sort Bitrate")
onClicked: {
if (app.backend && app.currentPage !== "favorites") {
app.backend.sortStations("bitrate")
}
}
}
Button {
text: qsTr("Sort Votes")
onClicked: {
if (app.backend && app.currentPage !== "favorites") {
app.backend.sortStations("votes")
Button {
text: root.app.width < 1180 ? "✕" : "✕ " + qsTr("Clear")
Layout.preferredWidth: root.app.width < 1180 ? 44 : 82
onClicked: {
searchField.text = ""
if (app.backend) {
app.backend.filterQuery = ""
}
}
}
}
}
// Compact Mode (Mobile/Narrow) Search Layout
ColumnLayout {
visible: root.compactMode
Layout.fillWidth: true
@@ -108,59 +100,27 @@ ColumnLayout {
Layout.fillWidth: true
spacing: 8
Item { Layout.fillWidth: true }
Button {
text: qsTr("Search")
text: "🔍 " + qsTr("Search")
highlighted: true
Layout.fillWidth: true
onClicked: {
if (compactSearchField.text.length < 2 || !app.backend) return
if (searchField.text.length < 2 || !app.backend) return
app.currentPage = "search"
app.backend.searchStations(compactSearchField.text)
}
}
}
Flow {
Layout.fillWidth: true
width: parent.width
spacing: 8
Button {
text: "A-Z"
onClicked: {
if (app.backend && app.currentPage !== "favorites") {
app.backend.sortStations("name")
}
app.backend.searchStations(searchField.text)
}
}
Button {
text: "kb"
text: "✕ " + qsTr("Clear")
Layout.fillWidth: true
onClicked: {
if (app.backend && app.currentPage !== "favorites") {
app.backend.sortStations("bitrate")
}
}
}
Button {
text: "❤"
onClicked: {
if (app.backend && app.currentPage !== "favorites") {
app.backend.sortStations("votes")
searchField.text = ""
if (app.backend) {
app.backend.filterQuery = ""
}
}
}
}
}
Label {
Layout.fillWidth: true
text: qsTr("Tip: you are not limited to DE/NL. Search worldwide by country, genre, or station name.")
color: BearTheme.textMuted
font.pixelSize: 11
wrapMode: root.compactMode ? Text.WordWrap : Text.NoWrap
elide: root.compactMode ? Text.ElideNone : Text.ElideRight
}
}
}

View File

@@ -0,0 +1,59 @@
// 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: btn
property string text: ""
property string iconText: ""
property bool isActive: false
signal clicked()
Layout.fillWidth: true
Layout.preferredHeight: 32
radius: 6
color: isActive
? "#4b4b4f"
: (mouseArea.containsMouse ? "#36373d" : "transparent")
border.color: "transparent"
border.width: 0
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: btn.clicked()
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 9
anchors.rightMargin: 9
spacing: 10
Label {
text: btn.iconText
font.pixelSize: 14
Layout.alignment: Qt.AlignVCenter
}
Label {
text: btn.text
color: btn.isActive ? BearTheme.textMain : (mouseArea.containsMouse ? BearTheme.textMain : BearTheme.textMuted)
font.bold: btn.isActive
font.pixelSize: 12
elide: Text.ElideRight
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
}
}
}

View File

@@ -0,0 +1,153 @@
// 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
required property bool compactMode
color: BearTheme.sidebar
border.color: "transparent"
border.width: 0
radius: 0
ColumnLayout {
anchors.fill: parent
anchors.margins: 20
spacing: 14
RowLayout {
Layout.fillWidth: true
spacing: 10
Layout.bottomMargin: 8
Image {
Layout.preferredWidth: 34
Layout.preferredHeight: 34
source: "qrc:/assets/app/bearwave.png"
fillMode: Image.PreserveAspectFit
smooth: true
mipmap: true
}
Image {
Layout.fillWidth: true
Layout.preferredHeight: 28
source: "qrc:/assets/app/bearwave_line.png"
fillMode: Image.PreserveAspectFit
horizontalAlignment: Image.AlignLeft
smooth: true
mipmap: true
}
}
Label {
text: qsTr("Stations")
color: BearTheme.textMuted
font.bold: true
font.pixelSize: 10
Layout.fillWidth: true
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
SidebarButton {
text: qsTr("Top Global")
iconText: "🌍"
isActive: app.currentPage === "top"
onClicked: app.navigateToTop()
}
SidebarButton {
text: qsTr("Germany")
iconText: "🇩🇪"
isActive: app.currentPage === "german"
onClicked: app.navigateToGerman()
}
SidebarButton {
text: qsTr("Netherlands")
iconText: "🇳🇱"
isActive: app.currentPage === "dutch"
onClicked: app.navigateToDutch()
}
SidebarButton {
text: qsTr("Worldwide")
iconText: "🌐"
isActive: app.currentPage === "world"
onClicked: app.navigateToWorld()
}
}
Label {
text: qsTr("Library")
color: BearTheme.textMuted
font.bold: true
font.pixelSize: 10
Layout.fillWidth: true
Layout.topMargin: 8
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
SidebarButton {
text: qsTr("Favorites")
iconText: "★"
isActive: app.currentPage === "favorites"
onClicked: app.navigateToFavorites()
}
SidebarButton {
text: qsTr("History")
iconText: "🕒"
isActive: app.currentPage === "history"
onClicked: app.navigateToHistory()
}
}
Item { Layout.fillHeight: true }
ColumnLayout {
Layout.fillWidth: true
spacing: 4
SidebarButton {
text: qsTr("Add Station")
iconText: ""
isActive: false
onClicked: app.addDialog.open()
}
SidebarButton {
text: qsTr("About BearWave")
iconText: ""
isActive: app.currentPage === "about"
onClicked: app.navigateToAbout()
}
SidebarButton {
visible: app.backend && app.backend.canResumeLastStation
text: qsTr("Resume")
iconText: "↺"
isActive: false
onClicked: {
if (app.backend) {
app.backend.resumeLastStation()
}
}
}
}
}
}

View File

@@ -16,9 +16,9 @@ Rectangle {
required property bool compactMode
required property real listWidth
height: compactMode ? 72 : 78
height: 52
width: listWidth
radius: 10
radius: 0
readonly property bool isCurrent: {
if (!app.backend || !modelData) return false
@@ -34,10 +34,24 @@ Rectangle {
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
? "#4a4a4d"
: (cardMouse.containsMouse ? "#2d2e34" : "transparent")
border.width: 0
readonly property string metadataText: {
if (!modelData) return ""
var parts = []
if (modelData.country) parts.push(modelData.country)
if (modelData.codec && modelData.codec !== "unknown" && modelData.codec !== "") parts.push(modelData.codec.toUpperCase())
if (modelData.bitrate > 0) parts.push(modelData.bitrate + " kbps")
if (modelData.votes > 0) {
parts.push(modelData.votes.toLocaleString(Qt.locale(), "f", 0) + " " + qsTr("votes"))
} else {
parts.push(qsTr("Stream"))
}
return parts.join(" • ")
}
function playStation() {
if (!app.backend) return
@@ -56,6 +70,7 @@ Rectangle {
hoverEnabled: true
acceptedButtons: Qt.LeftButton
onClicked: {
app.selectedStation = root.modelData
if (!app.backend) return
if (root.isCurrent) {
app.backend.player.togglePlayPause()
@@ -65,22 +80,33 @@ Rectangle {
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
color: BearTheme.cardBorder
opacity: 0.65
}
RowLayout {
anchors.fill: parent
anchors.margins: 10
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 10
// Favicon Rect
Rectangle {
Layout.preferredWidth: 44
Layout.preferredHeight: 44
radius: 8
color: "#123154"
border.color: BearTheme.accent
Layout.preferredWidth: 34
Layout.preferredHeight: 34
radius: 5
color: "#34353b"
clip: true
Image {
id: stationFavicon
anchors.fill: parent
anchors.margins: 3
anchors.margins: 2
source: (root.modelData.favicon && root.modelData.favicon.startsWith("https://"))
? root.modelData.favicon
: ""
@@ -100,9 +126,11 @@ Rectangle {
}
}
// Info Column
ColumnLayout {
Layout.fillWidth: true
spacing: 2
spacing: 1
Layout.alignment: Qt.AlignVCenter
RowLayout {
Layout.fillWidth: true
@@ -113,10 +141,11 @@ Rectangle {
text: root.modelData.name
color: root.isCurrent ? BearTheme.accent : BearTheme.textMain
font.bold: true
font.pixelSize: compactMode ? 13 : 14
font.pixelSize: 13
elide: Text.ElideRight
}
// Tiny EQ Indicator
Row {
id: eqAnimation
spacing: 2
@@ -126,32 +155,26 @@ Rectangle {
Rectangle {
id: bar1
width: 2
height: 12
color: BearTheme.accent
height: 10
color: BearTheme.playingAccent
radius: 1
Behavior on height {
NumberAnimation { duration: 120 }
}
Behavior on height { NumberAnimation { duration: 120 } }
}
Rectangle {
id: bar2
width: 2
height: 12
color: BearTheme.accent
height: 10
color: BearTheme.playingAccent
radius: 1
Behavior on height {
NumberAnimation { duration: 120 }
}
Behavior on height { NumberAnimation { duration: 120 } }
}
Rectangle {
id: bar3
width: 2
height: 12
color: BearTheme.accent
height: 10
color: BearTheme.playingAccent
radius: 1
Behavior on height {
NumberAnimation { duration: 120 }
}
Behavior on height { NumberAnimation { duration: 120 } }
}
Timer {
@@ -159,17 +182,17 @@ Rectangle {
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
bar1.height = Math.floor(Math.random() * 9) + 2
bar2.height = Math.floor(Math.random() * 9) + 2
bar3.height = Math.floor(Math.random() * 9) + 2
}
}
onVisibleChanged: {
if (!visible) {
bar1.height = 12
bar2.height = 12
bar3.height = 12
bar1.height = 10
bar2.height = 10
bar3.height = 10
}
}
}
@@ -177,21 +200,20 @@ Rectangle {
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"))
text: root.metadataText
color: BearTheme.textMuted
font.pixelSize: 11
elide: Text.ElideRight
}
}
// Edit Button (Custom User Stations Only)
Button {
visible: !root.modelData.uuid
Layout.preferredWidth: compactMode ? 34 : 40
Layout.preferredHeight: 40
Layout.preferredWidth: 32
Layout.preferredHeight: 32
text: "✏"
flat: true
ToolTip.visible: hovered
ToolTip.text: qsTr("Edit Station")
onClicked: {
@@ -200,10 +222,13 @@ Rectangle {
}
}
// Favorite Button
Button {
Layout.preferredWidth: compactMode ? 34 : 40
Layout.preferredHeight: 40
Layout.preferredWidth: 32
Layout.preferredHeight: 32
text: root.modelData.isFavorite ? "★" : "☆"
flat: true
font.pixelSize: 14
onClicked: {
if (!app.backend) return
app.backend.toggleFavoriteById(root.modelData.uuid, root.modelData.urlResolved)
@@ -211,10 +236,13 @@ Rectangle {
}
}
// Play/Pause Button
Button {
Layout.preferredWidth: compactMode ? 34 : 40
Layout.preferredHeight: 40
Layout.preferredWidth: 32
Layout.preferredHeight: 32
text: (root.isCurrent && app.backend && app.backend.player && app.backend.player.playing) ? "⏸" : "▶"
flat: true
font.pixelSize: 13
onClicked: {
if (!app.backend) return
if (root.isCurrent) {
@@ -225,4 +253,4 @@ Rectangle {
}
}
}
}
}

View File

@@ -0,0 +1,359 @@
// 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
color: BearTheme.panel
border.color: "transparent"
border.width: 0
radius: 0
readonly property var activeStation: app.selectedStation || (app.backend ? app.backend.currentStation : null)
readonly property bool isCurrentPlaying: {
if (!activeStation || !app.backend) return false
var currentUuid = app.backend.currentStationUuid
var currentUrl = app.backend.currentStationUrl
var activeUuid = activeStation.uuid || ""
var activeUrl = activeStation.urlResolved || activeStation.url || ""
if (currentUuid !== "" && activeUuid !== "") {
return currentUuid === activeUuid
}
return currentUrl !== "" && currentUrl === activeUrl
}
readonly property bool isPlaying: isCurrentPlaying && app.backend && app.backend.player && app.backend.player.playing
// Helper text edit to facilitate clipboard copying
TextEdit {
id: clipboardHelper
visible: false
selectByMouse: true
}
function copyToClipboard(text) {
clipboardHelper.text = text
clipboardHelper.selectAll()
clipboardHelper.copy()
app.toast(qsTr("Stream URL copied!"))
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 16
spacing: 10
visible: root.activeStation !== null
ScrollView {
id: detailsScroll
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
ScrollBar.vertical.policy: ScrollBar.AsNeeded
ColumnLayout {
width: detailsScroll.availableWidth
spacing: 10
// Cover Art / Favicon
Rectangle {
Layout.alignment: Qt.AlignLeft
Layout.preferredWidth: 72
Layout.preferredHeight: 72
radius: 8
color: "#34353b"
border.color: "transparent"
clip: true
Image {
id: detailFavicon
anchors.fill: parent
anchors.margins: {
if (app.backend && app.backend.player && app.backend.player.currentCoverArtUrl && root.isCurrentPlaying) {
return 0
}
return activeStation && activeStation.favicon && activeStation.favicon.startsWith("https://") ? 8 : 16
}
source: {
if (!root.activeStation) return ""
if (root.isCurrentPlaying && app.backend && app.backend.player && app.backend.player.currentCoverArtUrl && app.backend.player.currentCoverArtUrl !== "") {
return app.backend.player.currentCoverArtUrl
}
return (root.activeStation.favicon && root.activeStation.favicon.startsWith("https://")) ? root.activeStation.favicon : ""
}
fillMode: (root.isCurrentPlaying && app.backend && app.backend.player && app.backend.player.currentCoverArtUrl && app.backend.player.currentCoverArtUrl !== "") ? Image.PreserveAspectCrop : Image.PreserveAspectFit
asynchronous: true
cache: true
visible: source !== "" && status === Image.Ready
}
Image {
anchors.fill: parent
anchors.margins: 16
source: "qrc:/assets/app/bearwave.png"
fillMode: Image.PreserveAspectFit
smooth: true
visible: !detailFavicon.visible
}
}
// Station Name & Country
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Layout.alignment: Qt.AlignLeft
Label {
text: root.activeStation ? root.activeStation.name : ""
color: BearTheme.textMain
font.bold: true
font.pixelSize: 16
horizontalAlignment: Text.AlignLeft
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
Label {
text: root.activeStation ? root.activeStation.country : ""
color: BearTheme.textMuted
font.pixelSize: 13
horizontalAlignment: Text.AlignLeft
Layout.fillWidth: true
}
}
Flow {
Layout.fillWidth: true
spacing: 6
Button {
text: root.isPlaying ? "⏸ " + qsTr("Pause") : "▶ " + qsTr("Play")
highlighted: true
width: 86
height: 28
font.pixelSize: 11
onClicked: {
if (!app.backend || !root.activeStation) return
if (root.isCurrentPlaying) {
app.backend.player.togglePlayPause()
} else {
// Find index or play by url
app.backend.playRecentByUuid(root.activeStation.uuid, root.activeStation.urlResolved)
}
}
}
Button {
text: root.activeStation && root.activeStation.isFavorite ? "★ " + qsTr("Favorite") : "☆ " + qsTr("Add")
width: 104
height: 28
font.pixelSize: 11
onClicked: {
if (!app.backend || !root.activeStation) return
app.backend.toggleFavoriteById(root.activeStation.uuid, root.activeStation.urlResolved)
app.toast(root.activeStation.isFavorite ? qsTr("Removed from favorites") : qsTr("Added to favorites"))
}
}
Button {
text: "📁 " + qsTr("Groups")
width: 92
height: 28
font.pixelSize: 11
onClicked: app.toast(qsTr("Groups coming soon"))
}
}
// Separator
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 1
color: BearTheme.cardBorder
opacity: 0.6
}
// Technical Specifications
ColumnLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: qsTr("Details")
color: BearTheme.textMuted
font.bold: true
font.pixelSize: 10
}
GridLayout {
columns: 2
columnSpacing: 16
rowSpacing: 8
Label {
text: qsTr("Codec:")
color: BearTheme.textMuted
font.pixelSize: 12
}
Label {
text: (root.activeStation && root.activeStation.codec && root.activeStation.codec !== "unknown") ? root.activeStation.codec.toUpperCase() : qsTr("Unknown")
color: BearTheme.textMain
font.pixelSize: 12
font.bold: true
}
Label {
text: qsTr("Bitrate:")
color: BearTheme.textMuted
font.pixelSize: 12
}
Label {
text: (root.activeStation && root.activeStation.bitrate > 0) ? root.activeStation.bitrate + " kbps" : qsTr("Unknown")
color: BearTheme.textMain
font.pixelSize: 12
font.bold: true
}
Label {
text: qsTr("Votes:")
color: BearTheme.textMuted
font.pixelSize: 12
}
Label {
text: root.activeStation ? "" + root.activeStation.votes : "0"
color: BearTheme.textMain
font.pixelSize: 12
font.bold: true
}
}
}
// Separator
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 1
color: BearTheme.cardBorder
opacity: 0.6
}
// Tags / Genres
ColumnLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: qsTr("Tags")
color: BearTheme.textMuted
font.bold: true
font.pixelSize: 10
}
Flow {
Layout.fillWidth: true
spacing: 6
Repeater {
model: (root.activeStation && root.activeStation.tags) ? root.activeStation.tags.split(",") : []
delegate: Rectangle {
visible: modelData.trim().length > 0
height: tagText.implicitHeight + 8
width: tagText.implicitWidth + 12
radius: 4
color: BearTheme.card
border.color: BearTheme.cardBorder
border.width: 1
Label {
id: tagText
anchors.centerIn: parent
text: modelData.trim()
color: BearTheme.textMuted
font.pixelSize: 10
}
}
}
}
}
// Separator
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 1
color: BearTheme.cardBorder
opacity: 0.6
}
// Links / URLs
ColumnLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: qsTr("Links")
color: BearTheme.textMuted
font.bold: true
font.pixelSize: 10
}
Button {
text: "🔗 " + qsTr("Open homepage")
Layout.fillWidth: true
enabled: root.activeStation && root.activeStation.homepage && root.activeStation.homepage.startsWith("http")
onClicked: {
if (root.activeStation && root.activeStation.homepage) {
Qt.openUrlExternally(root.activeStation.homepage)
}
}
}
Button {
text: "📋 " + qsTr("Copy stream URL")
Layout.fillWidth: true
enabled: root.activeStation && (root.activeStation.urlResolved || root.activeStation.url)
onClicked: {
if (root.activeStation) {
var url = root.activeStation.urlResolved || root.activeStation.url
root.copyToClipboard(url)
}
}
}
}
}
}
}
// Empty state if no station is active
ColumnLayout {
anchors.centerIn: parent
spacing: 12
visible: root.activeStation === null
Label {
text: "📻"
font.pixelSize: 48
Layout.alignment: Qt.AlignHCenter
}
Label {
text: qsTr("No station selected")
color: BearTheme.textMain
font.bold: true
font.pixelSize: 14
Layout.alignment: Qt.AlignHCenter
}
Label {
text: qsTr("Select a station from the list\nto view details.")
color: BearTheme.textMuted
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
Layout.alignment: Qt.AlignHCenter
}
}
}

View File

@@ -26,7 +26,7 @@ Item {
ListView {
id: stationList
width: stationScrollView.availableWidth
spacing: 8
spacing: 0
model: app.activeModel()
visible: count > 0
@@ -56,4 +56,4 @@ Item {
color: BearTheme.textMuted
}
}
}
}

View File

@@ -5,9 +5,12 @@ 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
AboutPage 1.0 AboutPage.qml
AddStationDialog 1.0 AddStationDialog.qml
EditStationDialog 1.0 EditStationDialog.qml
LoadingOverlay 1.0 LoadingOverlay.qml
ToastPopup 1.0 ToastPopup.qml
StationListPanel 1.0 StationListPanel.qml
StationListPanel 1.0 StationListPanel.qml
SidebarNavigation 1.0 SidebarNavigation.qml
SidebarButton 1.0 SidebarButton.qml
StationDetailsPanel 1.0 StationDetailsPanel.qml

View File

@@ -5,15 +5,17 @@ pragma Singleton
import QtQuick 2.15
QtObject {
readonly property color bgA: "#0f141b"
readonly property color bgB: "#131b25"
readonly property color panel: "#182433"
readonly property color card: "#1b2a3d"
readonly property color cardHover: "#223654"
readonly property color cardBorder: "#2d4566"
readonly property color accent: "#2bb0ff"
readonly property color textMain: "#eaf1fb"
readonly property color textMuted: "#9eb1c9"
readonly property color bgA: "#1d1d22"
readonly property color bgB: "#202027"
readonly property color panel: "#22232a"
readonly property color sidebar: "#2b2c33"
readonly property color card: "#2a2b31"
readonly property color cardHover: "#33343b"
readonly property color cardBorder: "#3a3b43"
readonly property color accent: "#d7d7df"
readonly property color playingAccent: "#0a84ff"
readonly property color textMain: "#f0f0f4"
readonly property color textMuted: "#a3a4ad"
readonly property color warn: "#ff8b8b"
readonly property var worldTags: [
@@ -30,4 +32,4 @@ QtObject {
{ name: qsTr("Ambient"), tag: "ambient", icon: "🌌" },
{ name: qsTr("Blues / Soul"), tag: "blues", icon: "🎺" }
]
}
}

View File

@@ -24,7 +24,8 @@ void QrcResourcesTest::qmlModulesAreEmbedded()
QVERIFY(QFile::exists(QStringLiteral(":/qml/theme/qmldir")));
QVERIFY(QFile::exists(QStringLiteral(":/qml/components/qmldir")));
QVERIFY(QFile::exists(QStringLiteral(":/assets/app/bearwave.png")));
QVERIFY(QFile::exists(QStringLiteral(":/assets/legal/gpl-3.0.txt")));
}
QTEST_APPLESS_MAIN(QrcResourcesTest)
#include "qrc_resources_test.moc"
#include "qrc_resources_test.moc"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff