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>

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,10 +274,32 @@ ApplicationWindow {
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 {
anchors.fill: parent
anchors.margins: 10
spacing: 10
spacing: 0
WorldCategoryHeader {
Layout.fillWidth: true
@@ -214,10 +324,25 @@ ApplicationWindow {
}
}
PlayerBar {
Layout.fillWidth: true
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
}
}
}
LoadingOverlay {
@@ -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,19 +69,15 @@ Rectangle {
&& app.backend.currentStation.favicon.startsWith("https://")) {
return 8
}
return 16
return 10
}
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 6
RowLayout {
Layout.fillWidth: true
spacing: 8
Layout.alignment: Qt.AlignVCenter
spacing: 3
Label {
Layout.fillWidth: true
@@ -78,71 +85,30 @@ Rectangle {
? (app.backend.player.currentStationName || qsTr("No station selected"))
: qsTr("No station selected")
color: BearTheme.textMain
font.pixelSize: 16
font.pixelSize: 14
font.bold: true
elide: Text.ElideRight
}
Rectangle {
visible: app.backend && app.backend.currentStation && app.backend.currentStation.codec
&& app.backend.currentStation.codec !== "unknown"
&& app.backend.currentStation.codec !== ""
height: 18
width: codecLabel.implicitWidth + 12
radius: 4
color: "transparent"
border.color: BearTheme.accent
border.width: 1
Label {
id: codecLabel
anchors.centerIn: parent
text: (app.backend && app.backend.currentStation && app.backend.currentStation.codec)
? app.backend.currentStation.codec.toUpperCase() : ""
color: BearTheme.accent
font.pixelSize: 9
font.bold: true
}
}
Rectangle {
visible: app.backend && app.backend.currentStation && app.backend.currentStation.bitrate > 0
height: 18
width: bitrateLabel.implicitWidth + 12
radius: 4
color: BearTheme.accent
border.color: BearTheme.accent
border.width: 1
Label {
id: bitrateLabel
anchors.centerIn: parent
text: (app.backend && app.backend.currentStation)
? app.backend.currentStation.bitrate + " kbps" : ""
color: "#ffffff"
font.pixelSize: 9
font.bold: true
}
}
}
Label {
Layout.fillWidth: true
text: app.backend && app.backend.player && app.backend.player.currentNowPlaying.length > 0
? (qsTr("Now playing: ") + app.backend.player.currentNowPlaying)
: qsTr("Now playing: No track info")
: qsTr("Live radio")
color: BearTheme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
}
}
RowLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
spacing: 8
Button {
text: "⏮"
Layout.preferredWidth: 44
Layout.preferredWidth: 42
Layout.preferredHeight: 30
enabled: app.backend ? app.backend.hasPreviousStation() : false
onClicked: {
if (app.backend) {
@@ -153,7 +119,8 @@ Rectangle {
Button {
text: app.backend && app.backend.player && app.backend.player.playing ? "⏸" : "▶"
Layout.preferredWidth: 44
Layout.preferredWidth: 42
Layout.preferredHeight: 30
onClicked: {
if (app.backend && app.backend.player) {
app.backend.player.togglePlayPause()
@@ -163,7 +130,8 @@ Rectangle {
Button {
text: "⏹"
Layout.preferredWidth: 44
Layout.preferredWidth: 42
Layout.preferredHeight: 30
onClicked: {
if (app.backend && app.backend.player) {
app.backend.player.stop()
@@ -173,7 +141,8 @@ Rectangle {
Button {
text: "⏭"
Layout.preferredWidth: 44
Layout.preferredWidth: 42
Layout.preferredHeight: 30
enabled: app.backend ? app.backend.hasNextStation() : false
onClicked: {
if (app.backend) {
@@ -185,7 +154,8 @@ Rectangle {
Button {
id: muteButton
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
ToolTip.visible: hovered
@@ -205,7 +175,7 @@ Rectangle {
}
Slider {
Layout.fillWidth: true
Layout.preferredWidth: app.width < 1180 ? 115 : 160
from: 0
to: 1
value: app.backend && app.backend.player ? app.backend.player.volume : 0.5
@@ -218,4 +188,3 @@ Rectangle {
}
}
}
}

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")
}
Label {
text: qsTr("Sort")
color: BearTheme.textMuted
font.bold: true
font.pixelSize: 11
width: 76
height: 30
verticalAlignment: Text.AlignVCenter
}
Button {
text: qsTr("WORLD")
highlighted: app.activeQuickFilter === "world"
onClicked: openWorld()
width: 78
height: 30
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 {
visible: root.compactMode
Layout.fillWidth: true

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,9 +41,14 @@ ColumnLayout {
}
}
// Right Search and Clear buttons
RowLayout {
spacing: 6
Button {
text: qsTr("Search")
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"
@@ -51,33 +57,19 @@ ColumnLayout {
}
Button {
text: qsTr("Sort A-Z")
text: root.app.width < 1180 ? "✕" : "✕ " + qsTr("Clear")
Layout.preferredWidth: root.app.width < 1180 ? 44 : 82
onClicked: {
if (app.backend && app.currentPage !== "favorites") {
app.backend.sortStations("name")
searchField.text = ""
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 {
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)
}
app.backend.searchStations(searchField.text)
}
}
Flow {
Button {
text: "✕ " + qsTr("Clear")
Layout.fillWidth: true
width: parent.width
spacing: 8
Button {
text: "A-Z"
onClicked: {
if (app.backend && app.currentPage !== "favorites") {
app.backend.sortStations("name")
}
}
}
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")
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) {

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

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
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: [

View File

@@ -24,6 +24,7 @@ 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)

File diff suppressed because it is too large Load Diff

View File

@@ -1,58 +1,175 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<?xml version='1.0' encoding='utf-8'?>
<TS version="2.1" language="ru_RU">
<context>
<name>AboutDialog</name>
<message>
<location filename="../src/qml/components/AboutDialog.qml" line="24"/>
<source>Version</source>
<translation type="vanished">Версия</translation>
</message>
<message>
<source>build</source>
<translation type="vanished">сборка</translation>
</message>
<message>
<source>BearWave</source>
<translation type="vanished">BearWave</translation>
</message>
<message>
<source>Internet Radio Player for KDE</source>
<translation type="vanished">Интернет радиоплеер для KDE</translation>
</message>
<message>
<source>Author: Sebastian Palencsár</source>
<translation type="vanished">Автор: Sebastian Palencsár</translation>
</message>
<message>
<source>Open website</source>
<translation type="vanished">Перейти на веб-сайт</translation>
</message>
<message>
<source>Open GitHub</source>
<translation type="vanished">Перейти на GitHub</translation>
</message>
<message>
<source>Open LinkedIn</source>
<translation type="vanished">Перейти на LinkedIn</translation>
</message>
<message>
<source>GNU GPLv3 License</source>
<translation type="vanished">Лицензия GNU GPLv3</translation>
</message>
<message>
<source>Copyright (c) 2026</source>
<translation type="vanished">Copyright (c) 2026</translation>
</message>
</context>
<context>
<name>AboutPage</name>
<message>
<location filename="../src/qml/components/AboutPage.qml" line="25" />
<source>Version</source>
<translation>Версия</translation>
</message>
<message>
<location filename="../src/qml/components/AboutDialog.qml" line="24"/>
<source>build</source>
<translation>сборка</translation>
<location filename="../src/qml/components/AboutPage.qml" line="25" />
<source>Build</source>
<translation>Сборка</translation>
</message>
<message>
<location filename="../src/qml/components/AboutDialog.qml" line="53"/>
<source>BearWave</source>
<translation>BearWave</translation>
<location filename="../src/qml/components/AboutPage.qml" line="30" />
<source>Qt 6</source>
<translation>Qt 6</translation>
</message>
<message>
<location filename="../src/qml/components/AboutDialog.qml" line="61"/>
<source>Internet Radio Player for KDE</source>
<translation>Интернет радиоплеер для KDE</translation>
<location filename="../src/qml/components/AboutPage.qml" line="31" />
<source>Application framework and UI runtime: Core, DBus, Network, Quick, Quick Controls 2, Widgets, Multimedia, and LinguistTools.</source>
<translation>Прикладной фреймворк и среда выполнения UI: Core, DBus, Network, Quick, Quick Controls 2, Widgets, Multimedia и LinguistTools.</translation>
</message>
<message>
<location filename="../src/qml/components/AboutDialog.qml" line="82"/>
<source>Author: Sebastian Palencsár</source>
<translation>Автор: Sebastian Palencsár</translation>
<location filename="../src/qml/components/AboutPage.qml" line="32" />
<source>Subject to the upstream licenses of the installed Qt distribution.</source>
<translation>Действуют лицензии upstream-версии установленного дистрибутива Qt.</translation>
</message>
<message>
<location filename="../src/qml/components/AboutDialog.qml" line="97"/>
<location filename="../src/qml/components/AboutPage.qml" line="35" />
<source>Qt Multimedia backend</source>
<translation>Мультимедийный бэкенд Qt</translation>
</message>
<message>
<location filename="../src/qml/components/AboutPage.qml" line="36" />
<source>Audio playback runs through Qt Multimedia and the backend available on the system, such as GStreamer or FFmpeg.</source>
<translation>Воспроизведение аудио выполняется через Qt Multimedia и доступный в системе бэкенд, например GStreamer или FFmpeg.</translation>
</message>
<message>
<location filename="../src/qml/components/AboutPage.qml" line="37" />
<source>Backend components are provided by the operating system or Qt build and retain their own licenses.</source>
<translation>Компоненты бэкенда предоставляются операционной системой или сборкой Qt и сохраняют свои собственные лицензии.</translation>
</message>
<message>
<location filename="../src/qml/components/AboutPage.qml" line="40" />
<source>Radio Browser API</source>
<translation>Radio Browser API</translation>
</message>
<message>
<location filename="../src/qml/components/AboutPage.qml" line="41" />
<source>Public station directory API for station data and stream discovery. BearWave does not bundle any Radio Browser server code.</source>
<translation>Публичный API каталога станций для данных о станциях и обнаружения потоков. BearWave не включает серверный код Radio Browser.</translation>
</message>
<message>
<location filename="../src/qml/components/AboutPage.qml" line="42" />
<source>External service or API; subject to the upstream project and service terms.</source>
<translation>Внешний сервис или API; действуют условия upstream-проекта и сервиса.</translation>
</message>
<message>
<location filename="../src/qml/components/AboutPage.qml" line="45" />
<source>freedesktop.org integrations</source>
<translation>Интеграции freedesktop.org</translation>
</message>
<message>
<location filename="../src/qml/components/AboutPage.qml" line="46" />
<source>D-Bus, MPRIS, desktop notifications, desktop entry, and AppStream metadata.</source>
<translation>D-Bus, MPRIS, уведомления рабочего стола, desktop-файл и метаданные AppStream.</translation>
</message>
<message>
<location filename="../src/qml/components/AboutPage.qml" line="47" />
<source>Uses standards and interfaces for desktop integration; no third-party implementation is bundled.</source>
<translation>Используются стандарты и интерфейсы для интеграции с рабочим столом; сторонние реализации не включаются.</translation>
</message>
<message>
<location filename="../src/qml/components/AboutPage.qml" line="95" />
<source>Internet radio player for KDE</source>
<translation>Интернет-радиоплеер для KDE</translation>
</message>
<message>
<location filename="../src/qml/components/AboutPage.qml" line="126" />
<source>Author</source>
<translation>Автор</translation>
</message>
<message>
<location filename="../src/qml/components/AboutPage.qml" line="138" />
<source>Sebastian Palencsár</source>
<translation>Себастьян Паленчар</translation>
</message>
<message>
<location filename="../src/qml/components/AboutPage.qml" line="150" />
<source>Open website</source>
<translation>Перейти на веб-сайт</translation>
</message>
<message>
<location filename="../src/qml/components/AboutDialog.qml" line="121"/>
<location filename="../src/qml/components/AboutPage.qml" line="168" />
<source>Open GitHub</source>
<translation>Перейти на GitHub</translation>
</message>
<message>
<location filename="../src/qml/components/AboutDialog.qml" line="145"/>
<location filename="../src/qml/components/AboutPage.qml" line="186" />
<source>Open LinkedIn</source>
<translation>Перейти на LinkedIn</translation>
</message>
<message>
<location filename="../src/qml/components/AboutDialog.qml" line="175"/>
<location filename="../src/qml/components/AboutPage.qml" line="216" />
<source>Technologies and third-party components</source>
<translation>Технологии и сторонние компоненты</translation>
</message>
<message>
<location filename="../src/qml/components/AboutPage.qml" line="224" />
<source>BearWave uses the following frameworks, services, and desktop standards. These components are not relicensed by BearWave.</source>
<translation>BearWave использует следующие фреймворки, сервисы и стандарты рабочего стола. Эти компоненты не перелицензируются BearWave.</translation>
</message>
<message>
<location filename="../src/qml/components/AboutPage.qml" line="298" />
<source>GNU GPLv3 License</source>
<translation>Лицензия GNU GPLv3</translation>
</message>
<message>
<location filename="../src/qml/components/AboutDialog.qml" line="180"/>
<location filename="../src/qml/components/AboutPage.qml" line="305" />
<source>Copyright (c) 2026</source>
<translation>Copyright (c) 2026</translation>
</message>
<message>
<location filename="../src/qml/components/AboutPage.qml" line="334" />
<source>License text could not be loaded.</source>
<translation>Не удалось загрузить текст лицензии.</translation>
</message>
</context>
<context>
<name>AddStationDialog</name>
@@ -85,62 +202,62 @@
<context>
<name>BearTheme</name>
<message>
<location filename="../src/qml/theme/BearTheme.qml" line="20"/>
<location filename="../src/qml/theme/BearTheme.qml" line="22" />
<source>Pop</source>
<translation>Поп</translation>
</message>
<message>
<location filename="../src/qml/theme/BearTheme.qml" line="21"/>
<location filename="../src/qml/theme/BearTheme.qml" line="23" />
<source>Rock</source>
<translation>Рок</translation>
</message>
<message>
<location filename="../src/qml/theme/BearTheme.qml" line="22"/>
<location filename="../src/qml/theme/BearTheme.qml" line="24" />
<source>Electronic</source>
<translation>Электронная</translation>
</message>
<message>
<location filename="../src/qml/theme/BearTheme.qml" line="23"/>
<location filename="../src/qml/theme/BearTheme.qml" line="25" />
<source>Classical</source>
<translation>Классическая</translation>
</message>
<message>
<location filename="../src/qml/theme/BearTheme.qml" line="24"/>
<location filename="../src/qml/theme/BearTheme.qml" line="26" />
<source>Jazz</source>
<translation>Джаз</translation>
</message>
<message>
<location filename="../src/qml/theme/BearTheme.qml" line="25"/>
<location filename="../src/qml/theme/BearTheme.qml" line="27" />
<source>Metal</source>
<translation>Метал</translation>
</message>
<message>
<location filename="../src/qml/theme/BearTheme.qml" line="26"/>
<location filename="../src/qml/theme/BearTheme.qml" line="28" />
<source>Hip Hop</source>
<translation>Хип-хоп</translation>
</message>
<message>
<location filename="../src/qml/theme/BearTheme.qml" line="27"/>
<location filename="../src/qml/theme/BearTheme.qml" line="29" />
<source>Chillout</source>
<translation>Успокаивающая</translation>
</message>
<message>
<location filename="../src/qml/theme/BearTheme.qml" line="28"/>
<location filename="../src/qml/theme/BearTheme.qml" line="30" />
<source>News / Talk</source>
<translation>Новости / Разговоры</translation>
</message>
<message>
<location filename="../src/qml/theme/BearTheme.qml" line="29"/>
<location filename="../src/qml/theme/BearTheme.qml" line="31" />
<source>Soundtracks</source>
<translation>Саундтреки</translation>
</message>
<message>
<location filename="../src/qml/theme/BearTheme.qml" line="30"/>
<location filename="../src/qml/theme/BearTheme.qml" line="32" />
<source>Ambient</source>
<translation>Атмосфера</translation>
</message>
<message>
<location filename="../src/qml/theme/BearTheme.qml" line="31"/>
<location filename="../src/qml/theme/BearTheme.qml" line="33" />
<source>Blues / Soul</source>
<translation>Блюз / Соул</translation>
</message>
@@ -181,50 +298,42 @@
<context>
<name>HeaderNavigation</name>
<message>
<location filename="../src/qml/components/HeaderNavigation.qml" line="74"/>
<location filename="../src/qml/components/HeaderNavigation.qml" line="175"/>
<location filename="../src/qml/components/HeaderNavigation.qml" line="68" />
<source>Top</source>
<translation>Топ</translation>
</message>
<message>
<location filename="../src/qml/components/HeaderNavigation.qml" line="80"/>
<location filename="../src/qml/components/HeaderNavigation.qml" line="181"/>
<location filename="../src/qml/components/HeaderNavigation.qml" line="74" />
<source>DE</source>
<translation>DE</translation>
</message>
<message>
<location filename="../src/qml/components/HeaderNavigation.qml" line="86"/>
<location filename="../src/qml/components/HeaderNavigation.qml" line="187"/>
<location filename="../src/qml/components/HeaderNavigation.qml" line="80" />
<source>NL</source>
<translation>NL</translation>
</message>
<message>
<location filename="../src/qml/components/HeaderNavigation.qml" line="92"/>
<location filename="../src/qml/components/HeaderNavigation.qml" line="193"/>
<location filename="../src/qml/components/HeaderNavigation.qml" line="86" />
<source>Favorites</source>
<translation>Избранные</translation>
</message>
<message>
<location filename="../src/qml/components/HeaderNavigation.qml" line="98"/>
<location filename="../src/qml/components/HeaderNavigation.qml" line="199"/>
<location filename="../src/qml/components/HeaderNavigation.qml" line="92" />
<source>History</source>
<translation>История</translation>
</message>
<message>
<location filename="../src/qml/components/HeaderNavigation.qml" line="106"/>
<source>Manual +</source>
<translation>Вручную +</translation>
<translation type="vanished">Вручную +</translation>
</message>
<message>
<location filename="../src/qml/components/HeaderNavigation.qml" line="111"/>
<location filename="../src/qml/components/HeaderNavigation.qml" line="154"/>
<location filename="../src/qml/components/HeaderNavigation.qml" line="46" />
<source>About</source>
<translation>О BearWave</translation>
</message>
<message>
<location filename="../src/qml/components/HeaderNavigation.qml" line="117"/>
<source>Resume</source>
<translation>Продолжить</translation>
<translation type="vanished">Продолжить</translation>
</message>
</context>
<context>
@@ -243,12 +352,12 @@
<translation>BearWave</translation>
</message>
<message>
<location filename="../src/qml/Main.qml" line="168"/>
<location filename="../src/qml/Main.qml" line="245" />
<source>Error: </source>
<translation>Ошибка: </translation>
</message>
<message>
<location filename="../src/qml/Main.qml" line="59"/>
<location filename="../src/qml/Main.qml" line="103" />
<source>Top Global</source>
<translation>Всемирный топ</translation>
</message>
@@ -256,36 +365,39 @@
<context>
<name>NotificationManager</name>
<message>
<location filename="../src/notificationmanager.cpp" line="214"/>
<source>Station: %1</source>
<translation>Станция: %1</translation>
<translation type="vanished">Станция: %1</translation>
</message>
</context>
<context>
<name>PlayerBar</name>
<message>
<location filename="../src/qml/components/PlayerBar.qml" line="78"/>
<location filename="../src/qml/components/PlayerBar.qml" line="79"/>
<location filename="../src/qml/components/PlayerBar.qml" line="85" />
<location filename="../src/qml/components/PlayerBar.qml" line="86" />
<source>No station selected</source>
<translation>Нет выбранной станции</translation>
</message>
<message>
<location filename="../src/qml/components/PlayerBar.qml" line="132"/>
<location filename="../src/qml/components/PlayerBar.qml" line="96" />
<source>Now playing: </source>
<translation>Сейчас играет: </translation>
</message>
<message>
<location filename="../src/qml/components/PlayerBar.qml" line="133"/>
<source>Now playing: No track info</source>
<translation>Сейчас играет: нет информации о треке</translation>
<location filename="../src/qml/components/PlayerBar.qml" line="97" />
<source>Live radio</source>
<translation>Прямой эфир</translation>
</message>
<message>
<location filename="../src/qml/components/PlayerBar.qml" line="193"/>
<source>Now playing: No track info</source>
<translation type="vanished">Сейчас играет: нет информации о треке</translation>
</message>
<message>
<location filename="../src/qml/components/PlayerBar.qml" line="163" />
<source>Mute</source>
<translation>Без звука</translation>
</message>
<message>
<location filename="../src/qml/components/PlayerBar.qml" line="193"/>
<location filename="../src/qml/components/PlayerBar.qml" line="163" />
<source>Unmute</source>
<translation>Со звуком</translation>
</message>
@@ -293,56 +405,82 @@
<context>
<name>QuickFilters</name>
<message>
<location filename="../src/qml/components/QuickFilters.qml" line="49"/>
<location filename="../src/qml/components/QuickFilters.qml" line="120"/>
<location filename="../src/qml/components/QuickFilters.qml" line="173" />
<source>Genre:</source>
<translation>Жанр:</translation>
</message>
<message>
<location filename="../src/qml/components/QuickFilters.qml" line="56"/>
<location filename="../src/qml/components/QuickFilters.qml" line="131"/>
<location filename="../src/qml/components/QuickFilters.qml" line="65" />
<location filename="../src/qml/components/QuickFilters.qml" line="184" />
<source>Rock</source>
<translation>Рок</translation>
</message>
<message>
<location filename="../src/qml/components/QuickFilters.qml" line="62"/>
<location filename="../src/qml/components/QuickFilters.qml" line="137"/>
<location filename="../src/qml/components/QuickFilters.qml" line="73" />
<location filename="../src/qml/components/QuickFilters.qml" line="190" />
<source>News</source>
<translation>Новости</translation>
</message>
<message>
<location filename="../src/qml/components/QuickFilters.qml" line="68"/>
<location filename="../src/qml/components/QuickFilters.qml" line="143"/>
<location filename="../src/qml/components/QuickFilters.qml" line="81" />
<location filename="../src/qml/components/QuickFilters.qml" line="196" />
<source>Jazz</source>
<translation>Джаз</translation>
</message>
<message>
<location filename="../src/qml/components/QuickFilters.qml" line="76"/>
<location filename="../src/qml/components/QuickFilters.qml" line="155"/>
<location filename="../src/qml/components/QuickFilters.qml" line="104" />
<source>GB</source>
<translation>GB</translation>
</message>
<message>
<location filename="../src/qml/components/QuickFilters.qml" line="130" />
<source>Name</source>
<translation>Название</translation>
</message>
<message>
<location filename="../src/qml/components/QuickFilters.qml" line="141" />
<source>Bitrate</source>
<translation>Битрейт</translation>
</message>
<message>
<location filename="../src/qml/components/QuickFilters.qml" line="208" />
<source>Country:</source>
<translation>Страна:</translation>
</message>
<message>
<location filename="../src/qml/components/QuickFilters.qml" line="83"/>
<location filename="../src/qml/components/QuickFilters.qml" line="166"/>
<location filename="../src/qml/components/QuickFilters.qml" line="96" />
<location filename="../src/qml/components/QuickFilters.qml" line="219" />
<source>US</source>
<translation>US</translation>
</message>
<message>
<location filename="../src/qml/components/QuickFilters.qml" line="89"/>
<location filename="../src/qml/components/QuickFilters.qml" line="172"/>
<location filename="../src/qml/components/QuickFilters.qml" line="53" />
<source>Quick access</source>
<translation>Быстрый доступ</translation>
</message>
<message>
<location filename="../src/qml/components/QuickFilters.qml" line="118" />
<source>Sort</source>
<translation>Сортировка</translation>
</message>
<message>
<location filename="../src/qml/components/QuickFilters.qml" line="152" />
<source>Votes</source>
<translation>Голоса</translation>
</message>
<message>
<location filename="../src/qml/components/QuickFilters.qml" line="225" />
<source>UK</source>
<translation>UK</translation>
</message>
<message>
<location filename="../src/qml/components/QuickFilters.qml" line="95"/>
<location filename="../src/qml/components/QuickFilters.qml" line="178"/>
<location filename="../src/qml/components/QuickFilters.qml" line="112" />
<location filename="../src/qml/components/QuickFilters.qml" line="231" />
<source>FR</source>
<translation>FR</translation>
</message>
<message>
<location filename="../src/qml/components/QuickFilters.qml" line="101"/>
<location filename="../src/qml/components/QuickFilters.qml" line="184"/>
<location filename="../src/qml/components/QuickFilters.qml" line="237" />
<source>WORLD</source>
<translation>Мир</translation>
</message>
@@ -350,89 +488,260 @@
<context>
<name>RadioBackend</name>
<message>
<location filename="../src/radiobackend.cpp" line="424"/>
<location filename="../src/radiobackend.cpp" line="868"/>
<source>Stream URL must use http:// or https://</source>
<translation>URL-адрес потока должен использовать http:// или https://</translation>
<translation type="vanished">URL-адрес потока должен использовать http:// или https://</translation>
</message>
<message>
<location filename="../src/radiobackend.cpp" line="433"/>
<location filename="../src/radiobackend.cpp" line="879"/>
<source>Manual</source>
<translation>Справка</translation>
<translation type="vanished">Справка</translation>
</message>
<message>
<location filename="../src/radiobackend.cpp" line="540"/>
<location filename="../src/radiobackend.cpp" line="564"/>
<location filename="../src/radiobackend.cpp" line="803"/>
<source>Last played</source>
<translation>Последнее</translation>
<translation type="vanished">Последнее</translation>
</message>
</context>
<context>
<name>RadioBrowser</name>
<message>
<location filename="../src/radiobrowser.cpp" line="157"/>
<source>Network error</source>
<translation>Ошибка сети</translation>
<translation type="vanished">Ошибка сети</translation>
</message>
</context>
<context>
<name>SearchToolbar</name>
<message>
<location filename="../src/qml/components/SearchToolbar.qml" line="29"/>
<location filename="../src/qml/components/SearchToolbar.qml" line="89"/>
<location filename="../src/qml/components/SearchToolbar.qml" line="30" />
<source>Search stations (name, genre, country)...</source>
<translation>Поиск станций (название, жанр, страна)...</translation>
</message>
<message>
<location filename="../src/qml/components/SearchToolbar.qml" line="60" />
<location filename="../src/qml/components/SearchToolbar.qml" line="115" />
<source>Clear</source>
<translation>Очистить</translation>
</message>
<message>
<location filename="../src/qml/components/SearchToolbar.qml" line="81" />
<source>Search stations (name, genre, country)</source>
<translation>Поиск станций (название, жанр, страна)</translation>
</message>
<message>
<location filename="../src/qml/components/SearchToolbar.qml" line="44"/>
<location filename="../src/qml/components/SearchToolbar.qml" line="114"/>
<location filename="../src/qml/components/SearchToolbar.qml" line="49" />
<location filename="../src/qml/components/SearchToolbar.qml" line="104" />
<source>Search</source>
<translation>Поиск</translation>
</message>
<message>
<location filename="../src/qml/components/SearchToolbar.qml" line="54"/>
<source>Sort A-Z</source>
<translation>Сорт. А-Я</translation>
<translation type="vanished">Сорт. А-Я</translation>
</message>
<message>
<location filename="../src/qml/components/SearchToolbar.qml" line="63"/>
<source>Sort Bitrate</source>
<translation>Сорт. битрейт</translation>
<translation type="vanished">Сорт. битрейт</translation>
</message>
<message>
<location filename="../src/qml/components/SearchToolbar.qml" line="72"/>
<source>Sort Votes</source>
<translation>Сорт. голоса</translation>
<translation type="vanished">Сорт. голоса</translation>
</message>
<message>
<location filename="../src/qml/components/SearchToolbar.qml" line="160"/>
<source>Tip: you are not limited to DE/NL. Search worldwide by country, genre, or station name.</source>
<translation>Совет: вы не ограничены DE/NL. Ищите по всему миру, по стране, жанру или названию станции.</translation>
<translation type="vanished">Совет: вы не ограничены DE/NL. Ищите по всему миру, по стране, жанру или названию станции.</translation>
</message>
</context>
<context>
<name>SidebarNavigation</name>
<message>
<location filename="../src/qml/components/SidebarNavigation.qml" line="52" />
<source>Stations</source>
<translation>Станции</translation>
</message>
<message>
<location filename="../src/qml/components/SidebarNavigation.qml" line="64" />
<source>Top Global</source>
<translation>Всемирный топ</translation>
</message>
<message>
<location filename="../src/qml/components/SidebarNavigation.qml" line="71" />
<source>Germany</source>
<translation>Германия</translation>
</message>
<message>
<location filename="../src/qml/components/SidebarNavigation.qml" line="78" />
<source>Netherlands</source>
<translation>Нидерланды</translation>
</message>
<message>
<location filename="../src/qml/components/SidebarNavigation.qml" line="93" />
<source>Library</source>
<translation>Библиотека</translation>
</message>
<message>
<location filename="../src/qml/components/SidebarNavigation.qml" line="106" />
<source>Favorites</source>
<translation>Избранные</translation>
</message>
<message>
<location filename="../src/qml/components/SidebarNavigation.qml" line="113" />
<source>History</source>
<translation>История</translation>
</message>
<message>
<location filename="../src/qml/components/SidebarNavigation.qml" line="85" />
<source>Worldwide</source>
<translation>Весь мир</translation>
</message>
<message>
<source>Search</source>
<translation type="obsolete">Поиск</translation>
</message>
<message>
<location filename="../src/qml/components/SidebarNavigation.qml" line="127" />
<source>Add Station</source>
<translation>Добавить станцию</translation>
</message>
<message>
<location filename="../src/qml/components/SidebarNavigation.qml" line="134" />
<source>About BearWave</source>
<translation>О BearWave</translation>
</message>
<message>
<location filename="../src/qml/components/SidebarNavigation.qml" line="142" />
<source>Resume</source>
<translation>Продолжить</translation>
</message>
</context>
<context>
<name>StationCard</name>
<message>
<location filename="../src/qml/components/StationCard.qml" line="183"/>
<location filename="../src/qml/components/StationCard.qml" line="49" />
<source>votes</source>
<translation>голосов</translation>
</message>
<message>
<location filename="../src/qml/components/StationCard.qml" line="51" />
<source>Stream</source>
<translation>Поток</translation>
</message>
<message>
<location filename="../src/qml/components/StationCard.qml" line="196"/>
<location filename="../src/qml/components/StationCard.qml" line="218" />
<source>Edit Station</source>
<translation>Редактировать станцию</translation>
</message>
<message>
<location filename="../src/qml/components/StationCard.qml" line="210"/>
<location filename="../src/qml/components/StationCard.qml" line="235" />
<source>Removed from favorites</source>
<translation>Убрать из избранного</translation>
<translation>Удалено из избранного</translation>
</message>
<message>
<location filename="../src/qml/components/StationCard.qml" line="210"/>
<location filename="../src/qml/components/StationCard.qml" line="235" />
<source>Added to favorites</source>
<translation>Добавить в избранное</translation>
<translation>Добавлено в избранное</translation>
</message>
</context>
<context>
<name>StationDetailsPanel</name>
<message>
<location filename="../src/qml/components/StationDetailsPanel.qml" line="45" />
<source>Stream URL copied!</source>
<translation>URL потока скопирован!</translation>
</message>
<message>
<location filename="../src/qml/components/StationDetailsPanel.qml" line="188" />
<source>Details</source>
<translation>Подробности</translation>
</message>
<message>
<location filename="../src/qml/components/StationDetailsPanel.qml" line="137" />
<source>Pause</source>
<translation>Пауза</translation>
</message>
<message>
<location filename="../src/qml/components/StationDetailsPanel.qml" line="137" />
<source>Play</source>
<translation>Слушать</translation>
</message>
<message>
<location filename="../src/qml/components/StationDetailsPanel.qml" line="154" />
<source>Favorite</source>
<translation>Избранное</translation>
</message>
<message>
<location filename="../src/qml/components/StationDetailsPanel.qml" line="154" />
<source>Add</source>
<translation>Добавить</translation>
</message>
<message>
<location filename="../src/qml/components/StationDetailsPanel.qml" line="161" />
<source>Removed from favorites</source>
<translation>Удалено из избранного</translation>
</message>
<message>
<location filename="../src/qml/components/StationDetailsPanel.qml" line="161" />
<source>Added to favorites</source>
<translation>Добавлено в избранное</translation>
</message>
<message>
<location filename="../src/qml/components/StationDetailsPanel.qml" line="166" />
<source>Groups</source>
<translation>Группы</translation>
</message>
<message>
<location filename="../src/qml/components/StationDetailsPanel.qml" line="170" />
<source>Groups coming soon</source>
<translation>Группы скоро появятся</translation>
</message>
<message>
<location filename="../src/qml/components/StationDetailsPanel.qml" line="251" />
<source>Tags</source>
<translation>Теги</translation>
</message>
<message>
<location filename="../src/qml/components/StationDetailsPanel.qml" line="298" />
<source>Links</source>
<translation>Ссылки</translation>
</message>
<message>
<location filename="../src/qml/components/StationDetailsPanel.qml" line="200" />
<source>Codec:</source>
<translation>Кодек:</translation>
</message>
<message>
<location filename="../src/qml/components/StationDetailsPanel.qml" line="205" />
<location filename="../src/qml/components/StationDetailsPanel.qml" line="217" />
<source>Unknown</source>
<translation>Неизвестно</translation>
</message>
<message>
<location filename="../src/qml/components/StationDetailsPanel.qml" line="212" />
<source>Bitrate:</source>
<translation>Битрейт:</translation>
</message>
<message>
<location filename="../src/qml/components/StationDetailsPanel.qml" line="224" />
<source>Votes:</source>
<translation>Голоса:</translation>
</message>
<message>
<location filename="../src/qml/components/StationDetailsPanel.qml" line="305" />
<source>Open homepage</source>
<translation>Открыть сайт</translation>
</message>
<message>
<location filename="../src/qml/components/StationDetailsPanel.qml" line="316" />
<source>Copy stream URL</source>
<translation>Копировать URL потока</translation>
</message>
<message>
<location filename="../src/qml/components/StationDetailsPanel.qml" line="344" />
<source>No station selected</source>
<translation>Нет выбранной станции</translation>
</message>
<message>
<location filename="../src/qml/components/StationDetailsPanel.qml" line="352" />
<source>Select a station from the list
to view details.</source>
<translation>Выберите станцию из списка,
чтобы просмотреть подробности.</translation>
</message>
</context>
<context>
@@ -461,34 +770,28 @@
<context>
<name>SystemTrayManager</name>
<message>
<location filename="../src/systemtraymanager.cpp" line="50"/>
<source>BearWave</source>
<translation>BearWave</translation>
<translation type="vanished">BearWave</translation>
</message>
<message>
<location filename="../src/systemtraymanager.cpp" line="89"/>
<source>Pause</source>
<translation>Пауза</translation>
<translation type="vanished">Пауза</translation>
</message>
<message>
<location filename="../src/systemtraymanager.cpp" line="89"/>
<source>Play</source>
<translation>Слушать</translation>
<translation type="vanished">Слушать</translation>
</message>
<message>
<location filename="../src/systemtraymanager.cpp" line="98"/>
<source>Hide</source>
<translation>Скрыть</translation>
<translation type="vanished">Скрыть</translation>
</message>
<message>
<location filename="../src/systemtraymanager.cpp" line="98"/>
<source>Show</source>
<translation>Показать</translation>
<translation type="vanished">Показать</translation>
</message>
<message>
<location filename="../src/systemtraymanager.cpp" line="62"/>
<source>Quit</source>
<translation>Выйти</translation>
<translation type="vanished">Выйти</translation>
</message>
</context>
<context>