Prepare BearWave for public repository

This commit is contained in:
Sebastian Palencsar
2026-05-20 10:47:56 +02:00
commit 51992dd437
38 changed files with 3842 additions and 0 deletions

36
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,36 @@
---
name: Bug report
about: Report a crash, regression, playback problem, or packaging issue
title: "[Bug] "
labels: bug
assignees: ""
---
## Summary
Describe the problem clearly.
## Steps to Reproduce
1.
2.
3.
## Expected Behavior
What should have happened?
## Actual Behavior
What happened instead?
## Environment
- Distro:
- Plasma version:
- Qt / KF version if known:
- Install method: source / PKGBUILD / local install
## Logs or Screenshots
Add terminal output, screenshots, or error dialogs if available.

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: true

View File

@@ -0,0 +1,23 @@
---
name: Feature request
about: Suggest a focused improvement for BearWave
title: "[Feature] "
labels: enhancement
assignees: ""
---
## Goal
What problem should this solve for the user?
## Proposed Change
Describe the feature or improvement.
## Why It Fits BearWave
Explain why this belongs in a KDE-focused, dependency-conservative radio app.
## Alternatives Considered
List simpler or existing alternatives if applicable.

40
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Build
on:
push:
branches:
- main
- master
pull_request:
jobs:
linux-build:
runs-on: ubuntu-24.04
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
cmake \
ninja-build \
pkg-config \
qt6-base-dev \
qt6-declarative-dev \
qt6-tools-dev-tools \
libkf6coreaddons-dev \
libkf6i18n-dev \
libkf6kirigami-dev \
phonon4qt6-dev
- name: Configure
run: cmake -S . -B build-ci -GNinja -DCMAKE_BUILD_TYPE=Release
- name: Build
run: cmake --build build-ci
- name: Lint QML
run: qmllint src/qml/Main.qml

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
## Local agent/contributor notes (not for GitHub)
AGENTS.md
## Build directories
build/
build-ci/
cmake-build-*/
## CMake artifacts
CMakeCache.txt
CMakeFiles/
cmake_install.cmake
compile_commands.json
CTestTestfile.cmake
install_manifest.txt
Makefile
## Qt autogen artifacts
*.autogen/
## Editor / IDE
.idea/
.vscode/
.clangd
*.swp
*.swo
## Runtime files
*.log
## Local caches / user state
.cache/
## Installed or generated desktop files
org.kde.bearwave.desktop
## OS noise
.DS_Store

35
CMakeLists.txt Normal file
View File

@@ -0,0 +1,35 @@
cmake_minimum_required(VERSION 3.16)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
project(bearwave VERSION 1.0.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
# Qt6
find_package(Qt6 REQUIRED COMPONENTS Core DBus Quick QuickControls2 Widgets)
# KDE Frameworks
find_package(KF6Kirigami REQUIRED)
find_package(KF6I18n REQUIRED)
find_package(KF6CoreAddons REQUIRED)
# Phonon (Qt6)
find_package(Phonon4Qt6 REQUIRED)
# Add src directory
add_subdirectory(src)
# Configure and install desktop file with correct binary path
configure_file(
${CMAKE_CURRENT_SOURCE_DIR}/org.kde.bearwave.desktop.in
${CMAKE_CURRENT_BINARY_DIR}/org.kde.bearwave.desktop
@ONLY
)
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/org.kde.bearwave.desktop DESTINATION share/applications)
# Install app icon
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/bearwave.png DESTINATION share/icons/hicolor/256x256/apps RENAME org.kde.bearwave.png)

68
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,68 @@
# Contributing to BearWave
BearWave is a KDE-focused desktop internet radio app built with C++/Qt 6, QML, KDE Frameworks, and Phonon.
The project favors:
- stability over feature churn
- minimal dependencies
- readable, conservative code
- Plasma-friendly desktop behavior
## Before You Contribute
- Check existing issues before opening a new one.
- Prefer focused pull requests over broad refactors.
- Keep behavior predictable for desktop users.
- Avoid adding dependencies unless there is a clear payoff.
## Local Setup
Build from the repository root:
```bash
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j"$(nproc)"
```
Optional local install:
```bash
cmake --install build --prefix "$HOME/.local"
```
If you changed QML:
```bash
qmllint src/qml/Main.qml
```
## Change Guidelines
- Follow the existing style in each file.
- Keep changes minimal and scoped.
- Do not block the UI thread.
- Keep network operations asynchronous.
- Preserve MPRIS behavior and user state compatibility.
- When changing resources, register them in `src/qml.qrc`.
## Manual Checks
Please smoke-test the area you changed when possible:
- app launches successfully
- station list loads
- search and filtering behave correctly
- play/pause/stop work
- favorites persist across restart
- resume restores last station and volume
- tray and MPRIS behavior still work
## Pull Requests
Useful pull requests usually include:
- a short description of the user-visible change
- any relevant screenshots for UI changes
- exact verification commands that were run
- known limitations or follow-up work

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sebastian Palencsar
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

30
PKGBUILD Normal file
View File

@@ -0,0 +1,30 @@
pkgname=bearwave-git
pkgver=1.0.0
pkgrel=1
pkgdesc="A KDE-focused desktop internet radio app"
arch=('x86_64')
url="https://github.com/bearwave/bearwave" # Replace with actual URL if different
license=('MIT')
depends=('qt6-base' 'qt6-declarative' 'kirigami' 'ki18n' 'kcoreaddons' 'phonon-qt6')
makedepends=('cmake' 'extra-cmake-modules' 'git')
provides=('bearwave')
conflicts=('bearwave')
source=("git+file://${PWD}")
md5sums=('SKIP')
pkgver() {
cd "${srcdir}/${pkgname%-git}"
printf "1.0.0.r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
}
build() {
cmake -B build -S "${pkgname%-git}" \
-DCMAKE_BUILD_TYPE='Release' \
-DCMAKE_INSTALL_PREFIX='/usr'
cmake --build build -j"$(nproc)"
}
package() {
DESTDIR="$pkgdir" cmake --install build
install -Dm644 "${pkgname%-git}/LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}

255
README.md Normal file
View File

@@ -0,0 +1,255 @@
# BearWave
BearWave is a desktop internet radio player for KDE Plasma, built with C++/Qt 6, QML, KDE Frameworks, and Phonon.
It is designed for fast station browsing, simple playback controls, and good integration into the Plasma desktop experience.
BearWave currently targets Linux desktop environments, with KDE Plasma as the primary focus.
## Highlights
- Internet radio via the Radio Browser API (with ultra-fast local JSON caching)
- Station pages for Top, Germany, Netherlands, and global/genre filters
- Real-time local search and filtering (name, genre, country)
- Sorting (name, bitrate, votes)
- Favorites with persistent local storage
- Manual station add
- Playback controls and metadata display (title/artist when available)
- Resume support for last station and volume
- MPRIS integration (Plasma media controls, media keys, media applets with cover art fallback)
- System Tray integration for seamless background playback
- About dialog with project links and embedded MIT license text
## Project Status
BearWave is usable and actively evolving, but still early-stage software.
Current priorities:
- stability in normal playback flows
- predictable KDE/Plasma integration
- conservative packaging and installation behavior
- keeping the codebase small and maintainable
## Tech Stack
- **Language:** C++
- **UI:** QML (`QtQuick`, `QtQuick.Controls`, `QtQuick.Layouts`)
- **Frameworks:** Qt 6, KDE Frameworks 6 (Kirigami where available)
- **Audio:** Phonon4Qt6 backend (e.g. VLC backend)
- **Networking:** `QNetworkAccessManager` against Radio Browser
- **Build system:** CMake
## Project Layout
```text
bearwave/
CMakeLists.txt
LICENSE
README.md
org.kde.bearwave.desktop.in
src/
main.cpp
radiobackend.*
radiobrowser.*
radiostation.*
bearplayer.*
mprisadaptor.*
bearwavecontroladaptor.*
qml/Main.qml
qml.qrc
```
## Runtime Requirements
- Linux desktop session (KDE Plasma recommended)
- Qt 6 runtime libraries
- Phonon4Qt6 + a Phonon backend (VLC backend recommended)
- Network access to Radio Browser instances
## Dependencies
### Arch Linux
```bash
sudo pacman -S cmake extra-cmake-modules qt6-base qt6-declarative qt6-quickcontrols2 \
kirigami phonon-qt6 phonon-qt6-vlc vlc
```
### KDE Neon / Ubuntu-based
```bash
sudo apt install cmake ninja-build qt6-base-dev qt6-declarative-dev qt6-tools-dev \
libkf6kirigami-dev libkf6i18n-dev libkf6coreaddons-dev phonon4qt6-dev
```
Note: exact package names can vary between distro releases.
## Build
From the repository root:
```bash
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j"$(nproc)"
```
If you want a clean rebuild:
```bash
rm -rf build
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j"$(nproc)"
```
## Arch Linux (PKGBUILD)
For a clean, system-wide installation on Arch Linux, use the included `PKGBUILD`:
```bash
makepkg -si
```
This will automatically resolve dependencies, compile BearWave, and install it via pacman (`bearwave-git`).
## Install (User-local)
```bash
cmake --install build --prefix "$HOME/.local"
update-desktop-database "$HOME/.local/share/applications"
kbuildsycoca6
```
This installs:
- Binary: `~/.local/bin/bearwave`
- Desktop file: `~/.local/share/applications/org.kde.bearwave.desktop`
- Icon: `~/.local/share/icons/hicolor/256x256/apps/org.kde.bearwave.png`
Note: the generated desktop file uses the install prefix chosen during `cmake --install`.
## Run
- From launcher/menu: search for **BearWave**
- Or terminal:
```bash
~/.local/bin/bearwave
```
## Usage Guide
### Basic Flow
1. Open a station page (Top/DE/NL/Favorites or quick filters)
2. Pick a station and press play
3. Add favorites for quick access
4. Use Resume to continue from last session
### Keyboard Shortcuts
- `Space`: Play/Pause
- `Ctrl+F`: Focus search field
### Sorting
Use the sort controls to reorder station lists by:
- Name (A-Z)
- Bitrate
- Votes
## Data and Persistence
BearWave stores user state under:
- Favorites: `~/.config/bearwave/favorites.json`
- Last station + volume: `~/.config/bearwave/state.json`
- API Cache (RadioBrowser): `~/.cache/bearwave/api_cache/`
If these files are removed, app state resets to defaults or performs a fresh API sync.
## Plasma Integration
BearWave exposes playback through MPRIS, so it works with:
- Plasma media applets/widgets
- Global media key handling
- External MPRIS-capable controllers
## Autostart (Optional)
Enable autostart:
```bash
mkdir -p "$HOME/.config/autostart"
cp "$HOME/.local/share/applications/org.kde.bearwave.desktop" "$HOME/.config/autostart/"
```
Disable autostart:
```bash
rm -f "$HOME/.config/autostart/org.kde.bearwave.desktop"
```
## Troubleshooting
### No audio playback
- Ensure `phonon-qt6` and `phonon-qt6-vlc` (or equivalent) are installed
- Ensure `vlc` is installed
- Test another station URL (some streams go offline)
### App icon/menu entry not updating
- Re-run:
```bash
update-desktop-database "$HOME/.local/share/applications"
kbuildsycoca6
```
- Log out/in if desktop cache is stale
### Station list empty or slow
- Verify internet connectivity
- Radio Browser endpoint may be rate-limited temporarily
- Try another station category/filter
## Contributing
Issues and focused pull requests are welcome.
Before opening a larger change, it is worth checking whether it matches the project direction:
- KDE-first desktop behavior
- no unnecessary dependencies
- small, maintainable changes
- stability before feature breadth
See [CONTRIBUTING.md](CONTRIBUTING.md) for local build and review expectations.
## Release Notes
For an initial public repository, the recommended install paths are:
- build from source
- user-local install via CMake
- Arch Linux via the included `PKGBUILD`
If broader distribution is added later, Flatpak or AppImage would be reasonable follow-ups.
## Development Notes
- Main UI: `src/qml/Main.qml`
- Backend orchestration: `src/radiobackend.cpp`
- Stream playback: `src/bearplayer.cpp`
- API layer: `src/radiobrowser.cpp`
- MPRIS adapter: `src/mprisadaptor.cpp`
For contributor/agent workflow and guardrails, see `AGENTS.md`.
## License
This project is licensed under the MIT License.
See `LICENSE` for full text.

BIN
bearwave.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

BIN
github.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

1
github.svg Executable file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path fill="#eaf1fb" d="M237.9 461.4C237.9 463.4 235.6 465 232.7 465C229.4 465.3 227.1 463.7 227.1 461.4C227.1 459.4 229.4 457.8 232.3 457.8C235.3 457.5 237.9 459.1 237.9 461.4zM206.8 456.9C206.1 458.9 208.1 461.2 211.1 461.8C213.7 462.8 216.7 461.8 217.3 459.8C217.9 457.8 216 455.5 213 454.6C210.4 453.9 207.5 454.9 206.8 456.9zM251 455.2C248.1 455.9 246.1 457.8 246.4 460.1C246.7 462.1 249.3 463.4 252.3 462.7C255.2 462 257.2 460.1 256.9 458.1C256.6 456.2 253.9 454.9 251 455.2zM316.8 72C178.1 72 72 177.3 72 316C72 426.9 141.8 521.8 241.5 555.2C254.3 557.5 258.8 549.6 258.8 543.1C258.8 536.9 258.5 502.7 258.5 481.7C258.5 481.7 188.5 496.7 173.8 451.9C173.8 451.9 162.4 422.8 146 415.3C146 415.3 123.1 399.6 147.6 399.9C147.6 399.9 172.5 401.9 186.2 425.7C208.1 464.3 244.8 453.2 259.1 446.6C261.4 430.6 267.9 419.5 275.1 412.9C219.2 406.7 162.8 398.6 162.8 302.4C162.8 274.9 170.4 261.1 186.4 243.5C183.8 237 175.3 210.2 189 175.6C209.9 169.1 258 202.6 258 202.6C278 197 299.5 194.1 320.8 194.1C342.1 194.1 363.6 197 383.6 202.6C383.6 202.6 431.7 169 452.6 175.6C466.3 210.3 457.8 237 455.2 243.5C471.2 261.2 481 275 481 302.4C481 398.9 422.1 406.6 366.2 412.9C375.4 420.8 383.2 435.8 383.2 459.3C383.2 493 382.9 534.7 382.9 542.9C382.9 549.4 387.5 557.3 400.2 555C500.2 521.8 568 426.9 568 316C568 177.3 455.5 72 316.8 72zM169.2 416.9C167.9 417.9 168.2 420.2 169.9 422.1C171.5 423.7 173.8 424.4 175.1 423.1C176.4 422.1 176.1 419.8 174.4 417.9C172.8 416.3 170.5 415.6 169.2 416.9zM158.4 408.8C157.7 410.1 158.7 411.7 160.7 412.7C162.3 413.7 164.3 413.4 165 412C165.7 410.7 164.7 409.1 162.7 408.1C160.7 407.5 159.1 407.8 158.4 408.8zM190.8 444.4C189.2 445.7 189.8 448.7 192.1 450.6C194.4 452.9 197.3 453.2 198.6 451.6C199.9 450.3 199.3 447.3 197.3 445.4C195.1 443.1 192.1 442.8 190.8 444.4zM179.4 429.7C177.8 430.7 177.8 433.3 179.4 435.6C181 437.9 183.7 438.9 185 437.9C186.6 436.6 186.6 434 185 431.7C183.6 429.4 181 428.4 179.4 429.7z"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
globe.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

1
globe.svg Executable file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path fill="#eaf1fb" d="M415.9 344L225 344C227.9 408.5 242.2 467.9 262.5 511.4C273.9 535.9 286.2 553.2 297.6 563.8C308.8 574.3 316.5 576 320.5 576C324.5 576 332.2 574.3 343.4 563.8C354.8 553.2 367.1 535.8 378.5 511.4C398.8 467.9 413.1 408.5 416 344zM224.9 296L415.8 296C413 231.5 398.7 172.1 378.4 128.6C367 104.2 354.7 86.8 343.3 76.2C332.1 65.7 324.4 64 320.4 64C316.4 64 308.7 65.7 297.5 76.2C286.1 86.8 273.8 104.2 262.4 128.6C242.1 172.1 227.8 231.5 224.9 296zM176.9 296C180.4 210.4 202.5 130.9 234.8 78.7C142.7 111.3 74.9 195.2 65.5 296L176.9 296zM65.5 344C74.9 444.8 142.7 528.7 234.8 561.3C202.5 509.1 180.4 429.6 176.9 344L65.5 344zM463.9 344C460.4 429.6 438.3 509.1 406 561.3C498.1 528.6 565.9 444.8 575.3 344L463.9 344zM575.3 296C565.9 195.2 498.1 111.3 406 78.7C438.3 130.9 460.4 210.4 463.9 296L575.3 296z"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
linkedin.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

1
linkedin.svg Executable file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path fill="#eaf1fb" d="M160 96C124.7 96 96 124.7 96 160L96 480C96 515.3 124.7 544 160 544L480 544C515.3 544 544 515.3 544 480L544 160C544 124.7 515.3 96 480 96L160 96zM165 266.2L231.5 266.2L231.5 480L165 480L165 266.2zM236.7 198.5C236.7 219.8 219.5 237 198.2 237C176.9 237 159.7 219.8 159.7 198.5C159.7 177.2 176.9 160 198.2 160C219.5 160 236.7 177.2 236.7 198.5zM413.9 480L413.9 376C413.9 351.2 413.4 319.3 379.4 319.3C344.8 319.3 339.5 346.3 339.5 374.2L339.5 480L273.1 480L273.1 266.2L336.8 266.2L336.8 295.4L337.7 295.4C346.6 278.6 368.3 260.9 400.6 260.9C467.8 260.9 480.3 305.2 480.3 362.8L480.3 480L413.9 480z"/></svg>

After

Width:  |  Height:  |  Size: 840 B

View File

@@ -0,0 +1,11 @@
[Desktop Entry]
Type=Application
Name=BearWave
Comment=Internet Radio Player for KDE
Exec=@CMAKE_INSTALL_PREFIX@/bin/bearwave
Icon=org.kde.bearwave
Terminal=false
Categories=Audio;Music;Player;KDE;
Keywords=radio;stream;internet;audio;music;
StartupNotify=true
StartupWMClass=org.kde.bearwave

View File

@@ -0,0 +1,106 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import org.kde.plasma.plasmoid 2.0
import org.kde.plasma.components as PlasmaComponents
import org.kde.plasma.plasma5support as Plasma5Support
PlasmoidItem {
id: root
property string sourceName: "org.mpris.MediaPlayer2.bearwave"
property bool available: mprisSource.data[sourceName] !== undefined
property string playbackStatus: available ? (mprisSource.data[sourceName]["PlaybackStatus"] || "Stopped") : "Stopped"
property var metadata: available ? (mprisSource.data[sourceName]["Metadata"] || ({})) : ({})
property string titleText: metadata["xesam:title"] || "BearWave"
property string artistText: (metadata["xesam:artist"] && metadata["xesam:artist"].length > 0) ? metadata["xesam:artist"][0] : ""
preferredRepresentation: compactRepresentation
function callMpris(methodName) {
if (!available) {
return
}
const service = mprisSource.serviceForSource(sourceName)
const operation = service.operationDescription(methodName)
if (operation === undefined) {
return
}
service.startOperationCall(operation)
}
compactRepresentation: Item {
implicitWidth: 220
implicitHeight: 28
PlasmaComponents.Button {
anchors.fill: parent
text: available
? (artistText.length > 0 ? (artistText + " - " + titleText) : titleText)
: "BearWave nicht aktiv"
onClicked: root.expanded = true
}
}
fullRepresentation: Item {
implicitWidth: 320
implicitHeight: 170
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 8
PlasmaComponents.Label {
Layout.fillWidth: true
text: available ? titleText : "BearWave nicht aktiv"
elide: Text.ElideRight
font.bold: true
}
PlasmaComponents.Label {
Layout.fillWidth: true
text: artistText.length > 0 ? artistText : (available ? "Live-Radio" : "Bitte BearWave starten")
elide: Text.ElideRight
opacity: 0.8
}
PlasmaComponents.Label {
Layout.fillWidth: true
text: "Status: " + playbackStatus
opacity: 0.8
}
RowLayout {
Layout.fillWidth: true
spacing: 6
PlasmaComponents.Button {
text: "⏮"
enabled: available
onClicked: callMpris("Previous")
}
PlasmaComponents.Button {
text: playbackStatus === "Playing" ? "⏸" : "▶"
enabled: available
onClicked: callMpris("PlayPause")
}
PlasmaComponents.Button {
text: "⏹"
enabled: available
onClicked: callMpris("Stop")
}
PlasmaComponents.Button {
text: "⏭"
enabled: available
onClicked: callMpris("Next")
}
}
}
}
Plasma5Support.DataSource {
id: mprisSource
engine: "mpris2"
connectedSources: [sourceName]
}
}

View File

@@ -0,0 +1,18 @@
{
"KPackageStructure": "Plasma/Applet",
"KPlugin": {
"Id": "org.kde.plasma.bearwave",
"Name": "BearWave Control",
"Description": "Panel controls and station lists for BearWave",
"Icon": "audio-x-generic",
"Version": "0.1.0",
"Category": "Multimedia",
"Authors": [
{
"Name": "BearWave"
}
],
"License": "GPL-3.0-or-later"
},
"X-Plasma-API-Minimum-Version": "6.0"
}

36
src/CMakeLists.txt Normal file
View File

@@ -0,0 +1,36 @@
# BearWave Source Files
# Core application
set(SOURCES
main.cpp
radiostation.cpp
radiobrowser.cpp
radiobackend.cpp
bearplayer.cpp
coverartfetcher.cpp
mprisadaptor.cpp
bearwavecontroladaptor.cpp
)
# Create executable
add_executable(bearwave ${SOURCES})
# Qt Resources
target_sources(bearwave PRIVATE qml.qrc)
# Link libraries
target_link_libraries(bearwave PRIVATE
Qt6::Core
Qt6::DBus
Qt6::Network
Qt6::Quick
Qt6::QuickControls2
Qt6::Widgets
KF6::Kirigami
KF6::I18n
KF6::CoreAddons
Phonon::phonon4qt6
)
# Install
install(TARGETS bearwave DESTINATION bin)

191
src/bearplayer.cpp Normal file
View File

@@ -0,0 +1,191 @@
#include "bearplayer.h"
#include <QUrl>
#include <QDebug>
namespace {
QString firstMetaValue(const QMultiMap<QString, QString> &meta, const QStringList &keys)
{
for (const QString &key : keys) {
if (meta.contains(key)) {
const QString value = meta.value(key).trimmed();
if (!value.isEmpty()) {
return value;
}
}
}
return QString();
}
}
BearPlayer::BearPlayer(QObject *parent)
: QObject(parent)
{
m_mediaObject = new Phonon::MediaObject(this);
m_audioOutput = new Phonon::AudioOutput(Phonon::MusicCategory, this);
m_coverArtFetcher = new CoverArtFetcher(this);
connect(m_coverArtFetcher, &CoverArtFetcher::coverUrlReady, this, &BearPlayer::onCoverUrlReady);
Phonon::createPath(m_mediaObject, m_audioOutput);
connect(m_mediaObject, &Phonon::MediaObject::stateChanged,
this, &BearPlayer::onStateChanged);
connect(m_mediaObject, &Phonon::MediaObject::metaDataChanged,
this, &BearPlayer::onMetaDataChanged);
m_retryTimer.setSingleShot(true);
m_retryTimer.setInterval(1200);
connect(&m_retryTimer, &QTimer::timeout, this, [this]() {
if (m_lastUrl.isEmpty() || m_playing || m_retryAttempts >= 2) {
return;
}
++m_retryAttempts;
m_mediaObject->setCurrentSource(QUrl(m_lastUrl));
m_mediaObject->play();
qDebug() << "Retry stream" << m_retryAttempts << m_lastName;
});
}
BearPlayer::~BearPlayer()
{
stop();
}
void BearPlayer::playUrl(const QString &url, const QString &name)
{
if (url.isEmpty()) {
return;
}
m_currentStationName = name;
m_lastName = name;
m_lastUrl = url;
m_retryAttempts = 0;
emit currentStationChanged(m_currentStationName);
clearTrackInfo();
m_mediaObject->setCurrentSource(QUrl(url));
m_mediaObject->play();
qDebug() << "Playing:" << name << url;
}
void BearPlayer::stop()
{
m_mediaObject->stop();
m_currentStationName.clear();
m_lastName.clear();
m_lastUrl.clear();
m_retryAttempts = 0;
emit currentStationChanged(QString());
clearTrackInfo();
}
void BearPlayer::togglePlayPause()
{
if (m_playing) {
m_mediaObject->pause();
} else {
m_mediaObject->play();
}
}
void BearPlayer::setVolume(qreal vol)
{
m_audioOutput->setVolume(qBound(0.0, vol, 1.0));
emit volumeChanged(m_audioOutput->volume());
}
void BearPlayer::onStateChanged(int state)
{
switch (state) {
case 2:
m_playing = true;
break;
default:
m_playing = false;
scheduleRetry();
break;
}
emit playingChanged(m_playing);
}
void BearPlayer::scheduleRetry()
{
if (m_lastUrl.isEmpty() || m_retryAttempts >= 2 || m_playing) {
return;
}
if (!m_retryTimer.isActive()) {
m_retryTimer.start();
}
}
void BearPlayer::onMetaDataChanged()
{
const QMultiMap<QString, QString> meta = m_mediaObject->metaData();
const QString artist = firstMetaValue(meta, {
QStringLiteral("ARTIST"),
QStringLiteral("artist"),
QStringLiteral("Artist")
});
QString title = firstMetaValue(meta, {
QStringLiteral("TITLE"),
QStringLiteral("title"),
QStringLiteral("Title")
});
if (title.isEmpty()) {
title = firstMetaValue(meta, {
QStringLiteral("icy-title"),
QStringLiteral("Icy-Title"),
QStringLiteral("StreamTitle")
});
}
if (m_currentTrackArtist == artist && m_currentTrackTitle == title) {
return;
}
m_currentTrackArtist = artist;
m_currentTrackTitle = title;
m_currentCoverArtUrl.clear();
m_coverArtFetcher->fetch(artist, title);
emit trackInfoChanged();
}
void BearPlayer::onCoverUrlReady(const QString &url)
{
if (m_currentCoverArtUrl != url) {
m_currentCoverArtUrl = url;
emit trackInfoChanged();
}
}
QString BearPlayer::currentNowPlaying() const
{
if (!m_currentTrackArtist.isEmpty() && !m_currentTrackTitle.isEmpty()) {
return m_currentTrackArtist + QStringLiteral(" - ") + m_currentTrackTitle;
}
if (!m_currentTrackTitle.isEmpty()) {
return m_currentTrackTitle;
}
if (!m_currentTrackArtist.isEmpty()) {
return m_currentTrackArtist;
}
return QString();
}
void BearPlayer::clearTrackInfo()
{
if (m_currentTrackArtist.isEmpty() && m_currentTrackTitle.isEmpty()) {
return;
}
m_currentTrackArtist.clear();
m_currentTrackTitle.clear();
m_currentCoverArtUrl.clear();
m_coverArtFetcher->fetch(QString(), QString());
emit trackInfoChanged();
}

69
src/bearplayer.h Normal file
View File

@@ -0,0 +1,69 @@
#ifndef BEARPLAYER_H
#define BEARPLAYER_H
#include <QObject>
#include <QTimer>
#include <phonon4qt6/phonon/MediaObject>
#include <phonon4qt6/phonon/AudioOutput>
#include "coverartfetcher.h"
class BearPlayer : public QObject
{
Q_OBJECT
Q_PROPERTY(bool playing READ playing NOTIFY playingChanged)
Q_PROPERTY(qreal volume READ volume WRITE setVolume NOTIFY volumeChanged)
Q_PROPERTY(QString currentStationName READ currentStationName NOTIFY currentStationChanged)
Q_PROPERTY(QString currentTrackTitle READ currentTrackTitle NOTIFY trackInfoChanged)
Q_PROPERTY(QString currentTrackArtist READ currentTrackArtist NOTIFY trackInfoChanged)
Q_PROPERTY(QString currentNowPlaying READ currentNowPlaying NOTIFY trackInfoChanged)
Q_PROPERTY(QString currentCoverArtUrl READ currentCoverArtUrl NOTIFY trackInfoChanged)
public:
explicit BearPlayer(QObject *parent = nullptr);
~BearPlayer();
bool playing() const { return m_playing; }
qreal volume() const { return m_audioOutput->volume(); }
QString currentStationName() const { return m_currentStationName; }
QString currentTrackTitle() const { return m_currentTrackTitle; }
QString currentTrackArtist() const { return m_currentTrackArtist; }
QString currentCoverArtUrl() const { return m_currentCoverArtUrl; }
QString currentNowPlaying() const;
Q_INVOKABLE void playUrl(const QString &url, const QString &name);
Q_INVOKABLE void stop();
Q_INVOKABLE void togglePlayPause();
Q_INVOKABLE void setVolume(qreal vol);
signals:
void playingChanged(bool playing);
void volumeChanged(qreal volume);
void currentStationChanged(const QString &stationName);
void trackInfoChanged();
private slots:
void onStateChanged(int state);
void onMetaDataChanged();
void onCoverUrlReady(const QString &url);
private:
void clearTrackInfo();
void scheduleRetry();
Phonon::MediaObject *m_mediaObject = nullptr;
Phonon::AudioOutput *m_audioOutput = nullptr;
QTimer m_retryTimer;
QString m_currentStationName;
QString m_currentTrackTitle;
QString m_currentTrackArtist;
QString m_lastUrl;
QString m_lastName;
QString m_currentCoverArtUrl;
CoverArtFetcher *m_coverArtFetcher = nullptr;
int m_retryAttempts = 0;
bool m_playing = false;
};
#endif

View File

@@ -0,0 +1,68 @@
#include "bearwavecontroladaptor.h"
#include "radiobackend.h"
BearWaveControlAdaptor::BearWaveControlAdaptor(RadioBackend *backend)
: QDBusAbstractAdaptor(backend)
, m_backend(backend)
{
connect(m_backend, &RadioBackend::listsChanged, this, &BearWaveControlAdaptor::StationsChanged);
}
QVariantList BearWaveControlAdaptor::GetFavorites() const
{
return m_backend ? m_backend->getFavoriteStations() : QVariantList();
}
QVariantList BearWaveControlAdaptor::GetRecentStations() const
{
return m_backend ? m_backend->getRecentStations() : QVariantList();
}
bool BearWaveControlAdaptor::PlayFavoriteByUuid(const QString &uuid, const QString &urlResolved)
{
return m_backend ? m_backend->playFavoriteByUuid(uuid, urlResolved) : false;
}
bool BearWaveControlAdaptor::PlayRecentByUuid(const QString &uuid, const QString &urlResolved)
{
return m_backend ? m_backend->playRecentByUuid(uuid, urlResolved) : false;
}
bool BearWaveControlAdaptor::HasNext() const
{
return m_backend ? m_backend->hasNextStation() : false;
}
bool BearWaveControlAdaptor::HasPrevious() const
{
return m_backend ? m_backend->hasPreviousStation() : false;
}
void BearWaveControlAdaptor::PlayNext()
{
if (m_backend) {
m_backend->playNextStation();
}
}
void BearWaveControlAdaptor::PlayPrevious()
{
if (m_backend) {
m_backend->playPreviousStation();
}
}
void BearWaveControlAdaptor::PlayPause()
{
if (m_backend && m_backend->player()) {
m_backend->player()->togglePlayPause();
}
}
void BearWaveControlAdaptor::Stop()
{
if (m_backend && m_backend->player()) {
m_backend->player()->stop();
}
}

View File

@@ -0,0 +1,36 @@
#ifndef BEARWAVECONTROLADAPTOR_H
#define BEARWAVECONTROLADAPTOR_H
#include <QDBusAbstractAdaptor>
#include <QVariantList>
class RadioBackend;
class BearWaveControlAdaptor : public QDBusAbstractAdaptor
{
Q_OBJECT
Q_CLASSINFO("D-Bus Interface", "org.kde.BearWave.Control")
public:
explicit BearWaveControlAdaptor(RadioBackend *backend);
public slots:
QVariantList GetFavorites() const;
QVariantList GetRecentStations() const;
bool PlayFavoriteByUuid(const QString &uuid, const QString &urlResolved = QString());
bool PlayRecentByUuid(const QString &uuid, const QString &urlResolved = QString());
bool HasNext() const;
bool HasPrevious() const;
void PlayNext();
void PlayPrevious();
void PlayPause();
void Stop();
signals:
void StationsChanged();
private:
RadioBackend *m_backend;
};
#endif

82
src/coverartfetcher.cpp Normal file
View File

@@ -0,0 +1,82 @@
#include "coverartfetcher.h"
#include <QUrlQuery>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QDebug>
CoverArtFetcher::CoverArtFetcher(QObject *parent)
: QObject(parent)
{
m_networkManager = new QNetworkAccessManager(this);
}
void CoverArtFetcher::fetch(const QString &artist, const QString &title)
{
if (m_currentReply) {
QNetworkReply *reply = m_currentReply;
m_currentReply = nullptr;
reply->abort();
reply->deleteLater();
}
if (artist.isEmpty() && title.isEmpty()) {
emit coverUrlReady(QString());
return;
}
QString term = artist;
if (!title.isEmpty()) {
if (!term.isEmpty()) term += " ";
term += title;
}
QUrl url("https://itunes.apple.com/search");
QUrlQuery query;
query.addQueryItem("term", term);
query.addQueryItem("entity", "song");
query.addQueryItem("limit", "1");
url.setQuery(query);
QNetworkRequest request(url);
m_currentReply = m_networkManager->get(request);
connect(m_currentReply, &QNetworkReply::finished, this, &CoverArtFetcher::onReplyFinished);
}
void CoverArtFetcher::onReplyFinished()
{
QNetworkReply *reply = qobject_cast<QNetworkReply*>(sender());
if (!reply) return;
if (reply == m_currentReply) {
m_currentReply = nullptr;
}
QString coverUrl;
if (reply->error() == QNetworkReply::NoError) {
QByteArray data = reply->readAll();
QJsonDocument doc = QJsonDocument::fromJson(data);
if (doc.isObject()) {
QJsonObject obj = doc.object();
if (obj.value("resultCount").toInt() > 0) {
QJsonArray results = obj.value("results").toArray();
if (!results.isEmpty()) {
QJsonObject firstResult = results.first().toObject();
QString url = firstResult.value("artworkUrl100").toString();
if (!url.isEmpty()) {
// Replace 100x100bb with a higher resolution
url.replace("100x100bb", "600x600bb");
coverUrl = url;
}
}
}
}
} else {
if (reply->error() != QNetworkReply::OperationCanceledError) {
qDebug() << "CoverArtFetcher error:" << reply->errorString();
}
}
reply->deleteLater();
emit coverUrlReady(coverUrl);
}

28
src/coverartfetcher.h Normal file
View File

@@ -0,0 +1,28 @@
#ifndef COVERARTFETCHER_H
#define COVERARTFETCHER_H
#include <QObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
class CoverArtFetcher : public QObject
{
Q_OBJECT
public:
explicit CoverArtFetcher(QObject *parent = nullptr);
void fetch(const QString &artist, const QString &title);
signals:
void coverUrlReady(const QString &url);
private slots:
void onReplyFinished();
private:
QNetworkAccessManager *m_networkManager;
QNetworkReply *m_currentReply = nullptr;
};
#endif // COVERARTFETCHER_H

55
src/main.cpp Normal file
View File

@@ -0,0 +1,55 @@
#include <QApplication>
#include <QDBusConnection>
#include <QIcon>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include "radiobackend.h"
#include "mprisadaptor.h"
#include "bearwavecontroladaptor.h"
int main(int argc, char *argv[])
{
QApplication::setApplicationName(QStringLiteral("BearWave"));
QApplication::setDesktopFileName(QStringLiteral("org.kde.bearwave.desktop"));
QApplication::setOrganizationName(QStringLiteral("BearWave"));
QApplication::setApplicationVersion(QStringLiteral("1.0.0"));
QApplication app(argc, argv);
app.setWindowIcon(QIcon::fromTheme(QStringLiteral("org.kde.bearwave")));
app.setQuitOnLastWindowClosed(false);
QQmlApplicationEngine engine;
RadioBackend backend;
engine.rootContext()->setContextProperty("radioBackend", &backend);
MprisRootAdaptor mprisRoot(&backend, &app);
MprisPlayerAdaptor mprisPlayer(&backend);
BearWaveControlAdaptor controlAdaptor(&backend);
Q_UNUSED(mprisRoot)
Q_UNUSED(mprisPlayer)
Q_UNUSED(controlAdaptor)
QDBusConnection sessionBus = QDBusConnection::sessionBus();
sessionBus.registerObject(QStringLiteral("/org/mpris/MediaPlayer2"), &backend, QDBusConnection::ExportAdaptors);
sessionBus.registerService(QStringLiteral("org.mpris.MediaPlayer2.bearwave"));
const QUrl url(QStringLiteral("qrc:/Main.qml"));
QObject::connect(&engine, &QQmlApplicationEngine::objectCreated,
&app, [&app, url](QObject *obj, const QUrl &objUrl) {
if (!obj && url == objUrl) {
qCritical("Failed to load QML file");
app.exit(EXIT_FAILURE);
}
}, Qt::QueuedConnection);
engine.load(url);
if (engine.rootObjects().isEmpty()) {
return EXIT_FAILURE;
}
return app.exec();
}

320
src/mprisadaptor.cpp Normal file
View File

@@ -0,0 +1,320 @@
#include "mprisadaptor.h"
#include "radiobackend.h"
#include "bearplayer.h"
#include <QApplication>
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDBusObjectPath>
#include <QStandardPaths>
namespace {
void emitPlayerPropertiesChanged(const QStringList &changedProps, const QVariantMap &changedValues)
{
QDBusMessage signal = QDBusMessage::createSignal(
QStringLiteral("/org/mpris/MediaPlayer2"),
QStringLiteral("org.freedesktop.DBus.Properties"),
QStringLiteral("PropertiesChanged"));
signal << QStringLiteral("org.mpris.MediaPlayer2.Player")
<< changedValues
<< QStringList();
QDBusConnection::sessionBus().send(signal);
Q_UNUSED(changedProps)
}
}
MprisRootAdaptor::MprisRootAdaptor(RadioBackend *backend, QApplication *app, QObject *parent)
: QDBusAbstractAdaptor(backend)
, m_backend(backend)
, m_app(app)
{
}
bool MprisRootAdaptor::canQuit() const
{
return true;
}
bool MprisRootAdaptor::fullscreen() const
{
return false;
}
void MprisRootAdaptor::setFullscreen(bool value)
{
Q_UNUSED(value)
}
bool MprisRootAdaptor::canSetFullscreen() const
{
return false;
}
bool MprisRootAdaptor::canRaise() const
{
return false;
}
bool MprisRootAdaptor::hasTrackList() const
{
return false;
}
QString MprisRootAdaptor::identity() const
{
return QStringLiteral("BearWave");
}
QString MprisRootAdaptor::desktopEntry() const
{
return QStringLiteral("org.kde.bearwave");
}
QStringList MprisRootAdaptor::supportedUriSchemes() const
{
return {QStringLiteral("http"), QStringLiteral("https")};
}
QStringList MprisRootAdaptor::supportedMimeTypes() const
{
return {
QStringLiteral("audio/mpeg"),
QStringLiteral("audio/aac"),
QStringLiteral("audio/ogg"),
QStringLiteral("application/x-mpegURL"),
QStringLiteral("application/vnd.apple.mpegurl")
};
}
void MprisRootAdaptor::Raise()
{
}
void MprisRootAdaptor::Quit()
{
if (m_app) {
m_app->quit();
}
}
MprisPlayerAdaptor::MprisPlayerAdaptor(RadioBackend *backend)
: QDBusAbstractAdaptor(backend)
, m_backend(backend)
{
BearPlayer *p = player();
if (!p) {
return;
}
connect(p, &BearPlayer::playingChanged, this, [this]() {
QVariantMap changed;
changed.insert(QStringLiteral("PlaybackStatus"), playbackStatus());
emitPlayerPropertiesChanged({QStringLiteral("PlaybackStatus")}, changed);
});
connect(p, &BearPlayer::volumeChanged, this, [this]() {
QVariantMap changed;
changed.insert(QStringLiteral("Volume"), volume());
emitPlayerPropertiesChanged({QStringLiteral("Volume")}, changed);
});
connect(p, &BearPlayer::currentStationChanged, this, [this]() {
QVariantMap changed;
changed.insert(QStringLiteral("Metadata"), metadata());
changed.insert(QStringLiteral("PlaybackStatus"), playbackStatus());
changed.insert(QStringLiteral("CanGoNext"), canGoNext());
changed.insert(QStringLiteral("CanGoPrevious"), canGoPrevious());
emitPlayerPropertiesChanged({QStringLiteral("Metadata"), QStringLiteral("PlaybackStatus")}, changed);
});
connect(p, &BearPlayer::trackInfoChanged, this, [this]() {
QVariantMap changed;
changed.insert(QStringLiteral("Metadata"), metadata());
emitPlayerPropertiesChanged({QStringLiteral("Metadata")}, changed);
});
}
BearPlayer *MprisPlayerAdaptor::player() const
{
return m_backend ? m_backend->player() : nullptr;
}
QString MprisPlayerAdaptor::playbackStatus() const
{
BearPlayer *p = player();
if (!p) {
return QStringLiteral("Stopped");
}
if (p->currentStationName().isEmpty()) {
return QStringLiteral("Stopped");
}
return p->playing() ? QStringLiteral("Playing") : QStringLiteral("Paused");
}
double MprisPlayerAdaptor::rate() const
{
return 1.0;
}
void MprisPlayerAdaptor::setRate(double value)
{
Q_UNUSED(value)
}
double MprisPlayerAdaptor::minimumRate() const
{
return 1.0;
}
double MprisPlayerAdaptor::maximumRate() const
{
return 1.0;
}
double MprisPlayerAdaptor::volume() const
{
BearPlayer *p = player();
if (!p) {
return 0.0;
}
return p->volume();
}
void MprisPlayerAdaptor::setVolume(double value)
{
BearPlayer *p = player();
if (!p) {
return;
}
p->setVolume(value);
}
QVariantMap MprisPlayerAdaptor::metadata() const
{
QVariantMap map;
BearPlayer *p = player();
if (!p) {
return map;
}
const QString station = p->currentStationName();
const QString title = p->currentTrackTitle();
const QString artist = p->currentTrackArtist();
const QString coverUrl = p->currentCoverArtUrl();
map.insert(QStringLiteral("mpris:trackid"), QVariant::fromValue(QDBusObjectPath(QStringLiteral("/org/kde/bearwave/track/0"))));
if (!station.isEmpty()) {
map.insert(QStringLiteral("xesam:album"), station);
}
if (!title.isEmpty()) {
map.insert(QStringLiteral("xesam:title"), title);
} else if (!station.isEmpty()) {
map.insert(QStringLiteral("xesam:title"), station);
}
if (!artist.isEmpty()) {
map.insert(QStringLiteral("xesam:artist"), QStringList{artist});
}
if (!coverUrl.isEmpty()) {
map.insert(QStringLiteral("mpris:artUrl"), coverUrl);
} else {
QString iconPath = QStandardPaths::locate(QStandardPaths::GenericDataLocation, QStringLiteral("icons/hicolor/256x256/apps/org.kde.bearwave.png"));
if (!iconPath.isEmpty()) {
map.insert(QStringLiteral("mpris:artUrl"), QStringLiteral("file://") + iconPath);
}
}
map.insert(QStringLiteral("mpris:length"), static_cast<qlonglong>(0));
return map;
}
qlonglong MprisPlayerAdaptor::position() const
{
return 0;
}
bool MprisPlayerAdaptor::canSeek() const
{
return false;
}
bool MprisPlayerAdaptor::canControl() const
{
return true;
}
bool MprisPlayerAdaptor::canPlay() const
{
return true;
}
bool MprisPlayerAdaptor::canPause() const
{
return true;
}
bool MprisPlayerAdaptor::canGoNext() const
{
return m_backend && m_backend->hasNextStation();
}
bool MprisPlayerAdaptor::canGoPrevious() const
{
return m_backend && m_backend->hasPreviousStation();
}
void MprisPlayerAdaptor::Play()
{
BearPlayer *p = player();
if (!p) {
return;
}
if (!p->playing()) {
p->togglePlayPause();
}
}
void MprisPlayerAdaptor::Pause()
{
BearPlayer *p = player();
if (!p) {
return;
}
if (p->playing()) {
p->togglePlayPause();
}
}
void MprisPlayerAdaptor::PlayPause()
{
BearPlayer *p = player();
if (!p) {
return;
}
p->togglePlayPause();
}
void MprisPlayerAdaptor::Stop()
{
BearPlayer *p = player();
if (!p) {
return;
}
p->stop();
}
void MprisPlayerAdaptor::Next()
{
if (m_backend) {
m_backend->playNextStation();
}
}
void MprisPlayerAdaptor::Previous()
{
if (m_backend) {
m_backend->playPreviousStation();
}
}

104
src/mprisadaptor.h Normal file
View File

@@ -0,0 +1,104 @@
#ifndef MPRISADAPTOR_H
#define MPRISADAPTOR_H
#include <QDBusAbstractAdaptor>
#include <QVariantMap>
#include <QObject>
#include <QString>
#include <QStringList>
class QApplication;
class RadioBackend;
class BearPlayer;
class MprisRootAdaptor : public QDBusAbstractAdaptor
{
Q_OBJECT
Q_CLASSINFO("D-Bus Interface", "org.mpris.MediaPlayer2")
Q_PROPERTY(bool CanQuit READ canQuit CONSTANT)
Q_PROPERTY(bool Fullscreen READ fullscreen WRITE setFullscreen)
Q_PROPERTY(bool CanSetFullscreen READ canSetFullscreen CONSTANT)
Q_PROPERTY(bool CanRaise READ canRaise CONSTANT)
Q_PROPERTY(bool HasTrackList READ hasTrackList CONSTANT)
Q_PROPERTY(QString Identity READ identity CONSTANT)
Q_PROPERTY(QString DesktopEntry READ desktopEntry CONSTANT)
Q_PROPERTY(QStringList SupportedUriSchemes READ supportedUriSchemes CONSTANT)
Q_PROPERTY(QStringList SupportedMimeTypes READ supportedMimeTypes CONSTANT)
public:
explicit MprisRootAdaptor(RadioBackend *backend, QApplication *app, QObject *parent = nullptr);
bool canQuit() const;
bool fullscreen() const;
void setFullscreen(bool value);
bool canSetFullscreen() const;
bool canRaise() const;
bool hasTrackList() const;
QString identity() const;
QString desktopEntry() const;
QStringList supportedUriSchemes() const;
QStringList supportedMimeTypes() const;
public slots:
void Raise();
void Quit();
private:
RadioBackend *m_backend;
QApplication *m_app;
};
class MprisPlayerAdaptor : public QDBusAbstractAdaptor
{
Q_OBJECT
Q_CLASSINFO("D-Bus Interface", "org.mpris.MediaPlayer2.Player")
Q_PROPERTY(QString PlaybackStatus READ playbackStatus)
Q_PROPERTY(double Rate READ rate WRITE setRate)
Q_PROPERTY(double MinimumRate READ minimumRate CONSTANT)
Q_PROPERTY(double MaximumRate READ maximumRate CONSTANT)
Q_PROPERTY(double Volume READ volume WRITE setVolume)
Q_PROPERTY(QVariantMap Metadata READ metadata)
Q_PROPERTY(qlonglong Position READ position)
Q_PROPERTY(bool CanSeek READ canSeek CONSTANT)
Q_PROPERTY(bool CanControl READ canControl CONSTANT)
Q_PROPERTY(bool CanPlay READ canPlay CONSTANT)
Q_PROPERTY(bool CanPause READ canPause CONSTANT)
Q_PROPERTY(bool CanGoNext READ canGoNext CONSTANT)
Q_PROPERTY(bool CanGoPrevious READ canGoPrevious CONSTANT)
public:
explicit MprisPlayerAdaptor(RadioBackend *backend);
QString playbackStatus() const;
double rate() const;
void setRate(double value);
double minimumRate() const;
double maximumRate() const;
double volume() const;
void setVolume(double value);
QVariantMap metadata() const;
qlonglong position() const;
bool canSeek() const;
bool canControl() const;
bool canPlay() const;
bool canPause() const;
bool canGoNext() const;
bool canGoPrevious() const;
public slots:
void Play();
void Pause();
void PlayPause();
void Stop();
void Next();
void Previous();
private:
BearPlayer *player() const;
RadioBackend *m_backend;
};
#endif

9
src/qml.qrc Normal file
View File

@@ -0,0 +1,9 @@
<RCC>
<qresource prefix="/">
<file alias="Main.qml">qml/Main.qml</file>
<file alias="bearwave.png">../bearwave.png</file>
<file alias="globe.svg">../globe.svg</file>
<file alias="github.svg">../github.svg</file>
<file alias="linkedin.svg">../linkedin.svg</file>
</qresource>
</RCC>

938
src/qml/Main.qml Normal file
View File

@@ -0,0 +1,938 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import Qt.labs.platform 1.1 as Platform
ApplicationWindow {
id: root
title: "BearWave"
width: 980
height: 700
minimumWidth: 520
minimumHeight: 460
visible: true
onClosing: function(close) {
close.accepted = false
root.hide()
}
Platform.SystemTrayIcon {
id: systray
visible: true
icon.name: "multimedia-player"
tooltip: "BearWave"
menu: Platform.Menu {
Platform.MenuItem {
text: backend && backend.player && backend.player.playing ? "Pause" : "Play"
onTriggered: {
if (backend && backend.player) {
backend.player.togglePlayPause()
}
}
}
Platform.MenuItem {
text: root.visible ? "Verstecken" : "Zeigen"
onTriggered: {
if (root.visible) {
root.hide()
} else {
root.show()
root.raise()
root.requestActivate()
}
}
}
Platform.MenuSeparator {}
Platform.MenuItem {
text: "Beenden"
onTriggered: Qt.quit()
}
}
onActivated: function(reason) {
if (reason === Platform.SystemTrayIcon.Trigger) {
if (root.visible) {
root.hide()
} else {
root.show()
root.raise()
root.requestActivate()
}
}
}
}
property var currentPage: "top"
property var backend: (typeof radioBackend !== "undefined" ? radioBackend : null)
property bool compactMode: width < 780
property real contentOpacity: 1.0
property string activeQuickFilter: ""
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 warn: "#ff8b8b"
function toast(message) {
toastLabel.text = message
toastPopup.open()
}
function activeModel() {
if (!backend) {
return []
}
return currentPage === "favorites" ? backend.favoriteStations : backend.stations
}
onCurrentPageChanged: {
contentOpacity = 0.85
contentFadeRestart.restart()
}
Shortcut {
sequence: "Space"
onActivated: {
if (backend && backend.player) {
backend.player.togglePlayPause()
}
}
}
Shortcut {
sequence: "Ctrl+F"
onActivated: searchField.forceActiveFocus()
}
Rectangle {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.0; color: bgA }
GradientStop { position: 1.0; color: bgB }
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 10
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: compactMode ? 208 : 176
radius: 12
color: panel
border.color: cardBorder
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 8
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: "BearWave"
color: textMain
font.pixelSize: compactMode ? 18 : 22
font.bold: true
}
Rectangle {
Layout.preferredWidth: 1
Layout.fillHeight: true
color: cardBorder
opacity: 0.6
}
Button {
text: "Top"
highlighted: currentPage === "top"
onClicked: {
if (!backend) return
currentPage = "top"
activeQuickFilter = ""
backend.loadTopStations()
}
}
Button {
text: "DE"
highlighted: currentPage === "german"
onClicked: {
if (!backend) return
currentPage = "german"
activeQuickFilter = ""
backend.loadGermanStations()
}
}
Button {
text: "NL"
highlighted: currentPage === "dutch"
onClicked: {
if (!backend) return
currentPage = "dutch"
activeQuickFilter = ""
backend.loadDutchStations()
}
}
Button {
text: "Favoriten"
highlighted: currentPage === "favorites"
onClicked: {
currentPage = "favorites"
activeQuickFilter = ""
}
}
Item { Layout.fillWidth: true }
Button {
text: compactMode ? "+" : "Manuell +"
onClicked: addDialog.open()
}
Button {
text: "About"
onClicked: aboutDialog.open()
}
Button {
visible: backend && backend.canResumeLastStation
text: compactMode ? "↺" : "Resume"
onClicked: {
if (backend) {
backend.resumeLastStation()
}
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
TextField {
id: searchField
Layout.fillWidth: true
placeholderText: "Sender suchen (Name, Genre, Land)"
onTextChanged: {
if (backend) {
backend.filterQuery = text
}
}
onAccepted: {
if (text.length < 2 || !backend) return
currentPage = "search"
backend.searchStations(text)
}
}
Button {
text: "Suche"
highlighted: true
onClicked: {
if (searchField.text.length < 2 || !backend) return
currentPage = "search"
backend.searchStations(searchField.text)
}
}
Button {
text: compactMode ? "A-Z" : "Sort A-Z"
onClicked: {
if (backend && currentPage !== "favorites") {
backend.sortStations("name")
}
}
}
Button {
text: compactMode ? "kb" : "Sort Bitrate"
onClicked: {
if (backend && currentPage !== "favorites") {
backend.sortStations("bitrate")
}
}
}
Button {
text: compactMode ? "❤" : "Sort Votes"
onClicked: {
if (backend && currentPage !== "favorites") {
backend.sortStations("votes")
}
}
}
}
Label {
Layout.fillWidth: true
text: "Tipp: Du bist nicht auf DE/NL begrenzt - suche nach Ländern, Genres oder Sendernamen weltweit."
color: textMuted
font.pixelSize: 11
elide: Text.ElideRight
}
RowLayout {
Layout.fillWidth: true
spacing: 6
Label {
text: "Genre:"
color: textMuted
font.pixelSize: 11
}
Button {
text: "Rock"
highlighted: activeQuickFilter === "tag:rock"
onClicked: {
if (!backend) return
currentPage = "genre-rock"
activeQuickFilter = "tag:rock"
backend.loadByTag("rock")
}
}
Button {
text: "News"
highlighted: activeQuickFilter === "tag:news"
onClicked: {
if (!backend) return
currentPage = "genre-news"
activeQuickFilter = "tag:news"
backend.loadByTag("news")
}
}
Button {
text: "Jazz"
highlighted: activeQuickFilter === "tag:jazz"
onClicked: {
if (!backend) return
currentPage = "genre-jazz"
activeQuickFilter = "tag:jazz"
backend.loadByTag("jazz")
}
}
Item { Layout.preferredWidth: 10 }
Label {
text: "Land:"
color: textMuted
font.pixelSize: 11
}
Button {
text: "US"
highlighted: activeQuickFilter === "cc:US"
onClicked: {
if (!backend) return
currentPage = "country-us"
activeQuickFilter = "cc:US"
backend.loadByCountryCode("US")
}
}
Button {
text: "UK"
highlighted: activeQuickFilter === "cc:GB"
onClicked: {
if (!backend) return
currentPage = "country-gb"
activeQuickFilter = "cc:GB"
backend.loadByCountryCode("GB")
}
}
Button {
text: "FR"
highlighted: activeQuickFilter === "cc:FR"
onClicked: {
if (!backend) return
currentPage = "country-fr"
activeQuickFilter = "cc:FR"
backend.loadByCountryCode("FR")
}
}
Button {
text: "WORLD"
highlighted: activeQuickFilter === "world"
onClicked: {
if (!backend) return
currentPage = "world"
activeQuickFilter = "world"
backend.loadWorldStations()
}
}
Item { Layout.fillWidth: true }
}
Label {
Layout.fillWidth: true
visible: backend && backend.lastError.length > 0
text: backend ? ("Fehler: " + backend.lastError) : ""
color: warn
elide: Text.ElideRight
font.pixelSize: 12
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
radius: 12
color: panel
border.color: cardBorder
clip: true
opacity: contentOpacity
Behavior on opacity {
NumberAnimation { duration: 180; easing.type: Easing.OutCubic }
}
ScrollView {
id: stationScrollView
anchors.fill: parent
anchors.margins: 10
clip: true
ScrollBar.vertical.policy: ScrollBar.AlwaysOn
ListView {
id: stationList
width: stationScrollView.availableWidth
spacing: 8
model: activeModel()
visible: count > 0
delegate: Rectangle {
id: stationCard
required property int index
required property var modelData
width: stationScrollView.availableWidth
height: compactMode ? 72 : 78
radius: 10
color: cardMouse.containsMouse ? cardHover : card
border.color: cardBorder
MouseArea {
id: cardMouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton
onClicked: {
if (!backend) return
if (currentPage === "favorites") {
backend.playFavoriteStation(index)
} else {
backend.playStation(index)
}
}
}
RowLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 10
Rectangle {
Layout.preferredWidth: 44
Layout.preferredHeight: 44
radius: 8
color: "#123154"
border.color: accent
Image {
anchors.fill: parent
anchors.margins: 3
source: (stationCard.modelData.favicon && stationCard.modelData.favicon.startsWith("https://"))
? stationCard.modelData.favicon
: ""
fillMode: Image.PreserveAspectFit
asynchronous: true
cache: true
visible: source !== "" && status === Image.Ready
}
Label {
anchors.centerIn: parent
text: "FM"
color: "#d8ecff"
font.bold: true
visible: !parent.children[0].visible
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Label {
Layout.fillWidth: true
text: stationCard.modelData.name
color: textMain
font.bold: true
font.pixelSize: compactMode ? 13 : 14
elide: Text.ElideRight
}
Label {
Layout.fillWidth: true
text: stationCard.modelData.country + " • "
+ (stationCard.modelData.bitrate > 0 ? stationCard.modelData.bitrate + " kbps" : "Stream")
color: textMuted
font.pixelSize: 11
elide: Text.ElideRight
}
}
Button {
Layout.preferredWidth: compactMode ? 34 : 40
Layout.preferredHeight: 40
text: stationCard.modelData.isFavorite ? "★" : "☆"
onClicked: {
if (!backend) return
backend.toggleFavoriteById(stationCard.modelData.uuid, stationCard.modelData.urlResolved)
toast(stationCard.modelData.isFavorite ? "Aus Favoriten entfernt" : "Zu Favoriten hinzugefügt")
}
}
Button {
Layout.preferredWidth: compactMode ? 34 : 40
Layout.preferredHeight: 40
text: "▶"
onClicked: {
if (!backend) return
if (currentPage === "favorites") {
backend.playFavoriteStation(index)
} else {
backend.playStation(index)
}
}
}
}
}
}
}
Column {
anchors.centerIn: parent
spacing: 8
visible: stationList.count === 0
Label {
text: "Noch keine Sender geladen"
color: textMain
font.bold: true
}
Label {
text: "Lade DE/NL oder nutze die Suche"
color: textMuted
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 108
radius: 12
color: panel
border.color: cardBorder
RowLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 12
Rectangle {
Layout.preferredWidth: 88
Layout.preferredHeight: 88
radius: 8
color: "#182637"
clip: true
border.color: cardBorder
Image {
id: coverImage
anchors.fill: parent
source: backend && backend.player && backend.player.currentCoverArtUrl ? backend.player.currentCoverArtUrl : "qrc:/bearwave.png"
fillMode: backend && backend.player && backend.player.currentCoverArtUrl ? Image.PreserveAspectCrop : Image.PreserveAspectFit
smooth: true
asynchronous: true
anchors.margins: backend && backend.player && backend.player.currentCoverArtUrl ? 0 : 16
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 6
Label {
Layout.fillWidth: true
text: backend && backend.player ? (backend.player.currentStationName || "Kein Sender ausgewählt") : "Kein Sender ausgewählt"
color: textMain
font.pixelSize: 16
font.bold: true
elide: Text.ElideRight
}
Label {
Layout.fillWidth: true
text: backend && backend.player && backend.player.currentNowPlaying.length > 0
? ("Jetzt läuft: " + backend.player.currentNowPlaying)
: "Jetzt läuft: Keine Titelinfo"
color: textMuted
font.pixelSize: 12
elide: Text.ElideRight
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Button {
text: "⏮"
Layout.preferredWidth: 44
enabled: backend ? backend.hasPreviousStation() : false
onClicked: {
if (backend) {
backend.playPreviousStation()
}
}
}
Button {
text: backend && backend.player && backend.player.playing ? "⏸" : "▶"
Layout.preferredWidth: 44
onClicked: {
if (backend && backend.player) {
backend.player.togglePlayPause()
}
}
}
Button {
text: "⏹"
Layout.preferredWidth: 44
onClicked: {
if (backend && backend.player) {
backend.player.stop()
}
}
}
Button {
text: "⏭"
Layout.preferredWidth: 44
enabled: backend ? backend.hasNextStation() : false
onClicked: {
if (backend) {
backend.playNextStation()
}
}
}
Label {
text: "Lautstärke"
color: textMuted
}
Slider {
Layout.fillWidth: true
from: 0
to: 1
value: backend && backend.player ? backend.player.volume : 0.5
onMoved: {
if (backend && backend.player) {
backend.player.setVolume(value)
}
}
}
}
}
}
}
}
Rectangle {
visible: backend && backend.loading
anchors.fill: parent
color: "#7f0b121a"
z: 20
Rectangle {
anchors.centerIn: parent
width: 210
height: 110
radius: 12
color: panel
border.color: cardBorder
Column {
anchors.centerIn: parent
spacing: 10
BusyIndicator {
running: true
width: 36
height: 36
anchors.horizontalCenter: parent.horizontalCenter
}
Label {
text: "Lade Sender..."
color: textMain
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
}
Component.onCompleted: {
if (backend) {
backend.loadTopStations()
}
}
Dialog {
id: addDialog
title: "Sender manuell hinzufügen"
modal: true
anchors.centerIn: parent
width: compactMode ? 320 : 420
standardButtons: Dialog.Ok | Dialog.Cancel
contentItem: ColumnLayout {
spacing: 8
TextField { id: manualName; Layout.fillWidth: true; placeholderText: "Name" }
TextField { id: manualUrl; Layout.fillWidth: true; placeholderText: "Stream URL (http/https)" }
TextField { id: manualCountry; Layout.fillWidth: true; placeholderText: "Land (optional)" }
}
onAccepted: {
if (backend) {
backend.addManualStation(manualName.text, manualUrl.text, manualCountry.text)
toast("Sender hinzugefügt")
}
manualName.text = ""
manualUrl.text = ""
manualCountry.text = ""
}
}
Dialog {
id: aboutDialog
modal: true
anchors.centerIn: Overlay.overlay
width: compactMode ? 360 : 520
height: compactMode ? 560 : 680
standardButtons: Dialog.Ok
contentItem: ColumnLayout {
spacing: 16
// --- HEADER ---
ColumnLayout {
Layout.alignment: Qt.AlignHCenter
spacing: 6
Image {
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: 72
Layout.preferredHeight: 72
sourceSize.width: 72
sourceSize.height: 72
source: "qrc:/bearwave.png"
fillMode: Image.PreserveAspectFit
smooth: true
}
Label {
Layout.alignment: Qt.AlignHCenter
text: "BearWave"
color: textMain
font.bold: true
font.pixelSize: 26
}
Label {
Layout.alignment: Qt.AlignHCenter
text: "Internet Radio Player for KDE"
color: textMuted
font.pixelSize: 14
}
}
// --- CREDITS & LINKS ---
ColumnLayout {
Layout.alignment: Qt.AlignHCenter
spacing: 8
Layout.topMargin: 8
Layout.bottomMargin: 8
Label {
Layout.alignment: Qt.AlignHCenter
text: "Autor: Sebastian Palencsár"
color: textMain
font.bold: true
font.pixelSize: 14
}
RowLayout {
Layout.alignment: Qt.AlignHCenter
spacing: 16
ToolButton {
Layout.preferredWidth: 40
Layout.preferredHeight: 40
icon.source: "qrc:/globe.svg"
icon.width: 19
icon.height: 19
onClicked: Qt.openUrlExternally("https://palencsar.pro")
ToolTip.visible: hovered
ToolTip.text: "Website öffnen"
background: Rectangle {
radius: 20
color: parent.hovered ? "#274261" : "#1f3147"
border.color: cardBorder
border.width: 1
}
}
ToolButton {
Layout.preferredWidth: 40
Layout.preferredHeight: 40
icon.source: "qrc:/github.svg"
icon.width: 19
icon.height: 19
onClicked: Qt.openUrlExternally("https://github.com/spalencsar")
ToolTip.visible: hovered
ToolTip.text: "GitHub öffnen"
background: Rectangle {
radius: 20
color: parent.hovered ? "#274261" : "#1f3147"
border.color: cardBorder
border.width: 1
}
}
ToolButton {
Layout.preferredWidth: 40
Layout.preferredHeight: 40
icon.source: "qrc:/linkedin.svg"
icon.width: 19
icon.height: 19
onClicked: Qt.openUrlExternally("https://www.linkedin.com/in/spalencsar/")
ToolTip.visible: hovered
ToolTip.text: "LinkedIn öffnen"
background: Rectangle {
radius: 20
color: parent.hovered ? "#274261" : "#1f3147"
border.color: cardBorder
border.width: 1
}
}
}
}
// --- LICENSE ---
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 8
RowLayout {
Layout.fillWidth: true
Label {
Layout.fillWidth: true
text: "MIT License"
color: textMain
font.bold: true
}
Label {
text: "Copyright (c) 2026"
color: textMuted
font.pixelSize: 11
}
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 140
radius: 8
color: "#101a26"
border.color: cardBorder
ScrollView {
id: licenseScroll
anchors.fill: parent
anchors.margins: 10
clip: true
ScrollBar.vertical.policy: ScrollBar.AlwaysOn
Label {
width: licenseScroll.availableWidth
color: textMain
font.pixelSize: 11
wrapMode: Text.WordWrap
text: "Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
}
}
}
}
}
}
Popup {
id: toastPopup
x: (root.width - width) / 2
y: root.height - height - 20
padding: 10
closePolicy: Popup.NoAutoClose
background: Rectangle {
radius: 10
color: "#24364e"
border.color: accent
}
contentItem: Label {
id: toastLabel
color: textMain
font.pixelSize: 12
}
Timer {
interval: 1400
running: toastPopup.visible
repeat: false
onTriggered: toastPopup.close()
}
}
Timer {
id: contentFadeRestart
interval: 120
repeat: false
onTriggered: contentOpacity = 1.0
}
}

668
src/radiobackend.cpp Normal file
View File

@@ -0,0 +1,668 @@
#include "radiobackend.h"
#include <QDebug>
#include <algorithm>
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QStandardPaths>
namespace {
constexpr int kRecentLimit = 20;
QString appConfigDir()
{
return QDir::homePath() + QStringLiteral("/.config/bearwave");
}
}
RadioBackend::RadioBackend(QObject *parent)
: QObject(parent)
{
m_radioBrowser = new RadioBrowser(this);
m_player = new BearPlayer(this);
setupConnections();
loadFavorites();
loadState();
}
QList<QObject*> RadioBackend::stations() const
{
QList<QObject*> list;
for (RadioStation *s : m_filteredStations) {
list.append(s);
}
return list;
}
QList<QObject*> RadioBackend::favoriteStations() const
{
QList<QObject*> list;
for (RadioStation *s : m_favorites) {
list.append(s);
}
return list;
}
void RadioBackend::setupConnections()
{
connect(m_radioBrowser, &RadioBrowser::stationsLoaded,
this, &RadioBackend::onStationsLoaded);
connect(m_radioBrowser, &RadioBrowser::error,
this, [this](const QString &error) {
qWarning() << "RadioBrowser error:" << error;
setLastError(error);
setLoading(false);
});
connect(m_player, &BearPlayer::currentStationChanged, this, [this](const QString &name) {
if (!name.isEmpty()) {
m_lastStationName = name;
emit resumeStateChanged();
saveState();
}
});
connect(m_player, &BearPlayer::volumeChanged, this, [this]() {
saveState();
});
}
void RadioBackend::onStationsLoaded(const QList<RadioStation*> &stations)
{
qDeleteAll(m_stations);
m_stations.clear();
m_filteredStations.clear();
m_stations = stations;
for (RadioStation *s : m_stations) {
s->setParent(this);
for (RadioStation *f : m_favorites) {
if ((!s->uuid().isEmpty() && s->uuid() == f->uuid()) ||
(s->uuid().isEmpty() && s->urlResolved() == f->urlResolved())) {
s->setIsFavorite(true);
break;
}
}
}
std::sort(m_stations.begin(), m_stations.end(), [](RadioStation *a, RadioStation *b) {
return QString::localeAwareCompare(a->name(), b->name()) < 0;
});
rebuildFilteredStations(false);
setLastError(QString());
setLoading(false);
qDebug() << "Loaded" << stations.size() << "stations";
}
void RadioBackend::loadGermanStations()
{
setLoading(true);
m_radioBrowser->getGermanStations();
}
void RadioBackend::loadDutchStations()
{
setLoading(true);
m_radioBrowser->getDutchStations();
}
void RadioBackend::loadTopStations()
{
setLoading(true);
m_radioBrowser->getTopStations(100);
}
void RadioBackend::loadWorldStations()
{
setLoading(true);
m_radioBrowser->getWorldStations(200);
}
void RadioBackend::loadByTag(const QString &tag)
{
if (tag.trimmed().isEmpty()) {
return;
}
setLoading(true);
m_radioBrowser->getByTag(tag.trimmed());
}
void RadioBackend::loadByCountryCode(const QString &countryCode)
{
if (countryCode.trimmed().isEmpty()) {
return;
}
setLoading(true);
m_radioBrowser->getByCountry(countryCode.trimmed().toUpper());
}
void RadioBackend::searchStations(const QString &query)
{
if (query.isEmpty()) {
return;
}
setLoading(true);
m_radioBrowser->search(query);
}
void RadioBackend::playStation(int index)
{
if (!stationForVisibleIndex(index)) {
return;
}
m_currentFromFavorites = false;
m_currentIndex = index;
playCurrentSelection();
}
void RadioBackend::playFavoriteStation(int index)
{
if (index < 0 || index >= m_favorites.size()) {
return;
}
m_currentFromFavorites = true;
m_currentIndex = index;
playCurrentSelection();
}
void RadioBackend::playNextStation()
{
const QList<RadioStation*> list = currentList();
if (list.isEmpty()) {
return;
}
if (m_currentIndex < 0) {
m_currentIndex = 0;
} else if (m_currentIndex < list.size() - 1) {
++m_currentIndex;
}
playCurrentSelection();
}
void RadioBackend::playPreviousStation()
{
const QList<RadioStation*> list = currentList();
if (list.isEmpty()) {
return;
}
if (m_currentIndex < 0) {
m_currentIndex = 0;
} else if (m_currentIndex > 0) {
--m_currentIndex;
}
playCurrentSelection();
}
bool RadioBackend::hasNextStation() const
{
const QList<RadioStation*> list = currentList();
if (list.isEmpty()) {
return false;
}
if (m_currentIndex < 0) {
return true;
}
return m_currentIndex < list.size() - 1;
}
bool RadioBackend::hasPreviousStation() const
{
const QList<RadioStation*> list = currentList();
if (list.isEmpty()) {
return false;
}
return m_currentIndex > 0;
}
void RadioBackend::toggleFavorite(int index)
{
if (index < 0 || index >= m_stations.size()) {
return;
}
RadioStation *station = m_stations[index];
for (int i = 0; i < m_favorites.size(); ++i) {
if ((!station->uuid().isEmpty() && m_favorites[i]->uuid() == station->uuid()) ||
(station->uuid().isEmpty() && m_favorites[i]->urlResolved() == station->urlResolved())) {
m_favorites.removeAt(i);
station->setIsFavorite(false);
saveFavorites();
emit favoritesChanged();
emit stationsChanged();
return;
}
}
RadioStation *copy = new RadioStation(this);
copy->setUuid(station->uuid());
copy->setName(station->name());
copy->setUrl(station->url());
copy->setUrlResolved(station->urlResolved());
copy->setHomepage(station->homepage());
copy->setFavicon(station->favicon());
copy->setCountry(station->country());
copy->setTags(station->tags());
copy->setCodec(station->codec());
copy->setBitrate(station->bitrate());
copy->setVotes(station->votes());
copy->setIsOnline(station->isOnline());
copy->setIsFavorite(true);
m_favorites.append(copy);
std::sort(m_favorites.begin(), m_favorites.end(), [](RadioStation *a, RadioStation *b) {
return QString::localeAwareCompare(a->name(), b->name()) < 0;
});
station->setIsFavorite(true);
saveFavorites();
emit favoritesChanged();
emit stationsChanged();
emit listsChanged();
}
void RadioBackend::toggleFavoriteById(const QString &uuid, const QString &urlResolved)
{
int stationIndex = -1;
for (int i = 0; i < m_stations.size(); ++i) {
if ((!uuid.isEmpty() && m_stations[i]->uuid() == uuid) ||
(uuid.isEmpty() && !urlResolved.isEmpty() && m_stations[i]->urlResolved() == urlResolved)) {
stationIndex = i;
break;
}
}
if (stationIndex >= 0) {
toggleFavorite(stationIndex);
return;
}
for (int i = 0; i < m_favorites.size(); ++i) {
if ((!uuid.isEmpty() && m_favorites[i]->uuid() == uuid) ||
(uuid.isEmpty() && !urlResolved.isEmpty() && m_favorites[i]->urlResolved() == urlResolved)) {
m_favorites.removeAt(i);
for (RadioStation *s : m_stations) {
if ((!uuid.isEmpty() && s->uuid() == uuid) ||
(uuid.isEmpty() && !urlResolved.isEmpty() && s->urlResolved() == urlResolved)) {
s->setIsFavorite(false);
}
}
saveFavorites();
emit favoritesChanged();
emit stationsChanged();
emit listsChanged();
return;
}
}
}
void RadioBackend::addManualStation(const QString &name, const QString &url, const QString &country)
{
if (name.trimmed().isEmpty() || url.trimmed().isEmpty()) {
return;
}
RadioStation *station = new RadioStation(this);
station->setName(name.trimmed());
station->setUrl(url.trimmed());
station->setUrlResolved(url.trimmed());
station->setCountry(country.trimmed().isEmpty() ? QStringLiteral("Manual") : country.trimmed());
station->setCodec(QStringLiteral("unknown"));
station->setBitrate(0);
station->setVotes(0);
station->setIsOnline(true);
m_stations.prepend(station);
rebuildFilteredStations(false);
}
void RadioBackend::sortStations(const QString &mode)
{
if (mode == QStringLiteral("name")) {
std::sort(m_stations.begin(), m_stations.end(), [](RadioStation *a, RadioStation *b) {
return QString::localeAwareCompare(a->name(), b->name()) < 0;
});
} else if (mode == QStringLiteral("bitrate")) {
std::sort(m_stations.begin(), m_stations.end(), [](RadioStation *a, RadioStation *b) {
return a->bitrate() > b->bitrate();
});
} else if (mode == QStringLiteral("votes")) {
std::sort(m_stations.begin(), m_stations.end(), [](RadioStation *a, RadioStation *b) {
return a->votes() > b->votes();
});
}
rebuildFilteredStations(false);
}
QVariantList RadioBackend::getRecentStations() const
{
return m_recentStations;
}
QVariantList RadioBackend::getFavoriteStations() const
{
QVariantList result;
for (RadioStation *station : m_favorites) {
result.push_back(toVariantMap(station));
}
return result;
}
bool RadioBackend::playRecentByUuid(const QString &uuid, const QString &urlResolved)
{
for (RadioStation *station : m_stations) {
if (matchesStation(station, uuid, urlResolved)) {
const QString url = station->urlResolved().isEmpty() ? station->url() : station->urlResolved();
m_lastStationName = station->name();
m_lastStationUrl = url;
emit resumeStateChanged();
recordRecentStation(toVariantMap(station));
saveState();
m_player->playUrl(url, station->name());
return true;
}
}
for (const QVariant &entry : m_recentStations) {
const QVariantMap station = entry.toMap();
if ((!uuid.isEmpty() && station.value(QStringLiteral("uuid")).toString() == uuid) ||
(uuid.isEmpty() && !urlResolved.isEmpty() && station.value(QStringLiteral("urlResolved")).toString() == urlResolved)) {
const QString url = station.value(QStringLiteral("urlResolved")).toString();
if (url.isEmpty()) {
return false;
}
m_lastStationName = station.value(QStringLiteral("name")).toString();
m_lastStationUrl = url;
emit resumeStateChanged();
recordRecentStation(station);
saveState();
m_player->playUrl(url, m_lastStationName);
return true;
}
}
return false;
}
bool RadioBackend::playFavoriteByUuid(const QString &uuid, const QString &urlResolved)
{
for (int i = 0; i < m_favorites.size(); ++i) {
if (matchesStation(m_favorites[i], uuid, urlResolved)) {
playFavoriteStation(i);
return true;
}
}
return false;
}
void RadioBackend::resumeLastStation()
{
if (m_lastStationUrl.isEmpty()) {
return;
}
QVariantMap station;
station.insert(QStringLiteral("name"), m_lastStationName.isEmpty() ? QStringLiteral("Zuletzt gehoert") : m_lastStationName);
station.insert(QStringLiteral("urlResolved"), m_lastStationUrl);
recordRecentStation(station);
saveState();
m_player->playUrl(m_lastStationUrl, m_lastStationName.isEmpty() ? QStringLiteral("Zuletzt gehört") : m_lastStationName);
}
void RadioBackend::loadFavorites()
{
const QString configDir = appConfigDir();
QDir().mkpath(configDir);
QFile file(configDir + "/favorites.json");
if (!file.open(QIODevice::ReadOnly)) {
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
if (!doc.isArray()) {
return;
}
for (const QJsonValue &v : doc.array()) {
if (!v.isObject()) {
continue;
}
QJsonObject o = v.toObject();
RadioStation *s = new RadioStation(this);
s->setUuid(o.value("uuid").toString());
s->setName(o.value("name").toString());
s->setUrl(o.value("url").toString());
s->setUrlResolved(o.value("urlResolved").toString());
s->setCountry(o.value("country").toString());
s->setIsFavorite(true);
m_favorites.append(s);
}
std::sort(m_favorites.begin(), m_favorites.end(), [](RadioStation *a, RadioStation *b) {
return QString::localeAwareCompare(a->name(), b->name()) < 0;
});
}
void RadioBackend::saveFavorites() const
{
const QString configDir = appConfigDir();
QDir().mkpath(configDir);
QFile file(configDir + "/favorites.json");
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return;
}
QJsonArray arr;
for (RadioStation *s : m_favorites) {
QJsonObject o;
o.insert("uuid", s->uuid());
o.insert("name", s->name());
o.insert("url", s->url());
o.insert("urlResolved", s->urlResolved());
o.insert("country", s->country());
arr.append(o);
}
file.write(QJsonDocument(arr).toJson(QJsonDocument::Indented));
}
void RadioBackend::loadState()
{
const QString configDir = appConfigDir();
QDir().mkpath(configDir);
QFile file(configDir + "/state.json");
if (!file.open(QIODevice::ReadOnly)) {
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
if (!doc.isObject()) {
return;
}
const QJsonObject obj = doc.object();
m_lastStationName = obj.value("lastStationName").toString();
m_lastStationUrl = obj.value("lastStationUrl").toString();
const double volume = obj.value("volume").toDouble(0.5);
const QJsonArray recentArray = obj.value("recentStations").toArray();
m_recentStations.clear();
for (const QJsonValue &value : recentArray) {
if (!value.isObject()) {
continue;
}
const QVariantMap recentStation = value.toObject().toVariantMap();
if (recentStation.value(QStringLiteral("urlResolved")).toString().isEmpty()) {
continue;
}
m_recentStations.append(recentStation);
if (m_recentStations.size() >= kRecentLimit) {
break;
}
}
m_player->setVolume(volume);
emit resumeStateChanged();
}
void RadioBackend::saveState() const
{
const QString configDir = appConfigDir();
QDir().mkpath(configDir);
QFile file(configDir + "/state.json");
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return;
}
QJsonObject obj;
obj.insert("lastStationName", m_lastStationName);
obj.insert("lastStationUrl", m_lastStationUrl);
obj.insert("volume", m_player->volume());
obj.insert("recentStations", QJsonArray::fromVariantList(m_recentStations));
file.write(QJsonDocument(obj).toJson(QJsonDocument::Indented));
}
void RadioBackend::setLoading(bool loading)
{
if (m_loading == loading) {
return;
}
m_loading = loading;
emit loadingChanged();
}
void RadioBackend::setLastError(const QString &error)
{
if (m_lastError == error) {
return;
}
m_lastError = error;
emit lastErrorChanged();
}
QList<RadioStation*> RadioBackend::currentList() const
{
return m_currentFromFavorites ? m_favorites : m_filteredStations;
}
RadioStation *RadioBackend::stationForVisibleIndex(int index) const
{
if (index < 0 || index >= m_filteredStations.size()) {
return nullptr;
}
return m_filteredStations[index];
}
void RadioBackend::setFilterQuery(const QString &query)
{
if (m_filterQuery == query && !m_filteredStations.isEmpty() && !m_stations.isEmpty()) {
return;
}
m_filterQuery = query;
rebuildFilteredStations();
}
void RadioBackend::rebuildFilteredStations(bool emitFilterSignal)
{
m_filteredStations.clear();
if (m_filterQuery.trimmed().isEmpty()) {
m_filteredStations = m_stations;
} else {
const QString lowerQuery = m_filterQuery.toLower();
for (RadioStation *s : m_stations) {
if (s->name().toLower().contains(lowerQuery) ||
s->tags().toLower().contains(lowerQuery) ||
s->country().toLower().contains(lowerQuery)) {
m_filteredStations.append(s);
}
}
}
if (emitFilterSignal) {
emit filterQueryChanged();
}
emit stationsChanged();
emit listsChanged();
}
void RadioBackend::recordRecentStation(const QVariantMap &stationData)
{
const QString url = stationData.value(QStringLiteral("urlResolved")).toString();
if (url.isEmpty()) {
return;
}
QVariantMap normalizedStation = stationData;
if (!normalizedStation.contains(QStringLiteral("name"))) {
normalizedStation.insert(QStringLiteral("name"), QString());
}
for (int i = 0; i < m_recentStations.size(); ++i) {
const QVariantMap existing = m_recentStations[i].toMap();
const QString existingUuid = existing.value(QStringLiteral("uuid")).toString();
const QString newUuid = normalizedStation.value(QStringLiteral("uuid")).toString();
if ((!newUuid.isEmpty() && existingUuid == newUuid) ||
(newUuid.isEmpty() && existing.value(QStringLiteral("urlResolved")).toString() == url)) {
m_recentStations.removeAt(i);
break;
}
}
m_recentStations.prepend(normalizedStation);
while (m_recentStations.size() > kRecentLimit) {
m_recentStations.removeLast();
}
emit listsChanged();
}
void RadioBackend::playCurrentSelection()
{
const QList<RadioStation*> list = currentList();
if (list.isEmpty() || m_currentIndex < 0 || m_currentIndex >= list.size()) {
return;
}
RadioStation *station = list[m_currentIndex];
QString url = station->urlResolved().isEmpty()
? station->url()
: station->urlResolved();
m_lastStationName = station->name();
m_lastStationUrl = url;
emit resumeStateChanged();
recordRecentStation(toVariantMap(station));
saveState();
m_player->playUrl(url, station->name());
}
QVariantMap RadioBackend::toVariantMap(const RadioStation *station)
{
QVariantMap map;
if (!station) {
return map;
}
map.insert(QStringLiteral("uuid"), station->uuid());
map.insert(QStringLiteral("name"), station->name());
map.insert(QStringLiteral("country"), station->country());
map.insert(QStringLiteral("bitrate"), station->bitrate());
map.insert(QStringLiteral("isFavorite"), station->isFavorite());
map.insert(QStringLiteral("favicon"), station->favicon());
map.insert(QStringLiteral("urlResolved"), station->urlResolved());
return map;
}
bool RadioBackend::matchesStation(const RadioStation *station, const QString &uuid, const QString &urlResolved)
{
if (!station) {
return false;
}
if (!uuid.isEmpty() && station->uuid() == uuid) {
return true;
}
if (uuid.isEmpty() && !urlResolved.isEmpty() && station->urlResolved() == urlResolved) {
return true;
}
return false;
}

108
src/radiobackend.h Normal file
View File

@@ -0,0 +1,108 @@
#ifndef RADIOBACKEND_H
#define RADIOBACKEND_H
#include <QObject>
#include <QQmlEngine>
#include <QList>
#include <QVariantMap>
#include <QVariantList>
#include "radiobrowser.h"
#include "bearplayer.h"
class RadioStation;
class RadioBackend : public QObject
{
Q_OBJECT
Q_PROPERTY(QList<QObject*> stations READ stations NOTIFY stationsChanged)
Q_PROPERTY(QList<QObject*> favoriteStations READ favoriteStations NOTIFY favoritesChanged)
Q_PROPERTY(BearPlayer* player READ player CONSTANT)
Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged)
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
Q_PROPERTY(bool canResumeLastStation READ canResumeLastStation NOTIFY resumeStateChanged)
Q_PROPERTY(QString lastStationName READ lastStationName NOTIFY resumeStateChanged)
Q_PROPERTY(QString filterQuery READ filterQuery WRITE setFilterQuery NOTIFY filterQueryChanged)
public:
explicit RadioBackend(QObject *parent = nullptr);
QList<QObject*> stations() const;
QList<QObject*> favoriteStations() const;
BearPlayer* player() const { return m_player; }
bool loading() const { return m_loading; }
QString lastError() const { return m_lastError; }
bool canResumeLastStation() const { return !m_lastStationUrl.isEmpty(); }
QString lastStationName() const { return m_lastStationName; }
QString filterQuery() const { return m_filterQuery; }
Q_INVOKABLE void setFilterQuery(const QString &query);
Q_INVOKABLE void loadGermanStations();
Q_INVOKABLE void loadDutchStations();
Q_INVOKABLE void loadTopStations();
Q_INVOKABLE void loadWorldStations();
Q_INVOKABLE void loadByTag(const QString &tag);
Q_INVOKABLE void loadByCountryCode(const QString &countryCode);
Q_INVOKABLE void searchStations(const QString &query);
Q_INVOKABLE void playStation(int index);
Q_INVOKABLE void playFavoriteStation(int index);
Q_INVOKABLE void playNextStation();
Q_INVOKABLE void playPreviousStation();
Q_INVOKABLE bool hasNextStation() const;
Q_INVOKABLE bool hasPreviousStation() const;
Q_INVOKABLE void toggleFavorite(int index);
Q_INVOKABLE void toggleFavoriteById(const QString &uuid, const QString &urlResolved);
Q_INVOKABLE void addManualStation(const QString &name, const QString &url, const QString &country);
Q_INVOKABLE void sortStations(const QString &mode);
Q_INVOKABLE QVariantList getRecentStations() const;
Q_INVOKABLE QVariantList getFavoriteStations() const;
Q_INVOKABLE bool playRecentByUuid(const QString &uuid, const QString &urlResolved = QString());
Q_INVOKABLE bool playFavoriteByUuid(const QString &uuid, const QString &urlResolved = QString());
Q_INVOKABLE void resumeLastStation();
signals:
void stationsChanged();
void favoritesChanged();
void listsChanged();
void loadingChanged();
void lastErrorChanged();
void resumeStateChanged();
void filterQueryChanged();
private slots:
void onStationsLoaded(const QList<RadioStation*> &stations);
private:
RadioBrowser *m_radioBrowser = nullptr;
BearPlayer *m_player = nullptr;
QList<RadioStation*> m_stations;
QList<RadioStation*> m_filteredStations;
QList<RadioStation*> m_favorites;
int m_currentIndex = -1;
bool m_currentFromFavorites = false;
bool m_loading = false;
QString m_lastError;
QString m_lastStationName;
QString m_lastStationUrl;
QString m_filterQuery;
QVariantList m_recentStations;
void setupConnections();
void loadFavorites();
void saveFavorites() const;
void loadState();
void saveState() const;
void setLoading(bool loading);
void setLastError(const QString &error);
QList<RadioStation*> currentList() const;
RadioStation *stationForVisibleIndex(int index) const;
void rebuildFilteredStations(bool emitFilterSignal = true);
void recordRecentStation(const QVariantMap &stationData);
void playCurrentSelection();
static QVariantMap toVariantMap(const RadioStation *station);
static bool matchesStation(const RadioStation *station, const QString &uuid, const QString &urlResolved);
};
#endif

153
src/radiobrowser.cpp Normal file
View File

@@ -0,0 +1,153 @@
#include "radiobrowser.h"
#include <QJsonDocument>
#include <QJsonArray>
#include <QJsonObject>
#include <QUrl>
#include <QUrlQuery>
#include <QStandardPaths>
#include <QDir>
#include <QFile>
#include <QCryptographicHash>
namespace {
QString apiCacheDir()
{
return QDir::homePath() + QStringLiteral("/.cache/bearwave/api_cache");
}
}
RadioBrowser::RadioBrowser(QObject *parent)
: QObject(parent)
{
m_networkManager = new QNetworkAccessManager(this);
}
void RadioBrowser::search(const QString &query)
{
QString endpoint = "/stations/byname/" + QUrl::toPercentEncoding(query);
makeRequest(endpoint);
}
void RadioBrowser::getTopStations(int count)
{
QString endpoint = "/stations/topvote/" + QString::number(count);
makeRequest(endpoint);
}
void RadioBrowser::getByCountry(const QString &countryCode)
{
QString endpoint = "/stations/bycountrycodeexact/" + countryCode;
makeRequest(endpoint);
}
void RadioBrowser::getByTag(const QString &tag)
{
QString endpoint = "/stations/bytag/" + QUrl::toPercentEncoding(tag);
makeRequest(endpoint);
}
void RadioBrowser::getWorldStations(int count)
{
QString endpoint = "/stations?hidebroken=true&limit=" + QString::number(count) + "&order=votes&reverse=true";
makeRequest(endpoint);
}
void RadioBrowser::getGermanStations()
{
QString endpoint = "/stations/bycountrycodeexact/DE?limit=50&order=votes&reverse=true";
makeRequest(endpoint);
}
void RadioBrowser::getDutchStations()
{
QString endpoint = "/stations/bycountrycodeexact/NL?limit=50&order=votes&reverse=true";
makeRequest(endpoint);
}
void RadioBrowser::makeRequest(const QString &endpoint)
{
const QString cacheDir = apiCacheDir();
QDir().mkpath(cacheDir);
const QString hash = QString(QCryptographicHash::hash(endpoint.toUtf8(), QCryptographicHash::Md5).toHex());
const QString cachePath = cacheDir + "/" + hash + ".json";
QFile cacheFile(cachePath);
if (cacheFile.exists() && cacheFile.open(QIODevice::ReadOnly)) {
QByteArray cachedData = cacheFile.readAll();
QList<RadioStation*> stations = parseJsonResponse(cachedData);
if (!stations.isEmpty()) {
emit stationsLoaded(stations);
}
cacheFile.close();
}
QUrl url(m_baseUrl + endpoint);
QNetworkRequest request;
request.setUrl(url);
request.setHeader(QNetworkRequest::UserAgentHeader, "BearWave/1.0");
QNetworkReply *reply = m_networkManager->get(request);
reply->setProperty("cachePath", cachePath);
connect(reply, &QNetworkReply::finished, this, &RadioBrowser::onReplyFinished);
}
void RadioBrowser::onReplyFinished()
{
QNetworkReply *reply = qobject_cast<QNetworkReply*>(sender());
if (!reply) {
emit error("Network error");
return;
}
if (reply->error() != QNetworkReply::NoError) {
emit error(reply->errorString());
reply->deleteLater();
return;
}
QByteArray data = reply->readAll();
QList<RadioStation*> stations = parseJsonResponse(data);
QString cachePath = reply->property("cachePath").toString();
if (!cachePath.isEmpty() && !stations.isEmpty()) {
QFile file(cachePath);
if (file.open(QIODevice::WriteOnly)) {
file.write(data);
file.close();
}
}
emit stationsLoaded(stations);
reply->deleteLater();
}
QList<RadioStation*> RadioBrowser::parseJsonResponse(const QByteArray &jsonData)
{
QList<RadioStation*> stations;
QJsonDocument doc = QJsonDocument::fromJson(jsonData);
if (!doc.isArray()) {
return stations;
}
QJsonArray array = doc.array();
for (const QJsonValue &value : array) {
if (value.isObject()) {
QJsonObject obj = value.toObject();
QString name = obj.value("name").toString();
QString url = obj.value("url_resolved").toString();
if (name.isEmpty() || url.isEmpty()) {
continue;
}
RadioStation *station = RadioStation::fromJson(obj);
stations.append(station);
}
}
return stations;
}

41
src/radiobrowser.h Normal file
View File

@@ -0,0 +1,41 @@
#ifndef RADIOBROWSER_H
#define RADIOBROWSER_H
#include <QObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QList>
#include "radiostation.h"
class RadioBrowser : public QObject
{
Q_OBJECT
public:
explicit RadioBrowser(QObject *parent = nullptr);
void search(const QString &query);
void getTopStations(int count = 50);
void getByCountry(const QString &countryCode);
void getByTag(const QString &tag);
void getWorldStations(int count = 200);
void getGermanStations();
void getDutchStations();
signals:
void stationsLoaded(const QList<RadioStation*> &stations);
void error(const QString &message);
private slots:
void onReplyFinished();
private:
QNetworkAccessManager *m_networkManager = nullptr;
QString m_baseUrl = "https://de1.api.radio-browser.info/json";
void makeRequest(const QString &endpoint);
QList<RadioStation*> parseJsonResponse(const QByteArray &jsonData);
};
#endif

162
src/radiostation.cpp Normal file
View File

@@ -0,0 +1,162 @@
#include "radiostation.h"
#include <QJsonObject>
RadioStation::RadioStation(QObject *parent)
: QObject(parent)
{
}
QString RadioStation::uuid() const
{
return m_uuid;
}
void RadioStation::setUuid(const QString &uuid)
{
m_uuid = uuid;
}
QString RadioStation::name() const
{
return m_name;
}
void RadioStation::setName(const QString &name)
{
m_name = name;
}
QString RadioStation::url() const
{
return m_url;
}
void RadioStation::setUrl(const QString &url)
{
m_url = url;
}
QString RadioStation::urlResolved() const
{
return m_urlResolved;
}
void RadioStation::setUrlResolved(const QString &urlResolved)
{
m_urlResolved = urlResolved;
}
QString RadioStation::homepage() const
{
return m_homepage;
}
void RadioStation::setHomepage(const QString &homepage)
{
m_homepage = homepage;
}
QString RadioStation::favicon() const
{
return m_favicon;
}
void RadioStation::setFavicon(const QString &favicon)
{
m_favicon = favicon;
}
QString RadioStation::country() const
{
return m_country;
}
void RadioStation::setCountry(const QString &country)
{
m_country = country;
}
QString RadioStation::tags() const
{
return m_tags;
}
void RadioStation::setTags(const QString &tags)
{
m_tags = tags;
}
QString RadioStation::codec() const
{
return m_codec;
}
void RadioStation::setCodec(const QString &codec)
{
m_codec = codec;
}
int RadioStation::bitrate() const
{
return m_bitrate;
}
void RadioStation::setBitrate(int bitrate)
{
m_bitrate = bitrate;
}
int RadioStation::votes() const
{
return m_votes;
}
void RadioStation::setVotes(int votes)
{
m_votes = votes;
}
bool RadioStation::isOnline() const
{
return m_isOnline;
}
void RadioStation::setIsOnline(bool isOnline)
{
m_isOnline = isOnline;
}
bool RadioStation::isFavorite() const
{
return m_isFavorite;
}
void RadioStation::setIsFavorite(bool isFavorite)
{
if (m_isFavorite == isFavorite) {
return;
}
m_isFavorite = isFavorite;
emit isFavoriteChanged();
}
RadioStation* RadioStation::fromJson(const QJsonObject &json)
{
RadioStation *station = new RadioStation();
station->setUuid(json.value("stationuuid").toString());
station->setName(json.value("name").toString());
station->setUrl(json.value("url").toString());
station->setUrlResolved(json.value("url_resolved").toString());
station->setHomepage(json.value("homepage").toString());
station->setFavicon(json.value("favicon").toString());
station->setCountry(json.value("country").toString());
station->setTags(json.value("tags").toString());
station->setCodec(json.value("codec").toString());
station->setBitrate(json.value("bitrate").toInt());
station->setVotes(json.value("votes").toInt());
station->setIsOnline(json.value("lastcheckok").toInt() == 1);
return station;
}

89
src/radiostation.h Normal file
View File

@@ -0,0 +1,89 @@
#ifndef RADIOSTATION_H
#define RADIOSTATION_H
#include <QObject>
#include <QString>
#include <QVariant>
class RadioStation : public QObject
{
Q_OBJECT
Q_PROPERTY(QString uuid READ uuid CONSTANT)
Q_PROPERTY(QString name READ name CONSTANT)
Q_PROPERTY(QString url READ url CONSTANT)
Q_PROPERTY(QString urlResolved READ urlResolved CONSTANT)
Q_PROPERTY(QString homepage READ homepage CONSTANT)
Q_PROPERTY(QString favicon READ favicon CONSTANT)
Q_PROPERTY(QString country READ country CONSTANT)
Q_PROPERTY(QString tags READ tags CONSTANT)
Q_PROPERTY(QString codec READ codec CONSTANT)
Q_PROPERTY(int bitrate READ bitrate CONSTANT)
Q_PROPERTY(int votes READ votes CONSTANT)
Q_PROPERTY(bool isOnline READ isOnline CONSTANT)
Q_PROPERTY(bool isFavorite READ isFavorite WRITE setIsFavorite NOTIFY isFavoriteChanged)
public:
explicit RadioStation(QObject *parent = nullptr);
QString uuid() const;
void setUuid(const QString &uuid);
QString name() const;
void setName(const QString &name);
QString url() const;
void setUrl(const QString &url);
QString urlResolved() const;
void setUrlResolved(const QString &urlResolved);
QString homepage() const;
void setHomepage(const QString &homepage);
QString favicon() const;
void setFavicon(const QString &favicon);
QString country() const;
void setCountry(const QString &country);
QString tags() const;
void setTags(const QString &tags);
QString codec() const;
void setCodec(const QString &codec);
int bitrate() const;
void setBitrate(int bitrate);
int votes() const;
void setVotes(int votes);
bool isOnline() const;
void setIsOnline(bool isOnline);
bool isFavorite() const;
void setIsFavorite(bool isFavorite);
static RadioStation* fromJson(const QJsonObject &json);
signals:
void isFavoriteChanged();
private:
QString m_uuid;
QString m_name;
QString m_url;
QString m_urlResolved;
QString m_homepage;
QString m_favicon;
QString m_country;
QString m_tags;
QString m_codec;
int m_bitrate = 0;
int m_votes = 0;
bool m_isOnline = false;
bool m_isFavorite = false;
};
#endif