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

View File

@@ -10,14 +10,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Add Russian language support and translation (thanks to [@aaly11](https://github.com/aaly11)). - 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 ### Changed
- Flatpak repository signing key replaced with `Bearwave App <dev@bearwave.app>` (`5BAA384577671E45`). - 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 ### Fixed
- Fix German translation contexts broken after the QML refactor (thanks to [@aaly11](https://github.com/aaly11)). - 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) ## 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 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: Run unit tests after backend changes:
```bash ```bash
@@ -51,6 +59,7 @@ ctest --test-dir build --output-on-failure
- Keep network operations asynchronous. - Keep network operations asynchronous.
- Preserve MPRIS behavior and user state compatibility. - Preserve MPRIS behavior and user state compatibility.
- When changing resources, register them in `src/qml.qrc`. - 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 ## Manual Checks
@@ -63,6 +72,7 @@ Please smoke-test the area you changed when possible:
- favorites persist across restart - favorites persist across restart
- resume restores last station and volume - resume restores last station and volume
- tray and MPRIS behavior still work - 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 ## 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 | Station browser |
| ----------------------------------------- | -------------------------------------------- | | ----------------------------------------- | -------------------------------------------- |
| ![Main Window](screenshots/screen01.png) | ![Station Browser](screenshots/screen02.png) | | ![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) | | | ![World View](screenshots/screen05.png) | |
Screenshots: KDE Plasma on Linux. 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 - MPRIS integration for Plasma media controls and media keys
- system tray integration for background playback - system tray integration for background playback
- desktop notifications for song/track changes with local cover art caching - 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 ## Project Status
@@ -216,14 +217,14 @@ Add distributions here only after explicit testing, not by assumption alone.
### Language Support ### 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 - the application uses the system locale to select its UI language
- English is the base 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 - 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 ## Installation Status
@@ -290,6 +291,16 @@ cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j"$(nproc)" 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 ### Tests
From the build directory: From the build directory:
@@ -425,13 +436,14 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for local build and review expectations.
## Development Notes ## Development Notes
- main UI shell: `src/qml/Main.qml` - 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` - theme singleton: `src/qml/theme/BearTheme.qml`
- backend orchestration: `src/radiobackend.cpp` - backend orchestration: `src/radiobackend.cpp`
- stream playback: `src/bearplayer.cpp` - stream playback: `src/bearplayer.cpp`
- API layer: `src/radiobrowser.cpp` - API layer: `src/radiobrowser.cpp`
- MPRIS adapter: `src/mprisadaptor.cpp` - MPRIS adapter: `src/mprisadaptor.cpp`
- desktop notifications: `src/notificationmanager.cpp` - desktop notifications: `src/notificationmanager.cpp`
- embedded GPL license resource: `qrc:/assets/legal/gpl-3.0.txt` from `LICENSE`
- unit tests: `tests/` - unit tests: `tests/`
See [CHANGELOG.md](CHANGELOG.md) for release history. For contributor and agent guardrails, see `AGENTS.md`. 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 <QDBusConnectionInterface>
#include <QDBusMessage> #include <QDBusMessage>
#include <QDebug> #include <QDebug>
#include <QFile>
#include <QIcon> #include <QIcon>
#include <QLocale> #include <QLocale>
#include <QQmlApplicationEngine> #include <QQmlApplicationEngine>
@@ -56,9 +57,17 @@ int main(int argc, char *argv[])
engine.addImportPath(QStringLiteral("qrc:/qml")); engine.addImportPath(QStringLiteral("qrc:/qml"));
RadioBackend backend; 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("radioBackend", &backend);
engine.rootContext()->setContextProperty("bearwaveVersion", QStringLiteral(BEARWAVE_VERSION)); engine.rootContext()->setContextProperty("bearwaveVersion", QStringLiteral(BEARWAVE_VERSION));
engine.rootContext()->setContextProperty("bearwaveBuildId", QStringLiteral(BEARWAVE_GIT_HASH)); engine.rootContext()->setContextProperty("bearwaveBuildId", QStringLiteral(BEARWAVE_GIT_HASH));
engine.rootContext()->setContextProperty("bearwaveLicenseText", licenseText);
auto *mprisRoot = new MprisRootAdaptor(&backend, &app); auto *mprisRoot = new MprisRootAdaptor(&backend, &app);
auto *mprisPlayer = new MprisPlayerAdaptor(&backend); auto *mprisPlayer = new MprisPlayerAdaptor(&backend);

View File

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

View File

@@ -11,9 +11,9 @@ import components 1.0
ApplicationWindow { ApplicationWindow {
id: root id: root
title: qsTr("BearWave") title: qsTr("BearWave")
width: 980 width: 1400
height: 700 height: 720
minimumWidth: 520 minimumWidth: 900
minimumHeight: 460 minimumHeight: 460
visible: true visible: true
@@ -26,7 +26,9 @@ ApplicationWindow {
property var backend: (typeof radioBackend !== "undefined" ? radioBackend : null) property var backend: (typeof radioBackend !== "undefined" ? radioBackend : null)
readonly property string appVersion: (typeof bearwaveVersion !== "undefined" ? ("" + bearwaveVersion) : Qt.application.version) readonly property string appVersion: (typeof bearwaveVersion !== "undefined" ? ("" + bearwaveVersion) : Qt.application.version)
readonly property string appBuildId: (typeof bearwaveBuildId !== "undefined" ? ("" + bearwaveBuildId) : "?") 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 real contentOpacity: 1.0
property string activeQuickFilter: "" property string activeQuickFilter: ""
property string selectedWorldCategory: "" property string selectedWorldCategory: ""
@@ -36,12 +38,54 @@ ApplicationWindow {
property alias stationList: stationPanel.stationList property alias stationList: stationPanel.stationList
property alias addDialog: addDialogPane property alias addDialog: addDialogPane
property alias editDialog: editDialogPane property alias editDialog: editDialogPane
property alias aboutDialog: aboutDialogPane
function toast(message) { function toast(message) {
toastPopup.show(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() { function resetSearchFilter() {
searchTimer.stop() searchTimer.stop()
if (searchField.text !== "") { if (searchField.text !== "") {
@@ -85,6 +129,9 @@ ApplicationWindow {
onCurrentPageChanged: { onCurrentPageChanged: {
contentOpacity = 0.85 contentOpacity = 0.85
contentFadeRestart.restart() contentFadeRestart.restart()
if (currentPage !== "about") {
selectedStation = null
}
if (currentPage !== "search") { if (currentPage !== "search") {
resetSearchFilter() resetSearchFilter()
} }
@@ -111,34 +158,61 @@ ApplicationWindow {
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
gradient: Gradient { color: BearTheme.bgA
GradientStop { position: 0.0; color: BearTheme.bgA }
GradientStop { position: 1.0; color: BearTheme.bgB }
}
} }
ColumnLayout { SidebarNavigation {
anchors.fill: parent id: sidebarNav
anchors.margins: 12 visible: !root.compactMode
spacing: 10 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 { Rectangle {
Layout.fillWidth: true id: headerPanel
Layout.preferredHeight: headerContent.implicitHeight + 20 height: currentPage === "about" && !root.compactMode ? 1 : headerContent.implicitHeight + 18
radius: 12 anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
radius: 0
color: BearTheme.panel 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 { ColumnLayout {
id: headerContent id: headerContent
anchors.fill: parent anchors.fill: parent
anchors.margins: 10 anchors.margins: 9
spacing: 8 spacing: 8
visible: !(currentPage === "about" && !root.compactMode)
HeaderNavigation { HeaderNavigation {
Layout.fillWidth: true Layout.fillWidth: true
app: root app: root
compactMode: root.compactMode compactMode: root.compactMode
visible: root.compactMode
} }
Rectangle { Rectangle {
@@ -146,6 +220,7 @@ ApplicationWindow {
Layout.preferredHeight: 1 Layout.preferredHeight: 1
color: BearTheme.cardBorder color: BearTheme.cardBorder
opacity: 0.6 opacity: 0.6
visible: root.compactMode
} }
SearchToolbar { SearchToolbar {
@@ -154,12 +229,14 @@ ApplicationWindow {
app: root app: root
compactMode: root.compactMode compactMode: root.compactMode
searchTimer: searchTimer searchTimer: searchTimer
visible: currentPage !== "about"
} }
QuickFilters { QuickFilters {
Layout.fillWidth: true Layout.fillWidth: true
app: root app: root
compactMode: root.compactMode compactMode: root.compactMode
visible: currentPage !== "about"
} }
Label { Label {
@@ -173,12 +250,23 @@ ApplicationWindow {
} }
} }
Rectangle { PlayerBar {
Layout.fillWidth: true id: playerPanel
Layout.fillHeight: true height: implicitHeight
radius: 12 anchors.left: parent.left
color: BearTheme.panel anchors.right: parent.right
border.color: BearTheme.cardBorder 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 clip: true
opacity: contentOpacity opacity: contentOpacity
@@ -186,10 +274,32 @@ ApplicationWindow {
NumberAnimation { duration: 180; easing.type: Easing.OutCubic } NumberAnimation { duration: 180; easing.type: Easing.OutCubic }
} }
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
Rectangle {
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 1
color: BearTheme.cardBorder
visible: !root.compactMode
}
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
anchors.margins: 10 anchors.margins: 10
spacing: 10 spacing: 0
WorldCategoryHeader { WorldCategoryHeader {
Layout.fillWidth: true Layout.fillWidth: true
@@ -214,10 +324,25 @@ ApplicationWindow {
} }
} }
PlayerBar { StationDetailsPanel {
Layout.fillWidth: true id: stationDetails
visible: !root.compactMode && currentPage !== "about"
width: 350
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
app: root app: root
} }
AboutPage {
visible: currentPage === "about"
anchors.fill: parent
compactMode: root.compactMode
appVersion: root.appVersion
buildId: root.appBuildId
licenseText: root.appLicenseText
}
}
} }
LoadingOverlay { LoadingOverlay {
@@ -242,13 +367,6 @@ ApplicationWindow {
compactMode: root.compactMode compactMode: root.compactMode
} }
AboutDialog {
id: aboutDialogPane
compactMode: root.compactMode
appVersion: root.appVersion
buildId: root.appBuildId
}
ToastPopup { ToastPopup {
id: toastPopup id: toastPopup
window: root 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 var app
required property bool compactMode required property bool compactMode
implicitWidth: compactMode ? compactNav.implicitWidth : desktopNav.implicitWidth implicitWidth: compactNav.implicitWidth
implicitHeight: compactMode ? compactNav.implicitHeight : desktopNav.implicitHeight implicitHeight: compactNav.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()
}
}
}
}
ColumnLayout { ColumnLayout {
id: compactNav id: compactNav
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
visible: root.compactMode
spacing: 8 spacing: 8
RowLayout { RowLayout {
@@ -152,7 +44,8 @@ Item {
Button { Button {
text: qsTr("About") text: qsTr("About")
onClicked: app.aboutDialog.open() highlighted: app.currentPage === "about"
onClicked: app.navigateToAbout()
} }
Button { Button {
@@ -174,31 +67,31 @@ Item {
Button { Button {
text: qsTr("Top") text: qsTr("Top")
highlighted: app.currentPage === "top" highlighted: app.currentPage === "top"
onClicked: loadTop() onClicked: app.navigateToTop()
} }
Button { Button {
text: qsTr("DE") text: qsTr("DE")
highlighted: app.currentPage === "german" highlighted: app.currentPage === "german"
onClicked: loadGerman() onClicked: app.navigateToGerman()
} }
Button { Button {
text: qsTr("NL") text: qsTr("NL")
highlighted: app.currentPage === "dutch" highlighted: app.currentPage === "dutch"
onClicked: loadDutch() onClicked: app.navigateToDutch()
} }
Button { Button {
text: qsTr("Favorites") text: qsTr("Favorites")
highlighted: app.currentPage === "favorites" highlighted: app.currentPage === "favorites"
onClicked: showFavorites() onClicked: app.navigateToFavorites()
} }
Button { Button {
text: qsTr("History") text: qsTr("History")
highlighted: app.currentPage === "history" highlighted: app.currentPage === "history"
onClicked: showHistory() onClicked: app.navigateToHistory()
} }
} }
} }

View File

@@ -12,23 +12,34 @@ Rectangle {
required property var app required property var app
implicitHeight: 108 implicitHeight: 76
radius: 12 radius: 0
color: BearTheme.panel 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 { RowLayout {
anchors.fill: parent anchors.fill: parent
anchors.margins: 10 anchors.leftMargin: 12
anchors.rightMargin: 12
anchors.topMargin: 9
anchors.bottomMargin: 9
spacing: 12 spacing: 12
Rectangle { Rectangle {
Layout.preferredWidth: 88 Layout.preferredWidth: 56
Layout.preferredHeight: 88 Layout.preferredHeight: 56
radius: 8 radius: 6
color: "#182637" color: "#34353b"
clip: true clip: true
border.color: BearTheme.cardBorder border.color: "transparent"
Image { Image {
id: coverImage id: coverImage
@@ -58,19 +69,15 @@ Rectangle {
&& app.backend.currentStation.favicon.startsWith("https://")) { && app.backend.currentStation.favicon.startsWith("https://")) {
return 8 return 8
} }
return 16 return 10
} }
} }
} }
ColumnLayout { ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.alignment: Qt.AlignVCenter
spacing: 6 spacing: 3
RowLayout {
Layout.fillWidth: true
spacing: 8
Label { Label {
Layout.fillWidth: true Layout.fillWidth: true
@@ -78,71 +85,30 @@ Rectangle {
? (app.backend.player.currentStationName || qsTr("No station selected")) ? (app.backend.player.currentStationName || qsTr("No station selected"))
: qsTr("No station selected") : qsTr("No station selected")
color: BearTheme.textMain color: BearTheme.textMain
font.pixelSize: 16 font.pixelSize: 14
font.bold: true font.bold: true
elide: Text.ElideRight 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 { Label {
Layout.fillWidth: true Layout.fillWidth: true
text: app.backend && app.backend.player && app.backend.player.currentNowPlaying.length > 0 text: app.backend && app.backend.player && app.backend.player.currentNowPlaying.length > 0
? (qsTr("Now playing: ") + app.backend.player.currentNowPlaying) ? (qsTr("Now playing: ") + app.backend.player.currentNowPlaying)
: qsTr("Now playing: No track info") : qsTr("Live radio")
color: BearTheme.textMuted color: BearTheme.textMuted
font.pixelSize: 12 font.pixelSize: 12
elide: Text.ElideRight elide: Text.ElideRight
} }
}
RowLayout { RowLayout {
Layout.fillWidth: true Layout.alignment: Qt.AlignVCenter
spacing: 8 spacing: 8
Button { Button {
text: "⏮" text: "⏮"
Layout.preferredWidth: 44 Layout.preferredWidth: 42
Layout.preferredHeight: 30
enabled: app.backend ? app.backend.hasPreviousStation() : false enabled: app.backend ? app.backend.hasPreviousStation() : false
onClicked: { onClicked: {
if (app.backend) { if (app.backend) {
@@ -153,7 +119,8 @@ Rectangle {
Button { Button {
text: app.backend && app.backend.player && app.backend.player.playing ? "⏸" : "▶" text: app.backend && app.backend.player && app.backend.player.playing ? "⏸" : "▶"
Layout.preferredWidth: 44 Layout.preferredWidth: 42
Layout.preferredHeight: 30
onClicked: { onClicked: {
if (app.backend && app.backend.player) { if (app.backend && app.backend.player) {
app.backend.player.togglePlayPause() app.backend.player.togglePlayPause()
@@ -163,7 +130,8 @@ Rectangle {
Button { Button {
text: "⏹" text: "⏹"
Layout.preferredWidth: 44 Layout.preferredWidth: 42
Layout.preferredHeight: 30
onClicked: { onClicked: {
if (app.backend && app.backend.player) { if (app.backend && app.backend.player) {
app.backend.player.stop() app.backend.player.stop()
@@ -173,7 +141,8 @@ Rectangle {
Button { Button {
text: "⏭" text: "⏭"
Layout.preferredWidth: 44 Layout.preferredWidth: 42
Layout.preferredHeight: 30
enabled: app.backend ? app.backend.hasNextStation() : false enabled: app.backend ? app.backend.hasNextStation() : false
onClicked: { onClicked: {
if (app.backend) { if (app.backend) {
@@ -185,7 +154,8 @@ Rectangle {
Button { Button {
id: muteButton id: muteButton
text: (app.backend && app.backend.player && app.backend.player.volume > 0) ? "🔊" : "🔇" text: (app.backend && app.backend.player && app.backend.player.volume > 0) ? "🔊" : "🔇"
Layout.preferredWidth: 44 Layout.preferredWidth: 36
Layout.preferredHeight: 30
property real lastVolume: 0.5 property real lastVolume: 0.5
ToolTip.visible: hovered ToolTip.visible: hovered
@@ -205,7 +175,7 @@ Rectangle {
} }
Slider { Slider {
Layout.fillWidth: true Layout.preferredWidth: app.width < 1180 ? 115 : 160
from: 0 from: 0
to: 1 to: 1
value: app.backend && app.backend.player ? app.backend.player.volume : 0.5 value: app.backend && app.backend.player ? app.backend.player.volume : 0.5
@@ -217,5 +187,4 @@ Rectangle {
} }
} }
} }
}
} }

View File

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

View File

@@ -21,12 +21,13 @@ ColumnLayout {
RowLayout { RowLayout {
visible: !root.compactMode visible: !root.compactMode
Layout.fillWidth: true Layout.fillWidth: true
spacing: 8 spacing: 10
TextField { TextField {
id: searchField id: searchField
Layout.fillWidth: true Layout.fillWidth: true
placeholderText: qsTr("Search stations (name, genre, country)") Layout.minimumWidth: 180
placeholderText: qsTr("Search stations (name, genre, country)...")
onTextChanged: { onTextChanged: {
if (app.backend) { if (app.backend) {
app.backend.filterQuery = text app.backend.filterQuery = text
@@ -40,9 +41,14 @@ ColumnLayout {
} }
} }
// Right Search and Clear buttons
RowLayout {
spacing: 6
Button { Button {
text: qsTr("Search") text: root.app.width < 1180 ? "🔍" : "🔍 " + qsTr("Search")
highlighted: true highlighted: true
Layout.preferredWidth: root.app.width < 1180 ? 44 : 88
onClicked: { onClicked: {
if (searchField.text.length < 2 || !app.backend) return if (searchField.text.length < 2 || !app.backend) return
app.currentPage = "search" app.currentPage = "search"
@@ -51,33 +57,19 @@ ColumnLayout {
} }
Button { Button {
text: qsTr("Sort A-Z") text: root.app.width < 1180 ? "✕" : "✕ " + qsTr("Clear")
Layout.preferredWidth: root.app.width < 1180 ? 44 : 82
onClicked: { onClicked: {
if (app.backend && app.currentPage !== "favorites") { searchField.text = ""
app.backend.sortStations("name") if (app.backend) {
app.backend.filterQuery = ""
} }
} }
} }
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")
}
}
} }
} }
// Compact Mode (Mobile/Narrow) Search Layout
ColumnLayout { ColumnLayout {
visible: root.compactMode visible: root.compactMode
Layout.fillWidth: true Layout.fillWidth: true
@@ -108,59 +100,27 @@ ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 8 spacing: 8
Item { Layout.fillWidth: true }
Button { Button {
text: qsTr("Search") text: "🔍 " + qsTr("Search")
highlighted: true highlighted: true
Layout.fillWidth: true
onClicked: { onClicked: {
if (compactSearchField.text.length < 2 || !app.backend) return if (searchField.text.length < 2 || !app.backend) return
app.currentPage = "search" app.currentPage = "search"
app.backend.searchStations(compactSearchField.text) app.backend.searchStations(searchField.text)
}
} }
} }
Flow { Button {
text: "✕ " + qsTr("Clear")
Layout.fillWidth: true Layout.fillWidth: true
width: parent.width
spacing: 8
Button {
text: "A-Z"
onClicked: { onClicked: {
if (app.backend && app.currentPage !== "favorites") { searchField.text = ""
app.backend.sortStations("name") if (app.backend) {
} app.backend.filterQuery = ""
}
}
Button {
text: "kb"
onClicked: {
if (app.backend && app.currentPage !== "favorites") {
app.backend.sortStations("bitrate")
}
}
}
Button {
text: "❤"
onClicked: {
if (app.backend && app.currentPage !== "favorites") {
app.backend.sortStations("votes")
} }
} }
} }
} }
} }
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 bool compactMode
required property real listWidth required property real listWidth
height: compactMode ? 72 : 78 height: 52
width: listWidth width: listWidth
radius: 10 radius: 0
readonly property bool isCurrent: { readonly property bool isCurrent: {
if (!app.backend || !modelData) return false 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 readonly property bool isPlaying: isCurrent && app.backend && app.backend.player && app.backend.player.playing
color: isCurrent color: isCurrent
? (cardMouse.containsMouse ? "#1d3350" : "#16283e") ? "#4a4a4d"
: (cardMouse.containsMouse ? BearTheme.cardHover : BearTheme.card) : (cardMouse.containsMouse ? "#2d2e34" : "transparent")
border.color: isCurrent ? BearTheme.accent : BearTheme.cardBorder
border.width: isCurrent ? 2 : 1 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() { function playStation() {
if (!app.backend) return if (!app.backend) return
@@ -56,6 +70,7 @@ Rectangle {
hoverEnabled: true hoverEnabled: true
acceptedButtons: Qt.LeftButton acceptedButtons: Qt.LeftButton
onClicked: { onClicked: {
app.selectedStation = root.modelData
if (!app.backend) return if (!app.backend) return
if (root.isCurrent) { if (root.isCurrent) {
app.backend.player.togglePlayPause() 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 { RowLayout {
anchors.fill: parent anchors.fill: parent
anchors.margins: 10 anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 10 spacing: 10
// Favicon Rect
Rectangle { Rectangle {
Layout.preferredWidth: 44 Layout.preferredWidth: 34
Layout.preferredHeight: 44 Layout.preferredHeight: 34
radius: 8 radius: 5
color: "#123154" color: "#34353b"
border.color: BearTheme.accent clip: true
Image { Image {
id: stationFavicon id: stationFavicon
anchors.fill: parent anchors.fill: parent
anchors.margins: 3 anchors.margins: 2
source: (root.modelData.favicon && root.modelData.favicon.startsWith("https://")) source: (root.modelData.favicon && root.modelData.favicon.startsWith("https://"))
? root.modelData.favicon ? root.modelData.favicon
: "" : ""
@@ -100,9 +126,11 @@ Rectangle {
} }
} }
// Info Column
ColumnLayout { ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 2 spacing: 1
Layout.alignment: Qt.AlignVCenter
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
@@ -113,10 +141,11 @@ Rectangle {
text: root.modelData.name text: root.modelData.name
color: root.isCurrent ? BearTheme.accent : BearTheme.textMain color: root.isCurrent ? BearTheme.accent : BearTheme.textMain
font.bold: true font.bold: true
font.pixelSize: compactMode ? 13 : 14 font.pixelSize: 13
elide: Text.ElideRight elide: Text.ElideRight
} }
// Tiny EQ Indicator
Row { Row {
id: eqAnimation id: eqAnimation
spacing: 2 spacing: 2
@@ -126,32 +155,26 @@ Rectangle {
Rectangle { Rectangle {
id: bar1 id: bar1
width: 2 width: 2
height: 12 height: 10
color: BearTheme.accent color: BearTheme.playingAccent
radius: 1 radius: 1
Behavior on height { Behavior on height { NumberAnimation { duration: 120 } }
NumberAnimation { duration: 120 }
}
} }
Rectangle { Rectangle {
id: bar2 id: bar2
width: 2 width: 2
height: 12 height: 10
color: BearTheme.accent color: BearTheme.playingAccent
radius: 1 radius: 1
Behavior on height { Behavior on height { NumberAnimation { duration: 120 } }
NumberAnimation { duration: 120 }
}
} }
Rectangle { Rectangle {
id: bar3 id: bar3
width: 2 width: 2
height: 12 height: 10
color: BearTheme.accent color: BearTheme.playingAccent
radius: 1 radius: 1
Behavior on height { Behavior on height { NumberAnimation { duration: 120 } }
NumberAnimation { duration: 120 }
}
} }
Timer { Timer {
@@ -159,17 +182,17 @@ Rectangle {
running: root.isPlaying running: root.isPlaying
repeat: true repeat: true
onTriggered: { onTriggered: {
bar1.height = Math.floor(Math.random() * 11) + 3 bar1.height = Math.floor(Math.random() * 9) + 2
bar2.height = Math.floor(Math.random() * 11) + 3 bar2.height = Math.floor(Math.random() * 9) + 2
bar3.height = Math.floor(Math.random() * 11) + 3 bar3.height = Math.floor(Math.random() * 9) + 2
} }
} }
onVisibleChanged: { onVisibleChanged: {
if (!visible) { if (!visible) {
bar1.height = 12 bar1.height = 10
bar2.height = 12 bar2.height = 10
bar3.height = 12 bar3.height = 10
} }
} }
} }
@@ -177,21 +200,20 @@ Rectangle {
Label { Label {
Layout.fillWidth: true Layout.fillWidth: true
text: root.modelData.country + " • " text: root.metadataText
+ (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 color: BearTheme.textMuted
font.pixelSize: 11 font.pixelSize: 11
elide: Text.ElideRight elide: Text.ElideRight
} }
} }
// Edit Button (Custom User Stations Only)
Button { Button {
visible: !root.modelData.uuid visible: !root.modelData.uuid
Layout.preferredWidth: compactMode ? 34 : 40 Layout.preferredWidth: 32
Layout.preferredHeight: 40 Layout.preferredHeight: 32
text: "✏" text: "✏"
flat: true
ToolTip.visible: hovered ToolTip.visible: hovered
ToolTip.text: qsTr("Edit Station") ToolTip.text: qsTr("Edit Station")
onClicked: { onClicked: {
@@ -200,10 +222,13 @@ Rectangle {
} }
} }
// Favorite Button
Button { Button {
Layout.preferredWidth: compactMode ? 34 : 40 Layout.preferredWidth: 32
Layout.preferredHeight: 40 Layout.preferredHeight: 32
text: root.modelData.isFavorite ? "★" : "☆" text: root.modelData.isFavorite ? "★" : "☆"
flat: true
font.pixelSize: 14
onClicked: { onClicked: {
if (!app.backend) return if (!app.backend) return
app.backend.toggleFavoriteById(root.modelData.uuid, root.modelData.urlResolved) app.backend.toggleFavoriteById(root.modelData.uuid, root.modelData.urlResolved)
@@ -211,10 +236,13 @@ Rectangle {
} }
} }
// Play/Pause Button
Button { Button {
Layout.preferredWidth: compactMode ? 34 : 40 Layout.preferredWidth: 32
Layout.preferredHeight: 40 Layout.preferredHeight: 32
text: (root.isCurrent && app.backend && app.backend.player && app.backend.player.playing) ? "⏸" : "▶" text: (root.isCurrent && app.backend && app.backend.player && app.backend.player.playing) ? "⏸" : "▶"
flat: true
font.pixelSize: 13
onClicked: { onClicked: {
if (!app.backend) return if (!app.backend) return
if (root.isCurrent) { if (root.isCurrent) {

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 { ListView {
id: stationList id: stationList
width: stationScrollView.availableWidth width: stationScrollView.availableWidth
spacing: 8 spacing: 0
model: app.activeModel() model: app.activeModel()
visible: count > 0 visible: count > 0

View File

@@ -5,9 +5,12 @@ StationCard 1.0 StationCard.qml
WorldCategoryHeader 1.0 WorldCategoryHeader.qml WorldCategoryHeader 1.0 WorldCategoryHeader.qml
WorldCategories 1.0 WorldCategories.qml WorldCategories 1.0 WorldCategories.qml
PlayerBar 1.0 PlayerBar.qml PlayerBar 1.0 PlayerBar.qml
AboutDialog 1.0 AboutDialog.qml AboutPage 1.0 AboutPage.qml
AddStationDialog 1.0 AddStationDialog.qml AddStationDialog 1.0 AddStationDialog.qml
EditStationDialog 1.0 EditStationDialog.qml EditStationDialog 1.0 EditStationDialog.qml
LoadingOverlay 1.0 LoadingOverlay.qml LoadingOverlay 1.0 LoadingOverlay.qml
ToastPopup 1.0 ToastPopup.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 import QtQuick 2.15
QtObject { QtObject {
readonly property color bgA: "#0f141b" readonly property color bgA: "#1d1d22"
readonly property color bgB: "#131b25" readonly property color bgB: "#202027"
readonly property color panel: "#182433" readonly property color panel: "#22232a"
readonly property color card: "#1b2a3d" readonly property color sidebar: "#2b2c33"
readonly property color cardHover: "#223654" readonly property color card: "#2a2b31"
readonly property color cardBorder: "#2d4566" readonly property color cardHover: "#33343b"
readonly property color accent: "#2bb0ff" readonly property color cardBorder: "#3a3b43"
readonly property color textMain: "#eaf1fb" readonly property color accent: "#d7d7df"
readonly property color textMuted: "#9eb1c9" readonly property color playingAccent: "#0a84ff"
readonly property color textMain: "#f0f0f4"
readonly property color textMuted: "#a3a4ad"
readonly property color warn: "#ff8b8b" readonly property color warn: "#ff8b8b"
readonly property var worldTags: [ readonly property var worldTags: [

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff