commit 51992dd437d90321877d11b4f8ee1a497a2c3800 Author: Sebastian Palencsar Date: Wed May 20 10:47:56 2026 +0200 Prepare BearWave for public repository diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8cef1af --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -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. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0086358 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..c484d9b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -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. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..dd3632a --- /dev/null +++ b/.github/workflows/build.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8fce981 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..00b4a15 --- /dev/null +++ b/CMakeLists.txt @@ -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) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ea7d6a1 --- /dev/null +++ b/CONTRIBUTING.md @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2c40287 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/PKGBUILD b/PKGBUILD new file mode 100644 index 0000000..94edfd8 --- /dev/null +++ b/PKGBUILD @@ -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" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..1b92811 --- /dev/null +++ b/README.md @@ -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. diff --git a/bearwave.png b/bearwave.png new file mode 100755 index 0000000..15b1335 Binary files /dev/null and b/bearwave.png differ diff --git a/github.png b/github.png new file mode 100755 index 0000000..57bb3dd Binary files /dev/null and b/github.png differ diff --git a/github.svg b/github.svg new file mode 100755 index 0000000..9d46af0 --- /dev/null +++ b/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/globe.png b/globe.png new file mode 100755 index 0000000..ffa5177 Binary files /dev/null and b/globe.png differ diff --git a/globe.svg b/globe.svg new file mode 100755 index 0000000..05bcaa2 --- /dev/null +++ b/globe.svg @@ -0,0 +1 @@ + diff --git a/linkedin.png b/linkedin.png new file mode 100755 index 0000000..a4af27e Binary files /dev/null and b/linkedin.png differ diff --git a/linkedin.svg b/linkedin.svg new file mode 100755 index 0000000..0112df8 --- /dev/null +++ b/linkedin.svg @@ -0,0 +1 @@ + diff --git a/org.kde.bearwave.desktop.in b/org.kde.bearwave.desktop.in new file mode 100644 index 0000000..842d279 --- /dev/null +++ b/org.kde.bearwave.desktop.in @@ -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 diff --git a/plasmoid-bearwave/contents/ui/main.qml b/plasmoid-bearwave/contents/ui/main.qml new file mode 100644 index 0000000..531ac5c --- /dev/null +++ b/plasmoid-bearwave/contents/ui/main.qml @@ -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] + } +} diff --git a/plasmoid-bearwave/metadata.json b/plasmoid-bearwave/metadata.json new file mode 100644 index 0000000..3edf0dd --- /dev/null +++ b/plasmoid-bearwave/metadata.json @@ -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" +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt new file mode 100644 index 0000000..b6777d0 --- /dev/null +++ b/src/CMakeLists.txt @@ -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) diff --git a/src/bearplayer.cpp b/src/bearplayer.cpp new file mode 100644 index 0000000..6718896 --- /dev/null +++ b/src/bearplayer.cpp @@ -0,0 +1,191 @@ +#include "bearplayer.h" + +#include +#include + +namespace { +QString firstMetaValue(const QMultiMap &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 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(); +} diff --git a/src/bearplayer.h b/src/bearplayer.h new file mode 100644 index 0000000..b31fca0 --- /dev/null +++ b/src/bearplayer.h @@ -0,0 +1,69 @@ +#ifndef BEARPLAYER_H +#define BEARPLAYER_H + +#include +#include + +#include +#include +#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 diff --git a/src/bearwavecontroladaptor.cpp b/src/bearwavecontroladaptor.cpp new file mode 100644 index 0000000..b72ee85 --- /dev/null +++ b/src/bearwavecontroladaptor.cpp @@ -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(); + } +} diff --git a/src/bearwavecontroladaptor.h b/src/bearwavecontroladaptor.h new file mode 100644 index 0000000..216e4a4 --- /dev/null +++ b/src/bearwavecontroladaptor.h @@ -0,0 +1,36 @@ +#ifndef BEARWAVECONTROLADAPTOR_H +#define BEARWAVECONTROLADAPTOR_H + +#include +#include + +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 diff --git a/src/coverartfetcher.cpp b/src/coverartfetcher.cpp new file mode 100644 index 0000000..3f8e08f --- /dev/null +++ b/src/coverartfetcher.cpp @@ -0,0 +1,82 @@ +#include "coverartfetcher.h" +#include +#include +#include +#include +#include + +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(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); +} diff --git a/src/coverartfetcher.h b/src/coverartfetcher.h new file mode 100644 index 0000000..84c0e5a --- /dev/null +++ b/src/coverartfetcher.h @@ -0,0 +1,28 @@ +#ifndef COVERARTFETCHER_H +#define COVERARTFETCHER_H + +#include +#include +#include + +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 diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..9f63661 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,55 @@ +#include +#include +#include +#include +#include + +#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(); +} diff --git a/src/mprisadaptor.cpp b/src/mprisadaptor.cpp new file mode 100644 index 0000000..e6c3d9b --- /dev/null +++ b/src/mprisadaptor.cpp @@ -0,0 +1,320 @@ +#include "mprisadaptor.h" + +#include "radiobackend.h" +#include "bearplayer.h" + +#include +#include +#include +#include +#include + +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(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(); + } +} diff --git a/src/mprisadaptor.h b/src/mprisadaptor.h new file mode 100644 index 0000000..bd3ffb0 --- /dev/null +++ b/src/mprisadaptor.h @@ -0,0 +1,104 @@ +#ifndef MPRISADAPTOR_H +#define MPRISADAPTOR_H + +#include +#include +#include +#include +#include + +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 diff --git a/src/qml.qrc b/src/qml.qrc new file mode 100644 index 0000000..34f8516 --- /dev/null +++ b/src/qml.qrc @@ -0,0 +1,9 @@ + + + qml/Main.qml + ../bearwave.png + ../globe.svg + ../github.svg + ../linkedin.svg + + diff --git a/src/qml/Main.qml b/src/qml/Main.qml new file mode 100644 index 0000000..b92251b --- /dev/null +++ b/src/qml/Main.qml @@ -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 + } +} diff --git a/src/radiobackend.cpp b/src/radiobackend.cpp new file mode 100644 index 0000000..256750a --- /dev/null +++ b/src/radiobackend.cpp @@ -0,0 +1,668 @@ +#include "radiobackend.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +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 RadioBackend::stations() const +{ + QList list; + for (RadioStation *s : m_filteredStations) { + list.append(s); + } + return list; +} + +QList RadioBackend::favoriteStations() const +{ + QList 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 &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 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 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 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 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 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 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; +} diff --git a/src/radiobackend.h b/src/radiobackend.h new file mode 100644 index 0000000..221c9e0 --- /dev/null +++ b/src/radiobackend.h @@ -0,0 +1,108 @@ +#ifndef RADIOBACKEND_H +#define RADIOBACKEND_H + +#include +#include +#include +#include +#include + +#include "radiobrowser.h" +#include "bearplayer.h" + +class RadioStation; + +class RadioBackend : public QObject +{ + Q_OBJECT + + Q_PROPERTY(QList stations READ stations NOTIFY stationsChanged) + Q_PROPERTY(QList 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 stations() const; + QList 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 &stations); + +private: + RadioBrowser *m_radioBrowser = nullptr; + BearPlayer *m_player = nullptr; + QList m_stations; + QList m_filteredStations; + QList 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 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 diff --git a/src/radiobrowser.cpp b/src/radiobrowser.cpp new file mode 100644 index 0000000..fe2896a --- /dev/null +++ b/src/radiobrowser.cpp @@ -0,0 +1,153 @@ +#include "radiobrowser.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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(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 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 RadioBrowser::parseJsonResponse(const QByteArray &jsonData) +{ + QList 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; +} diff --git a/src/radiobrowser.h b/src/radiobrowser.h new file mode 100644 index 0000000..6d2d479 --- /dev/null +++ b/src/radiobrowser.h @@ -0,0 +1,41 @@ +#ifndef RADIOBROWSER_H +#define RADIOBROWSER_H + +#include +#include +#include +#include + +#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 &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 parseJsonResponse(const QByteArray &jsonData); +}; + +#endif diff --git a/src/radiostation.cpp b/src/radiostation.cpp new file mode 100644 index 0000000..6648bbb --- /dev/null +++ b/src/radiostation.cpp @@ -0,0 +1,162 @@ +#include "radiostation.h" + +#include + +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; +} diff --git a/src/radiostation.h b/src/radiostation.h new file mode 100644 index 0000000..a227821 --- /dev/null +++ b/src/radiostation.h @@ -0,0 +1,89 @@ +#ifndef RADIOSTATION_H +#define RADIOSTATION_H + +#include +#include +#include + +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