From 9402dca7edddff3bdffa2f4ef0b5b6a421cac9ad Mon Sep 17 00:00:00 2001 From: joren Date: Mon, 23 Mar 2026 23:34:23 +0100 Subject: [PATCH] Initial implementation of qobuz-qt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rust backend (qobuz-backend static lib): Qobuz API client (reqwest/tokio), Symphonia audio decoder, CPAL audio output, extern "C" FFI bridge - Qt 6 frontend mirroring spotify-qt layout: toolbar with playback controls, left library dock, central track list, right search panel - Auth: email/password login with MD5-signed requests; session token persisted via QSettings - Playback: double-click a track → Rust fetches stream URL → Symphonia decodes → CPAL outputs to default audio device - Dark Fusion palette matching spotify-qt feel Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 6 + BUILD.md | 83 ++++++ CMakeLists.txt | 95 +++++++ Cargo.toml | 7 + res.qrc | 6 + rust/Cargo.toml | 33 +++ rust/include/qobuz_backend.h | 79 ++++++ rust/src/api/client.rs | 371 +++++++++++++++++++++++++ rust/src/api/mod.rs | 5 + rust/src/api/models.rs | 260 ++++++++++++++++++ rust/src/lib.rs | 486 +++++++++++++++++++++++++++++++++ rust/src/player/decoder.rs | 207 ++++++++++++++ rust/src/player/mod.rs | 186 +++++++++++++ rust/src/player/output.rs | 81 ++++++ src/CMakeLists.txt | 44 +++ src/backend/qobuzbackend.cpp | 221 +++++++++++++++ src/backend/qobuzbackend.hpp | 94 +++++++ src/dialog/login.cpp | 80 ++++++ src/dialog/login.hpp | 32 +++ src/dialog/settings.cpp | 54 ++++ src/dialog/settings.hpp | 18 ++ src/list/library.cpp | 110 ++++++++ src/list/library.hpp | 40 +++ src/list/tracks.cpp | 115 ++++++++ src/list/tracks.hpp | 40 +++ src/main.cpp | 39 +++ src/mainwindow.cpp | 251 +++++++++++++++++ src/mainwindow.hpp | 52 ++++ src/model/tracklistmodel.cpp | 113 ++++++++ src/model/tracklistmodel.hpp | 57 ++++ src/util/icon.hpp | 32 +++ src/util/settings.hpp | 37 +++ src/view/maincontent.cpp | 58 ++++ src/view/maincontent.hpp | 34 +++ src/view/maintoolbar.cpp | 173 ++++++++++++ src/view/maintoolbar.hpp | 63 +++++ src/view/sidepanel/view.cpp | 139 ++++++++++ src/view/sidepanel/view.hpp | 55 ++++ src/widget/clickableslider.hpp | 32 +++ src/widget/volumebutton.hpp | 75 +++++ 40 files changed, 3963 insertions(+) create mode 100644 .gitignore create mode 100644 BUILD.md create mode 100644 CMakeLists.txt create mode 100644 Cargo.toml create mode 100644 res.qrc create mode 100644 rust/Cargo.toml create mode 100644 rust/include/qobuz_backend.h create mode 100644 rust/src/api/client.rs create mode 100644 rust/src/api/mod.rs create mode 100644 rust/src/api/models.rs create mode 100644 rust/src/lib.rs create mode 100644 rust/src/player/decoder.rs create mode 100644 rust/src/player/mod.rs create mode 100644 rust/src/player/output.rs create mode 100644 src/CMakeLists.txt create mode 100644 src/backend/qobuzbackend.cpp create mode 100644 src/backend/qobuzbackend.hpp create mode 100644 src/dialog/login.cpp create mode 100644 src/dialog/login.hpp create mode 100644 src/dialog/settings.cpp create mode 100644 src/dialog/settings.hpp create mode 100644 src/list/library.cpp create mode 100644 src/list/library.hpp create mode 100644 src/list/tracks.cpp create mode 100644 src/list/tracks.hpp create mode 100644 src/main.cpp create mode 100644 src/mainwindow.cpp create mode 100644 src/mainwindow.hpp create mode 100644 src/model/tracklistmodel.cpp create mode 100644 src/model/tracklistmodel.hpp create mode 100644 src/util/icon.hpp create mode 100644 src/util/settings.hpp create mode 100644 src/view/maincontent.cpp create mode 100644 src/view/maincontent.hpp create mode 100644 src/view/maintoolbar.cpp create mode 100644 src/view/maintoolbar.hpp create mode 100644 src/view/sidepanel/view.cpp create mode 100644 src/view/sidepanel/view.hpp create mode 100644 src/widget/clickableslider.hpp create mode 100644 src/widget/volumebutton.hpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98ee006 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +build/ +target/ +*.o +*.a +*.so +Cargo.lock diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..aa818aa --- /dev/null +++ b/BUILD.md @@ -0,0 +1,83 @@ +# Building qobuz-qt + +## Dependencies + +### Debian / Ubuntu +```bash +sudo apt install -y \ + cmake \ + ninja-build \ + qt6-base-dev \ + qt6-svg-dev \ + libssl-dev \ + libasound2-dev \ + libdbus-1-dev \ + pkg-config \ + curl + +# Rust toolchain +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +source "$HOME/.cargo/env" +``` + +### Arch Linux +```bash +sudo pacman -S cmake ninja qt6-base qt6-svg openssl alsa-lib rust +``` + +### Fedora +```bash +sudo dnf install cmake ninja-build qt6-qtbase-devel qt6-qtsvg-devel \ + openssl-devel alsa-lib-devel rust cargo +``` + +## Build + +```bash +cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake --build build -j$(nproc) +``` + +The first build will take longer as it compiles the Rust backend via +[Corrosion](https://github.com/corrosion-rs/corrosion). + +## Run + +```bash +./build/qobuz-qt +``` + +On first launch a login dialog appears. Enter your Qobuz email and password. +Credentials are stored via `QSettings` in the standard user config directory. + +## Audio Quality + +Open **File → Settings** to choose the preferred streaming quality: +- Hi-Res 24-bit/192 kHz (format 27) +- Hi-Res 24-bit/96 kHz (format 7) +- CD 16-bit (format 6) ← default +- MP3 320 kbps (format 5) + +## Architecture + +``` +qobuz-qt/ +├── CMakeLists.txt Root build file (uses Corrosion for Rust) +├── Cargo.toml Cargo workspace +├── rust/ Rust backend (static library) +│ ├── Cargo.toml +│ ├── include/ +│ │ └── qobuz_backend.h C header for FFI +│ └── src/ +│ ├── lib.rs extern "C" API + Tokio runtime +│ ├── api/ Qobuz HTTP API client (reqwest + tokio) +│ └── player/ Audio playback (Symphonia decoder + CPAL output) +└── src/ C++ / Qt 6 frontend + ├── backend/ Qt QObject wrapper around the Rust library + ├── view/ Toolbar, main content, side panel + ├── list/ Track list, library sidebar + ├── model/ TrackListModel (QAbstractTableModel) + ├── dialog/ Login, Settings dialogs + ├── widget/ VolumeButton, ClickableSlider + └── util/ AppSettings (QSettings), Icon helpers +``` diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..40a8f72 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,95 @@ +cmake_minimum_required(VERSION 3.21) + +project(qobuz-qt LANGUAGES CXX VERSION 0.1.0) + +set(CMAKE_AUTOUIC ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Optional D-Bus support +option(USE_DBUS "Use D-Bus integration" ON) + +# LTO support +option(USE_LTO "Use link time optimization" OFF) + +if (POLICY CMP0072) + cmake_policy(SET CMP0072 NEW) +endif () + +# Find Qt 6 +find_package(Qt6 COMPONENTS Core Widgets Network Gui Svg REQUIRED) +if (USE_DBUS) + find_package(Qt6 OPTIONAL_COMPONENTS DBus QUIET) +endif () + +# ----- Rust backend via corrosion ----- +include(FetchContent) +FetchContent_Declare( + Corrosion + GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git + GIT_TAG v0.5 +) +FetchContent_MakeAvailable(Corrosion) + +corrosion_import_crate(MANIFEST_PATH rust/Cargo.toml CRATES qobuz-backend) + +# Create main executable +add_executable(qobuz-qt res.qrc) + +# Source files +add_subdirectory(src) + +# Include paths +target_include_directories(qobuz-qt PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src" + "${CMAKE_CURRENT_SOURCE_DIR}/rust/include" +) + +# Version / app definitions +target_compile_definitions(qobuz-qt PRIVATE APP_VERSION="v${PROJECT_VERSION}") +target_compile_definitions(qobuz-qt PRIVATE APP_NAME="${PROJECT_NAME}") + +# Link Qt + Rust backend +target_link_libraries(qobuz-qt PRIVATE + Qt6::Core + Qt6::Widgets + Qt6::Network + Qt6::Gui + Qt6::Svg + qobuz-backend +) + +# Platform-specific system libs needed by the Rust audio stack (cpal/ALSA) +if (UNIX AND NOT APPLE) + target_link_libraries(qobuz-qt PRIVATE asound) +endif () + +# Compiler warnings +if (CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + target_compile_options(qobuz-qt PRIVATE -Wall -Wextra -Wno-unused-parameter) +endif () + +# D-Bus +if (Qt6DBus_FOUND) + target_compile_definitions(qobuz-qt PRIVATE USE_DBUS) + target_link_libraries(qobuz-qt PRIVATE Qt6::DBus) +endif () + +# LTO +if (USE_LTO) + include(CheckIPOSupported) + check_ipo_supported(RESULT ipo_supported OUTPUT ipo_error) + if (ipo_supported AND CMAKE_BUILD_TYPE STREQUAL "Release") + set_property(TARGET qobuz-qt PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE) + endif () +endif () + +# Install +if (UNIX) + install(FILES res/logo/qobuz-qt.svg DESTINATION share/icons/hicolor/scalable/apps) + install(FILES res/app/qobuz-qt.desktop DESTINATION share/applications) + install(TARGETS qobuz-qt RUNTIME DESTINATION bin) +endif () diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ce5db8a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,7 @@ +[workspace] +members = ["rust"] +resolver = "2" + +[profile.release] +lto = "thin" +opt-level = 3 diff --git a/res.qrc b/res.qrc new file mode 100644 index 0000000..34c02d5 --- /dev/null +++ b/res.qrc @@ -0,0 +1,6 @@ + + + + + + diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..9b9974f --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "qobuz-backend" +version = "0.1.0" +edition = "2021" + +[lib] +name = "qobuz_backend" +crate-type = ["staticlib"] + +[dependencies] +# HTTP client – blocking feature is needed by the audio decoder +reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream", "blocking"], default-features = false } +tokio = { version = "1", features = ["full"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Audio decoding + output +symphonia = { version = "0.5", features = ["all"] } +cpal = { version = "0.15", features = ["jack"] } +rb = "0.3" + +# Misc +anyhow = "1" +thiserror = "2" +md5 = "0.7" +dirs = "5" +toml = "0.8" + +[profile.release] +lto = "thin" +opt-level = 3 diff --git a/rust/include/qobuz_backend.h b/rust/include/qobuz_backend.h new file mode 100644 index 0000000..783c9bb --- /dev/null +++ b/rust/include/qobuz_backend.h @@ -0,0 +1,79 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Opaque handle +typedef struct QobuzBackendOpaque QobuzBackendOpaque; + +// Event type constants (mirror lib.rs) +enum QobuzEvent { + EV_LOGIN_OK = 0, + EV_LOGIN_ERR = 1, + EV_SEARCH_OK = 2, + EV_SEARCH_ERR = 3, + EV_ALBUM_OK = 4, + EV_ALBUM_ERR = 5, + EV_ARTIST_OK = 6, + EV_ARTIST_ERR = 7, + EV_PLAYLIST_OK = 8, + EV_PLAYLIST_ERR = 9, + EV_FAV_TRACKS_OK = 10, + EV_FAV_ALBUMS_OK = 11, + EV_FAV_ARTISTS_OK = 12, + EV_PLAYLISTS_OK = 13, + EV_TRACK_CHANGED = 14, + EV_STATE_CHANGED = 15, + EV_POSITION = 16, + EV_TRACK_URL_OK = 17, + EV_TRACK_URL_ERR = 18, + EV_GENERIC_ERR = 19, +}; + +// Callback signature +typedef void (*QobuzEventCallback)(void *userdata, int event_type, const char *json); + +// Lifecycle +QobuzBackendOpaque *qobuz_backend_new(QobuzEventCallback cb, void *userdata); +void qobuz_backend_free(QobuzBackendOpaque *backend); + +// Auth +void qobuz_backend_login(QobuzBackendOpaque *backend, const char *email, const char *password); +void qobuz_backend_set_token(QobuzBackendOpaque *backend, const char *token); + +// Catalog +void qobuz_backend_search(QobuzBackendOpaque *backend, const char *query, uint32_t offset, uint32_t limit); +void qobuz_backend_get_album(QobuzBackendOpaque *backend, const char *album_id); +void qobuz_backend_get_artist(QobuzBackendOpaque *backend, int64_t artist_id); +void qobuz_backend_get_playlist(QobuzBackendOpaque *backend, int64_t playlist_id, uint32_t offset, uint32_t limit); + +// Favorites +void qobuz_backend_get_fav_tracks(QobuzBackendOpaque *backend, uint32_t offset, uint32_t limit); +void qobuz_backend_get_fav_albums(QobuzBackendOpaque *backend, uint32_t offset, uint32_t limit); +void qobuz_backend_get_fav_artists(QobuzBackendOpaque *backend, uint32_t offset, uint32_t limit); +void qobuz_backend_get_user_playlists(QobuzBackendOpaque *backend, uint32_t offset, uint32_t limit); + +// Playback +void qobuz_backend_play_track(QobuzBackendOpaque *backend, int64_t track_id, int32_t format_id); +void qobuz_backend_pause(QobuzBackendOpaque *backend); +void qobuz_backend_resume(QobuzBackendOpaque *backend); +void qobuz_backend_stop(QobuzBackendOpaque *backend); +void qobuz_backend_set_volume(QobuzBackendOpaque *backend, uint8_t volume); +uint64_t qobuz_backend_get_position(const QobuzBackendOpaque *backend); +uint64_t qobuz_backend_get_duration(const QobuzBackendOpaque *backend); +uint8_t qobuz_backend_get_volume(const QobuzBackendOpaque *backend); +int qobuz_backend_get_state(const QobuzBackendOpaque *backend); +int qobuz_backend_take_track_finished(QobuzBackendOpaque *backend); + +// Favorites modification +void qobuz_backend_add_fav_track(QobuzBackendOpaque *backend, int64_t track_id); +void qobuz_backend_remove_fav_track(QobuzBackendOpaque *backend, int64_t track_id); +void qobuz_backend_add_fav_album(QobuzBackendOpaque *backend, const char *album_id); +void qobuz_backend_remove_fav_album(QobuzBackendOpaque *backend, const char *album_id); + +#ifdef __cplusplus +} +#endif diff --git a/rust/src/api/client.rs b/rust/src/api/client.rs new file mode 100644 index 0000000..0dc7f05 --- /dev/null +++ b/rust/src/api/client.rs @@ -0,0 +1,371 @@ +use anyhow::{bail, Result}; +use reqwest::Client; +use serde_json::Value; +use std::time::{SystemTime, UNIX_EPOCH}; + +use super::models::*; + +const BASE_URL: &str = "https://www.qobuz.com/api.json/0.2/"; +const USER_AGENT: &str = + "Dalvik/2.1.0 (Linux; U; Android 9; Nexus 6P Build/PQ3A.190801.002) QobuzMobileAndroid/9.7.0.3-b26022717"; +const APP_VERSION: &str = "9.7.0.3"; +pub const DEFAULT_APP_ID: &str = "312369995"; +pub const DEFAULT_APP_SECRET: &str = "e79f8b9be485692b0e5f9dd895826368"; + +pub struct QobuzClient { + http: Client, + pub auth_token: Option, + app_id: String, + app_secret: String, +} + +impl QobuzClient { + pub fn new() -> Result { + Self::new_with_config(None, None) + } + + pub fn new_with_config(app_id: Option<&str>, app_secret: Option<&str>) -> Result { + let app_id = app_id.unwrap_or(DEFAULT_APP_ID).to_string(); + let app_secret = app_secret.unwrap_or(DEFAULT_APP_SECRET).to_string(); + + let http = Client::builder() + .user_agent(USER_AGENT) + .default_headers({ + let mut h = reqwest::header::HeaderMap::new(); + h.insert("X-App-Id", app_id.parse()?); + h.insert("X-App-Version", APP_VERSION.parse()?); + h.insert("X-Device-Platform", "android".parse()?); + h.insert("X-Device-Model", "Nexus 6P".parse()?); + h.insert("X-Device-Os-Version", "9".parse()?); + h + }) + .build()?; + + Ok(Self { + http, + auth_token: None, + app_id, + app_secret, + }) + } + + pub fn set_auth_token(&mut self, token: String) { + self.auth_token = Some(token); + } + + fn ts() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + } + + fn request_sig(&self, method: &str, params: &mut Vec<(&str, String)>, ts: u64) -> String { + params.sort_by_key(|(k, _)| *k); + let mut s = method.replace('/', ""); + for (k, v) in params.iter() { + s.push_str(k); + s.push_str(v); + } + s.push_str(&ts.to_string()); + s.push_str(&self.app_secret); + format!("{:x}", md5::compute(s.as_bytes())) + } + + fn url(&self, method: &str) -> String { + format!("{}{}", BASE_URL, method) + } + + async fn check_response(resp: reqwest::Response) -> Result { + let status = resp.status(); + let body: Value = resp.json().await?; + if !status.is_success() { + let msg = body + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("unknown API error"); + bail!("HTTP {}: {}", status, msg); + } + Ok(body) + } + + fn get_request(&self, method: &str) -> reqwest::RequestBuilder { + let mut builder = self.http.get(self.url(method)); + builder = builder.query(&[("app_id", self.app_id.as_str())]); + if let Some(token) = &self.auth_token { + builder = builder.header("Authorization", format!("Bearer {}", token)); + } + builder + } + + // --- Auth --- + + pub async fn login(&mut self, email: &str, password: &str) -> Result { + match self.oauth2_login(email, password).await { + Ok(r) => Ok(r), + Err(_) => self.legacy_login(email, password).await, + } + } + + async fn oauth2_login(&mut self, email: &str, password: &str) -> Result { + let ts = Self::ts(); + let mut sign_params: Vec<(&str, String)> = vec![ + ("password", password.to_string()), + ("username", email.to_string()), + ]; + let sig = self.request_sig("oauth2login", &mut sign_params, ts); + + let resp = self + .http + .get(self.url("oauth2/login")) + .query(&[ + ("app_id", self.app_id.as_str()), + ("username", email), + ("password", password), + ("request_ts", ts.to_string().as_str()), + ("request_sig", sig.as_str()), + ]) + .send() + .await?; + + let status = resp.status(); + let body: Value = resp.json().await?; + if !status.is_success() { + let msg = body.get("message").and_then(|m| m.as_str()).unwrap_or("login failed"); + bail!("oauth2 login failed ({}): {}", status, msg); + } + + self.extract_and_store_token(serde_json::from_value(body)?) + } + + async fn legacy_login(&mut self, email: &str, password: &str) -> Result { + let ts = Self::ts(); + let mut sign_params: Vec<(&str, String)> = vec![ + ("email", email.to_string()), + ("password", password.to_string()), + ]; + let sig = self.request_sig("userlogin", &mut sign_params, ts); + + let resp = self + .http + .get(self.url("user/login")) + .query(&[ + ("app_id", self.app_id.as_str()), + ("email", email), + ("password", password), + ("request_ts", ts.to_string().as_str()), + ("request_sig", sig.as_str()), + ]) + .send() + .await?; + + let status = resp.status(); + let body: Value = resp.json().await?; + if !status.is_success() { + let msg = body.get("message").and_then(|m| m.as_str()).unwrap_or("login failed"); + bail!("user login failed ({}): {}", status, msg); + } + + self.extract_and_store_token(serde_json::from_value(body)?) + } + + fn extract_and_store_token(&mut self, login: OAuthLoginResponse) -> Result { + if let Some(token) = login + .oauth2 + .as_ref() + .and_then(|o| o.access_token.clone()) + .or_else(|| login.user_auth_token.clone()) + { + self.auth_token = Some(token); + } + Ok(login) + } + + // --- User --- + + pub async fn get_user(&self) -> Result { + let resp = self.get_request("user/get").send().await?; + let body = Self::check_response(resp).await?; + let user: UserDto = serde_json::from_value(body["user"].clone()) + .or_else(|_| serde_json::from_value(body.clone()))?; + Ok(user) + } + + // --- Track --- + + pub async fn get_track(&self, track_id: i64) -> Result { + let resp = self + .get_request("track/get") + .query(&[("track_id", track_id.to_string())]) + .send() + .await?; + let body = Self::check_response(resp).await?; + Ok(serde_json::from_value(body)?) + } + + pub async fn get_track_url(&self, track_id: i64, format: Format) -> Result { + let ts = Self::ts(); + let intent = "stream"; + let mut sign_params: Vec<(&str, String)> = vec![ + ("format_id", format.id().to_string()), + ("intent", intent.to_string()), + ("track_id", track_id.to_string()), + ]; + let sig = self.request_sig("trackgetFileUrl", &mut sign_params, ts); + + let resp = self + .get_request("track/getFileUrl") + .query(&[ + ("track_id", track_id.to_string()), + ("format_id", format.id().to_string()), + ("intent", intent.to_string()), + ("request_ts", ts.to_string()), + ("request_sig", sig), + ]) + .send() + .await?; + + let body = Self::check_response(resp).await?; + Ok(serde_json::from_value(body)?) + } + + // --- Album --- + + pub async fn get_album(&self, album_id: &str) -> Result { + let resp = self + .get_request("album/get") + .query(&[("album_id", album_id), ("limit", "50"), ("offset", "0")]) + .send() + .await?; + let body = Self::check_response(resp).await?; + Ok(serde_json::from_value(body)?) + } + + // --- Artist --- + + pub async fn get_artist(&self, artist_id: i64) -> Result { + let resp = self + .get_request("artist/get") + .query(&[ + ("artist_id", artist_id.to_string()), + ("extra", "albums,biography".to_string()), + ]) + .send() + .await?; + let body = Self::check_response(resp).await?; + Ok(serde_json::from_value(body)?) + } + + // --- Search --- + + pub async fn search(&self, query: &str, offset: u32, limit: u32) -> Result { + let resp = self + .get_request("catalog/search") + .query(&[ + ("query", query), + ("offset", &offset.to_string()), + ("limit", &limit.to_string()), + ]) + .send() + .await?; + let body = Self::check_response(resp).await?; + Ok(serde_json::from_value(body)?) + } + + // --- Favorites / Library --- + + pub async fn get_user_playlists(&self, offset: u32, limit: u32) -> Result { + let resp = self + .get_request("playlist/getUserPlaylists") + .query(&[("offset", &offset.to_string()), ("limit", &limit.to_string())]) + .send() + .await?; + let body = Self::check_response(resp).await?; + Ok(serde_json::from_value(body)?) + } + + pub async fn get_playlist(&self, playlist_id: i64, offset: u32, limit: u32) -> Result { + let resp = self + .get_request("playlist/get") + .query(&[ + ("playlist_id", &playlist_id.to_string()), + ("extra", &"tracks".to_string()), + ("offset", &offset.to_string()), + ("limit", &limit.to_string()), + ]) + .send() + .await?; + let body = Self::check_response(resp).await?; + Ok(serde_json::from_value(body)?) + } + + pub async fn get_fav_tracks(&self, offset: u32, limit: u32) -> Result> { + let resp = self + .get_request("favorite/getUserFavorites") + .query(&[("type", "tracks"), ("offset", &offset.to_string()), ("limit", &limit.to_string())]) + .send() + .await?; + let body = Self::check_response(resp).await?; + Ok(serde_json::from_value(body["tracks"].clone())?) + } + + pub async fn get_fav_albums(&self, offset: u32, limit: u32) -> Result> { + let resp = self + .get_request("favorite/getUserFavorites") + .query(&[("type", "albums"), ("offset", &offset.to_string()), ("limit", &limit.to_string())]) + .send() + .await?; + let body = Self::check_response(resp).await?; + Ok(serde_json::from_value(body["albums"].clone())?) + } + + pub async fn get_fav_artists(&self, offset: u32, limit: u32) -> Result> { + let resp = self + .get_request("favorite/getUserFavorites") + .query(&[("type", "artists"), ("offset", &offset.to_string()), ("limit", &limit.to_string())]) + .send() + .await?; + let body = Self::check_response(resp).await?; + Ok(serde_json::from_value(body["artists"].clone())?) + } + + pub async fn add_fav_track(&self, track_id: i64) -> Result<()> { + let resp = self + .get_request("favorite/create") + .query(&[("type", "tracks"), ("track_ids", &track_id.to_string())]) + .send() + .await?; + Self::check_response(resp).await?; + Ok(()) + } + + pub async fn remove_fav_track(&self, track_id: i64) -> Result<()> { + let resp = self + .get_request("favorite/delete") + .query(&[("type", "tracks"), ("track_ids", &track_id.to_string())]) + .send() + .await?; + Self::check_response(resp).await?; + Ok(()) + } + + pub async fn add_fav_album(&self, album_id: &str) -> Result<()> { + let resp = self + .get_request("favorite/create") + .query(&[("type", "albums"), ("album_ids", album_id)]) + .send() + .await?; + Self::check_response(resp).await?; + Ok(()) + } + + pub async fn remove_fav_album(&self, album_id: &str) -> Result<()> { + let resp = self + .get_request("favorite/delete") + .query(&[("type", "albums"), ("album_ids", album_id)]) + .send() + .await?; + Self::check_response(resp).await?; + Ok(()) + } +} diff --git a/rust/src/api/mod.rs b/rust/src/api/mod.rs new file mode 100644 index 0000000..6f6974c --- /dev/null +++ b/rust/src/api/mod.rs @@ -0,0 +1,5 @@ +pub mod client; +pub mod models; + +pub use client::QobuzClient; +pub use models::*; diff --git a/rust/src/api/models.rs b/rust/src/api/models.rs new file mode 100644 index 0000000..5049ce8 --- /dev/null +++ b/rust/src/api/models.rs @@ -0,0 +1,260 @@ +use serde::{Deserialize, Serialize}; + +// --- Auth --- + +#[derive(Debug, Deserialize, Clone, Serialize)] +pub struct OAuthDto { + pub token_type: Option, + pub access_token: Option, + pub refresh_token: Option, + pub expires_in: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct OAuthLoginResponse { + pub status: Option, + pub user: Option, + pub oauth2: Option, + pub user_auth_token: Option, +} + +// --- User --- + +#[derive(Debug, Deserialize, Clone, Serialize)] +pub struct UserDto { + pub id: Option, + pub login: Option, + pub firstname: Option, + pub lastname: Option, + pub email: Option, + pub display_name: Option, + pub country_code: Option, + pub subscription: Option, +} + +#[derive(Debug, Deserialize, Clone, Serialize)] +pub struct SubscriptionDto { + pub description: Option, + pub end_date: Option, + pub is_recurring: Option, + pub offer: Option, +} + +// --- Track --- + +#[derive(Debug, Deserialize, Clone, Serialize)] +pub struct TrackDto { + pub id: i64, + pub title: Option, + pub duration: Option, + pub track_number: Option, + pub album: Option, + pub performer: Option, + pub composer: Option, + pub work: Option, + pub media_number: Option, + pub streamable: Option, + pub purchasable: Option, + pub hires: Option, + pub hires_streamable: Option, + pub audio_info: Option, + pub maximum_bit_depth: Option, + pub maximum_sampling_rate: Option, + pub maximum_channel_count: Option, +} + +#[derive(Debug, Deserialize, Clone, Serialize)] +pub struct AudioInfoDto { + pub replaygain_track_gain: Option, + pub replaygain_track_peak: Option, +} + +#[derive(Debug, Deserialize, Clone, Serialize)] +pub struct TrackFileUrlDto { + pub track_id: Option, + pub duration: Option, + pub url: Option, + pub format_id: Option, + pub mime_type: Option, + pub sampling_rate: Option, + pub bit_depth: Option, +} + +// --- Album --- + +#[derive(Debug, Deserialize, Clone, Serialize)] +pub struct AlbumDto { + pub id: Option, + pub title: Option, + pub artist: Option, + pub tracks_count: Option, + pub duration: Option, + pub genre: Option, + pub image: Option, + pub label: Option, + pub release_date_original: Option, + pub maximum_bit_depth: Option, + pub maximum_sampling_rate: Option, + pub hires_streamable: Option, + pub streamable: Option, + pub tracks: Option, +} + +#[derive(Debug, Deserialize, Clone, Serialize)] +pub struct TracksWrapper { + pub items: Option>, + pub total: Option, + pub offset: Option, + pub limit: Option, +} + +// --- Artist --- + +#[derive(Debug, Deserialize, Clone, Serialize)] +pub struct ArtistDto { + pub id: Option, + pub name: Option, + pub albums_count: Option, + pub image: Option, + pub biography: Option, +} + +#[derive(Debug, Deserialize, Clone, Serialize)] +pub struct BiographyDto { + pub content: Option, + pub summary: Option, +} + +// --- Genre --- + +#[derive(Debug, Deserialize, Clone, Serialize)] +pub struct GenreDto { + pub id: Option, + pub name: Option, + pub slug: Option, +} + +// --- Image --- + +#[derive(Debug, Deserialize, Clone, Serialize)] +pub struct ImageDto { + pub small: Option, + pub thumbnail: Option, + pub large: Option, + pub back: Option, +} + +// --- Label --- + +#[derive(Debug, Deserialize, Clone, Serialize)] +pub struct LabelDto { + pub id: Option, + pub name: Option, +} + +// --- Search --- + +#[derive(Debug, Deserialize, Serialize)] +pub struct SearchCatalogDto { + pub query: Option, + pub albums: Option>, + pub tracks: Option>, + pub artists: Option>, + pub playlists: Option>, +} + +#[derive(Debug, Deserialize, Clone, Serialize)] +pub struct SearchResultItems { + pub items: Option>, + pub total: Option, + pub offset: Option, + pub limit: Option, +} + +// --- Playlist --- + +#[derive(Debug, Deserialize, Clone, Serialize)] +pub struct PlaylistDto { + pub id: Option, + pub name: Option, + pub tracks_count: Option, + pub duration: Option, + pub description: Option, + pub owner: Option, + pub image_rectangle: Option>, + pub tracks: Option, +} + +#[derive(Debug, Deserialize, Clone, Serialize)] +pub struct PlaylistOwnerDto { + pub id: Option, + pub name: Option, +} + +// --- User library --- + +#[derive(Debug, Deserialize, Serialize)] +pub struct UserPlaylistsDto { + pub playlists: Option>, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct FavArtistDto { + pub id: Option, + pub name: Option, + pub albums_count: Option, + pub image: Option, +} + +// --- Format --- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Format { + Mp3 = 5, + Cd = 6, + HiRes96 = 7, + HiRes192 = 27, +} + +impl Format { + pub fn id(self) -> i32 { + self as i32 + } + + pub fn from_id(id: i32) -> Self { + match id { + 5 => Format::Mp3, + 6 => Format::Cd, + 7 => Format::HiRes96, + 27 => Format::HiRes192, + _ => Format::Cd, + } + } + + pub fn label(self) -> &'static str { + match self { + Format::Mp3 => "MP3 320", + Format::Cd => "CD 16-bit", + Format::HiRes96 => "Hi-Res 24-bit/96kHz", + Format::HiRes192 => "Hi-Res 24-bit/192kHz", + } + } + + pub fn all() -> &'static [Format] { + &[Format::HiRes192, Format::HiRes96, Format::Cd, Format::Mp3] + } +} + +// --- QWS --- + +#[derive(Debug, Deserialize, Clone, Serialize)] +pub struct QwsTokenResponse { + pub jwt_qws: Option, +} + +#[derive(Debug, Deserialize, Clone, Serialize)] +pub struct QwsToken { + pub exp: Option, + pub jwt: Option, + pub endpoint: Option, +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..f0d78ba --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,486 @@ +//! qobuz-backend: C-ABI library consumed by the Qt frontend. +//! +//! Event type constants – second argument of EventCallback: +//! 0 EV_LOGIN_OK { "token": "…", "user": { … } } +//! 1 EV_LOGIN_ERR { "error": "…" } +//! 2 EV_SEARCH_OK SearchCatalogDto +//! 3 EV_SEARCH_ERR { "error": "…" } +//! 4 EV_ALBUM_OK AlbumDto +//! 5 EV_ALBUM_ERR { "error": "…" } +//! 6 EV_ARTIST_OK ArtistDto +//! 7 EV_ARTIST_ERR { "error": "…" } +//! 8 EV_PLAYLIST_OK PlaylistDto +//! 9 EV_PLAYLIST_ERR { "error": "…" } +//! 10 EV_FAV_TRACKS_OK { "items": […], "total": N } +//! 11 EV_FAV_ALBUMS_OK { "items": […], "total": N } +//! 12 EV_FAV_ARTISTS_OK { "items": […], "total": N } +//! 13 EV_PLAYLISTS_OK { "items": […], "total": N } +//! 14 EV_TRACK_CHANGED TrackDto +//! 15 EV_STATE_CHANGED { "state": "playing"|"paused"|"idle"|"error" } +//! 16 EV_POSITION { "position": u64, "duration": u64 } +//! 17 EV_TRACK_URL_OK TrackFileUrlDto +//! 18 EV_TRACK_URL_ERR { "error": "…" } +//! 19 EV_GENERIC_ERR { "error": "…" } + +mod api; +mod player; + +use std::{ + ffi::{CStr, CString}, + os::raw::{c_char, c_int, c_void}, + sync::Arc, +}; + +use api::{Format, QobuzClient}; +use player::{Player, PlayerState}; +use tokio::runtime::Runtime; +use tokio::sync::Mutex; + +// ---------- Send-safe raw pointer wrapper ---------- + +/// Wraps a `*mut c_void` so it can cross thread boundaries. +/// SAFETY: The Qt QobuzBackend object is kept alive for the Backend's lifetime +/// and callbacks only call QMetaObject::invokeMethod (thread-safe Qt API). +#[derive(Clone, Copy)] +struct SendPtr(*mut c_void); +unsafe impl Send for SendPtr {} +unsafe impl Sync for SendPtr {} + +// ---------- Event type constants ---------- + +pub const EV_LOGIN_OK: c_int = 0; +pub const EV_LOGIN_ERR: c_int = 1; +pub const EV_SEARCH_OK: c_int = 2; +pub const EV_SEARCH_ERR: c_int = 3; +pub const EV_ALBUM_OK: c_int = 4; +pub const EV_ALBUM_ERR: c_int = 5; +pub const EV_ARTIST_OK: c_int = 6; +pub const EV_ARTIST_ERR: c_int = 7; +pub const EV_PLAYLIST_OK: c_int = 8; +pub const EV_PLAYLIST_ERR: c_int = 9; +pub const EV_FAV_TRACKS_OK: c_int = 10; +pub const EV_FAV_ALBUMS_OK: c_int = 11; +pub const EV_FAV_ARTISTS_OK: c_int = 12; +pub const EV_PLAYLISTS_OK: c_int = 13; +pub const EV_TRACK_CHANGED: c_int = 14; +pub const EV_STATE_CHANGED: c_int = 15; +pub const EV_POSITION: c_int = 16; +pub const EV_TRACK_URL_OK: c_int = 17; +pub const EV_TRACK_URL_ERR: c_int = 18; +pub const EV_GENERIC_ERR: c_int = 19; + +// ---------- Callback ---------- + +pub type EventCallback = unsafe extern "C" fn(*mut c_void, c_int, *const c_char); + +// ---------- Backend ---------- + +struct BackendInner { + client: Arc>, + player: Player, + rt: Runtime, + cb: EventCallback, + ud: SendPtr, +} + +pub struct Backend(BackendInner); + +// ---------- Helpers ---------- + +fn call_cb(cb: EventCallback, ud: SendPtr, ev: c_int, json: &str) { + let cstr = CString::new(json).unwrap_or_else(|_| CString::new("{}").unwrap()); + unsafe { cb(ud.0, ev, cstr.as_ptr()) }; +} + +fn err_json(msg: &str) -> String { + serde_json::json!({ "error": msg }).to_string() +} + +/// Spawn a Send + 'static future on the backend's Tokio runtime. +fn spawn(inner: &BackendInner, f: F) +where + F: std::future::Future + Send + 'static, +{ + inner.rt.spawn(f); +} + +// ---------- Construction / destruction ---------- + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_new( + event_cb: EventCallback, + userdata: *mut c_void, +) -> *mut Backend { + let rt = Runtime::new().expect("tokio runtime"); + let client = Arc::new(Mutex::new(QobuzClient::new().expect("QobuzClient"))); + let player = Player::new(); + + Box::into_raw(Box::new(Backend(BackendInner { + client, + player, + rt, + cb: event_cb, + ud: SendPtr(userdata), + }))) +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_free(ptr: *mut Backend) { + if !ptr.is_null() { + drop(Box::from_raw(ptr)); + } +} + +// ---------- Auth ---------- + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_login( + ptr: *mut Backend, + email: *const c_char, + password: *const c_char, +) { + let inner = &(*ptr).0; + let email = CStr::from_ptr(email).to_string_lossy().into_owned(); + let password = CStr::from_ptr(password).to_string_lossy().into_owned(); + let client = inner.client.clone(); + let cb = inner.cb; + let ud = inner.ud; + + spawn(inner, async move { + let result = client.lock().await.login(&email, &password).await; + let (ev, json) = match result { + Ok(resp) => { + let token = resp + .oauth2.as_ref().and_then(|o| o.access_token.as_deref()) + .or(resp.user_auth_token.as_deref()) + .unwrap_or("") + .to_string(); + let user_val = resp.user.as_ref() + .map(|u| serde_json::to_value(u).unwrap_or_default()) + .unwrap_or_default(); + (EV_LOGIN_OK, serde_json::json!({"token": token, "user": user_val}).to_string()) + } + Err(e) => (EV_LOGIN_ERR, err_json(&e.to_string())), + }; + call_cb(cb, ud, ev, &json); + }); +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_set_token(ptr: *mut Backend, token: *const c_char) { + let inner = &(*ptr).0; + let token = CStr::from_ptr(token).to_string_lossy().into_owned(); + let client = inner.client.clone(); + // blocking_lock is available on tokio::sync::Mutex when not in an async context + inner.rt.spawn(async move { + client.lock().await.set_auth_token(token); + }); +} + +// ---------- Search ---------- + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_search( + ptr: *mut Backend, + query: *const c_char, + offset: u32, + limit: u32, +) { + let inner = &(*ptr).0; + let query = CStr::from_ptr(query).to_string_lossy().into_owned(); + let client = inner.client.clone(); + let cb = inner.cb; let ud = inner.ud; + + spawn(inner, async move { + let result = client.lock().await.search(&query, offset, limit).await; + let (ev, json) = match result { + Ok(r) => (EV_SEARCH_OK, serde_json::to_string(&r).unwrap_or_default()), + Err(e) => (EV_SEARCH_ERR, err_json(&e.to_string())), + }; + call_cb(cb, ud, ev, &json); + }); +} + +// ---------- Album ---------- + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_get_album(ptr: *mut Backend, album_id: *const c_char) { + let inner = &(*ptr).0; + let album_id = CStr::from_ptr(album_id).to_string_lossy().into_owned(); + let client = inner.client.clone(); + let cb = inner.cb; let ud = inner.ud; + + spawn(inner, async move { + let result = client.lock().await.get_album(&album_id).await; + let (ev, json) = match result { + Ok(r) => (EV_ALBUM_OK, serde_json::to_string(&r).unwrap_or_default()), + Err(e) => (EV_ALBUM_ERR, err_json(&e.to_string())), + }; + call_cb(cb, ud, ev, &json); + }); +} + +// ---------- Artist ---------- + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_get_artist(ptr: *mut Backend, artist_id: i64) { + let inner = &(*ptr).0; + let client = inner.client.clone(); + let cb = inner.cb; let ud = inner.ud; + + spawn(inner, async move { + let result = client.lock().await.get_artist(artist_id).await; + let (ev, json) = match result { + Ok(r) => (EV_ARTIST_OK, serde_json::to_string(&r).unwrap_or_default()), + Err(e) => (EV_ARTIST_ERR, err_json(&e.to_string())), + }; + call_cb(cb, ud, ev, &json); + }); +} + +// ---------- Playlist ---------- + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_get_playlist( + ptr: *mut Backend, + playlist_id: i64, + offset: u32, + limit: u32, +) { + let inner = &(*ptr).0; + let client = inner.client.clone(); + let cb = inner.cb; let ud = inner.ud; + + spawn(inner, async move { + let result = client.lock().await.get_playlist(playlist_id, offset, limit).await; + let (ev, json) = match result { + Ok(r) => (EV_PLAYLIST_OK, serde_json::to_string(&r).unwrap_or_default()), + Err(e) => (EV_PLAYLIST_ERR, err_json(&e.to_string())), + }; + call_cb(cb, ud, ev, &json); + }); +} + +// ---------- Favorites ---------- + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_get_fav_tracks(ptr: *mut Backend, offset: u32, limit: u32) { + let inner = &(*ptr).0; + let client = inner.client.clone(); + let cb = inner.cb; let ud = inner.ud; + spawn(inner, async move { + let result = client.lock().await.get_fav_tracks(offset, limit).await; + let (ev, json) = match result { + Ok(r) => (EV_FAV_TRACKS_OK, serde_json::to_string(&r).unwrap_or_default()), + Err(e) => (EV_GENERIC_ERR, err_json(&e.to_string())), + }; + call_cb(cb, ud, ev, &json); + }); +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_get_fav_albums(ptr: *mut Backend, offset: u32, limit: u32) { + let inner = &(*ptr).0; + let client = inner.client.clone(); + let cb = inner.cb; let ud = inner.ud; + spawn(inner, async move { + let result = client.lock().await.get_fav_albums(offset, limit).await; + let (ev, json) = match result { + Ok(r) => (EV_FAV_ALBUMS_OK, serde_json::to_string(&r).unwrap_or_default()), + Err(e) => (EV_GENERIC_ERR, err_json(&e.to_string())), + }; + call_cb(cb, ud, ev, &json); + }); +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_get_fav_artists(ptr: *mut Backend, offset: u32, limit: u32) { + let inner = &(*ptr).0; + let client = inner.client.clone(); + let cb = inner.cb; let ud = inner.ud; + spawn(inner, async move { + let result = client.lock().await.get_fav_artists(offset, limit).await; + let (ev, json) = match result { + Ok(r) => (EV_FAV_ARTISTS_OK, serde_json::to_string(&r).unwrap_or_default()), + Err(e) => (EV_GENERIC_ERR, err_json(&e.to_string())), + }; + call_cb(cb, ud, ev, &json); + }); +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_get_user_playlists(ptr: *mut Backend, offset: u32, limit: u32) { + let inner = &(*ptr).0; + let client = inner.client.clone(); + let cb = inner.cb; let ud = inner.ud; + spawn(inner, async move { + let result = client.lock().await.get_user_playlists(offset, limit).await; + let (ev, json) = match result { + Ok(r) => { + let items = r.playlists.as_ref().and_then(|p| p.items.as_ref()).cloned().unwrap_or_default(); + let total = r.playlists.as_ref().and_then(|p| p.total).unwrap_or(0); + (EV_PLAYLISTS_OK, serde_json::json!({"items": items, "total": total}).to_string()) + } + Err(e) => (EV_GENERIC_ERR, err_json(&e.to_string())), + }; + call_cb(cb, ud, ev, &json); + }); +} + +// ---------- Playback ---------- + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_play_track( + ptr: *mut Backend, + track_id: i64, + format_id: i32, +) { + let inner = &(*ptr).0; + let client = inner.client.clone(); + let cb = inner.cb; let ud = inner.ud; + let format = Format::from_id(format_id); + let cmd_tx = inner.player.cmd_tx.clone(); + let status = inner.player.status.clone(); + + spawn(inner, async move { + // 1. Track metadata + let track = match client.lock().await.get_track(track_id).await { + Ok(t) => t, + Err(e) => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json(&e.to_string())); return; } + }; + + // 2. Stream URL + let url_dto = match client.lock().await.get_track_url(track_id, format).await { + Ok(u) => u, + Err(e) => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json(&e.to_string())); return; } + }; + let url = match url_dto.url { + Some(u) => u, + None => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json("no stream URL")); return; } + }; + + // 3. Notify track change + if let Ok(j) = serde_json::to_string(&track) { + call_cb(cb, ud, EV_TRACK_CHANGED, &j); + } + + // 4. Update status + send play command + *status.current_track.lock().unwrap() = Some(track.clone()); + if let Some(dur) = track.duration { + status.duration_secs.store(dur as u64, std::sync::atomic::Ordering::Relaxed); + } + let _ = cmd_tx.send(player::PlayerCommand::Play(player::TrackInfo { track, url, format })); + + // 5. State notification + call_cb(cb, ud, EV_STATE_CHANGED, r#"{"state":"playing"}"#); + }); +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_pause(ptr: *mut Backend) { + let inner = &(*ptr).0; + inner.player.pause(); + call_cb(inner.cb, inner.ud, EV_STATE_CHANGED, r#"{"state":"paused"}"#); +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_resume(ptr: *mut Backend) { + let inner = &(*ptr).0; + inner.player.resume(); + call_cb(inner.cb, inner.ud, EV_STATE_CHANGED, r#"{"state":"playing"}"#); +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_stop(ptr: *mut Backend) { + let inner = &(*ptr).0; + inner.player.stop(); + call_cb(inner.cb, inner.ud, EV_STATE_CHANGED, r#"{"state":"idle"}"#); +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_set_volume(ptr: *mut Backend, volume: u8) { + (*ptr).0.player.set_volume(volume); +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_get_position(ptr: *const Backend) -> u64 { + (*ptr).0.player.status.get_position() +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_get_duration(ptr: *const Backend) -> u64 { + (*ptr).0.player.status.get_duration() +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_get_volume(ptr: *const Backend) -> u8 { + (*ptr).0.player.status.get_volume() +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_get_state(ptr: *const Backend) -> c_int { + match (*ptr).0.player.status.get_state() { + PlayerState::Playing => 1, + PlayerState::Paused => 2, + _ => 0, + } +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_take_track_finished(ptr: *mut Backend) -> c_int { + let finished = (*ptr).0.player.status.track_finished + .swap(false, std::sync::atomic::Ordering::SeqCst); + if finished { 1 } else { 0 } +} + +// ---------- Favorites modification ---------- + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_add_fav_track(ptr: *mut Backend, track_id: i64) { + let inner = &(*ptr).0; + let client = inner.client.clone(); + let cb = inner.cb; let ud = inner.ud; + spawn(inner, async move { + if let Err(e) = client.lock().await.add_fav_track(track_id).await { + call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())); + } + }); +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_remove_fav_track(ptr: *mut Backend, track_id: i64) { + let inner = &(*ptr).0; + let client = inner.client.clone(); + let cb = inner.cb; let ud = inner.ud; + spawn(inner, async move { + if let Err(e) = client.lock().await.remove_fav_track(track_id).await { + call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())); + } + }); +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_add_fav_album(ptr: *mut Backend, album_id: *const c_char) { + let inner = &(*ptr).0; + let album_id = CStr::from_ptr(album_id).to_string_lossy().into_owned(); + let client = inner.client.clone(); + let cb = inner.cb; let ud = inner.ud; + spawn(inner, async move { + if let Err(e) = client.lock().await.add_fav_album(&album_id).await { + call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())); + } + }); +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_remove_fav_album(ptr: *mut Backend, album_id: *const c_char) { + let inner = &(*ptr).0; + let album_id = CStr::from_ptr(album_id).to_string_lossy().into_owned(); + let client = inner.client.clone(); + let cb = inner.cb; let ud = inner.ud; + spawn(inner, async move { + if let Err(e) = client.lock().await.remove_fav_album(&album_id).await { + call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())); + } + }); +} diff --git a/rust/src/player/decoder.rs b/rust/src/player/decoder.rs new file mode 100644 index 0000000..59d074f --- /dev/null +++ b/rust/src/player/decoder.rs @@ -0,0 +1,207 @@ +use anyhow::Result; +use std::io::{self, Read, Seek, SeekFrom}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; + +use symphonia::core::{ + codecs::DecoderOptions, + errors::Error as SymphoniaError, + formats::FormatOptions, + io::{MediaSource, MediaSourceStream}, + meta::MetadataOptions, + probe::Hint, +}; + +use crate::player::{output::AudioOutput, PlayerStatus}; + +/// First 512 KiB of stream kept in memory to support backward seeks during probing. +const HEAD_SIZE: usize = 512 * 1024; + +struct HttpStreamSource { + reader: reqwest::blocking::Response, + head: Vec, + reader_pos: u64, + pos: u64, + content_length: Option, +} + +impl HttpStreamSource { + fn new(response: reqwest::blocking::Response, content_length: Option) -> Self { + Self { + reader: response, + head: Vec::new(), + reader_pos: 0, + pos: 0, + content_length, + } + } +} + +impl Read for HttpStreamSource { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let pos = self.pos as usize; + if pos < self.head.len() { + let avail = self.head.len() - pos; + let n = buf.len().min(avail); + buf[..n].copy_from_slice(&self.head[pos..pos + n]); + self.pos += n as u64; + return Ok(n); + } + + let n = self.reader.read(buf)?; + if n > 0 { + if self.reader_pos < HEAD_SIZE as u64 { + let capacity = HEAD_SIZE.saturating_sub(self.head.len()); + let to_buf = n.min(capacity); + if to_buf > 0 { + self.head.extend_from_slice(&buf[..to_buf]); + } + } + self.reader_pos += n as u64; + self.pos += n as u64; + } + Ok(n) + } +} + +impl Seek for HttpStreamSource { + fn seek(&mut self, from: SeekFrom) -> io::Result { + let cl = self.content_length.unwrap_or(u64::MAX); + let target: u64 = match from { + SeekFrom::Start(n) => n, + SeekFrom::End(n) if n < 0 => cl.saturating_sub((-n) as u64), + SeekFrom::End(_) => cl, + SeekFrom::Current(n) if n >= 0 => self.pos.saturating_add(n as u64), + SeekFrom::Current(n) => self.pos.saturating_sub((-n) as u64), + }; + + if target == self.pos { + return Ok(self.pos); + } + + if target < self.reader_pos { + if target < self.head.len() as u64 { + self.pos = target; + return Ok(self.pos); + } + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "backward seek past head buffer", + )); + } + + let mut remaining = target - self.reader_pos; + while remaining > 0 { + let mut discard = [0u8; 8192]; + let want = (remaining as usize).min(discard.len()); + match self.reader.read(&mut discard[..want]) { + Ok(0) => break, + Ok(n) => { + if self.reader_pos < HEAD_SIZE as u64 { + let capacity = HEAD_SIZE.saturating_sub(self.head.len()); + let to_buf = n.min(capacity); + if to_buf > 0 { + self.head.extend_from_slice(&discard[..to_buf]); + } + } + self.reader_pos += n as u64; + remaining -= n as u64; + } + Err(e) => return Err(e), + } + } + self.pos = self.reader_pos; + Ok(self.pos) + } +} + +impl MediaSource for HttpStreamSource { + fn is_seekable(&self) -> bool { + true + } + fn byte_len(&self) -> Option { + self.content_length + } +} + +/// Stream and decode audio from `url`. Runs on a dedicated OS thread. +pub fn play_track( + url: &str, + status: &PlayerStatus, + stop: &Arc, + paused: &Arc, +) -> Result<()> { + let response = reqwest::blocking::get(url)?; + let content_length = response.content_length(); + let source = HttpStreamSource::new(response, content_length); + let mss = MediaSourceStream::new(Box::new(source), Default::default()); + + let hint = Hint::new(); + let probed = symphonia::default::get_probe() + .format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default()) + .map_err(|e| anyhow::anyhow!("probe failed: {e}"))?; + + let mut format = probed.format; + let track = format + .tracks() + .iter() + .find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL) + .ok_or_else(|| anyhow::anyhow!("no audio tracks"))? + .clone(); + + let track_id = track.id; + let sample_rate = track.codec_params.sample_rate.unwrap_or(44100); + let channels = track.codec_params.channels.map(|c| c.count()).unwrap_or(2); + + let mut decoder = symphonia::default::get_codecs() + .make(&track.codec_params, &DecoderOptions::default()) + .map_err(|e| anyhow::anyhow!("decoder init failed: {e}"))?; + + let mut audio_output = AudioOutput::try_open(sample_rate, channels)?; + + loop { + if stop.load(Ordering::SeqCst) { + break; + } + while paused.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(50)); + if stop.load(Ordering::SeqCst) { + return Ok(()); + } + } + + let packet = match format.next_packet() { + Ok(p) => p, + Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + break; + } + Err(SymphoniaError::ResetRequired) => { + decoder.reset(); + continue; + } + Err(e) => return Err(anyhow::anyhow!("format error: {e}")), + }; + + if packet.track_id() != track_id { + continue; + } + + if let Some(ts) = packet.ts().checked_div(sample_rate as u64) { + status.position_secs.store(ts, std::sync::atomic::Ordering::Relaxed); + } + + match decoder.decode(&packet) { + Ok(decoded) => { + let volume = status.volume.load(Ordering::Relaxed) as f32 / 100.0; + audio_output.write(decoded, volume, stop)?; + } + Err(SymphoniaError::IoError(_)) => break, + Err(SymphoniaError::DecodeError(e)) => eprintln!("decode error: {e}"), + Err(e) => return Err(anyhow::anyhow!("decode error: {e}")), + } + } + + Ok(()) +} diff --git a/rust/src/player/mod.rs b/rust/src/player/mod.rs new file mode 100644 index 0000000..6bc1683 --- /dev/null +++ b/rust/src/player/mod.rs @@ -0,0 +1,186 @@ +mod decoder; +pub mod output; + +use std::sync::{ + atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering}, + Arc, +}; +use std::time::Duration; + +use crate::api::{Format, TrackDto}; + +#[derive(Debug, Clone)] +pub enum PlayerCommand { + Play(TrackInfo), + Pause, + Resume, + Stop, + Seek(u64), + SetVolume(u8), +} + +#[derive(Debug, Clone)] +pub struct TrackInfo { + pub track: TrackDto, + pub url: String, + pub format: Format, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum PlayerState { + Idle, + Playing, + Paused, + Stopped, + Error(String), +} + +#[derive(Clone)] +pub struct PlayerStatus { + pub state: Arc>, + pub position_secs: Arc, + pub duration_secs: Arc, + pub volume: Arc, + pub current_track: Arc>>, + /// Set to true by the decode thread when a track finishes naturally. + pub track_finished: Arc, +} + +impl PlayerStatus { + pub fn new() -> Self { + Self { + state: Arc::new(std::sync::Mutex::new(PlayerState::Idle)), + position_secs: Arc::new(AtomicU64::new(0)), + duration_secs: Arc::new(AtomicU64::new(0)), + volume: Arc::new(AtomicU8::new(80)), + current_track: Arc::new(std::sync::Mutex::new(None)), + track_finished: Arc::new(AtomicBool::new(false)), + } + } + + pub fn get_state(&self) -> PlayerState { + self.state.lock().unwrap().clone() + } + + pub fn get_position(&self) -> u64 { + self.position_secs.load(Ordering::Relaxed) + } + + pub fn get_duration(&self) -> u64 { + self.duration_secs.load(Ordering::Relaxed) + } + + pub fn get_volume(&self) -> u8 { + self.volume.load(Ordering::Relaxed) + } + + pub fn get_current_track(&self) -> Option { + self.current_track.lock().unwrap().clone() + } +} + +pub struct Player { + pub cmd_tx: std::sync::mpsc::SyncSender, + pub status: PlayerStatus, +} + +impl Player { + pub fn new() -> Self { + let (cmd_tx, cmd_rx) = std::sync::mpsc::sync_channel(32); + let status = PlayerStatus::new(); + let status_clone = status.clone(); + + std::thread::spawn(move || { + player_loop(cmd_rx, status_clone); + }); + + Self { cmd_tx, status } + } + + pub fn send(&self, cmd: PlayerCommand) { + self.cmd_tx.send(cmd).ok(); + } + + pub fn play_track(&self, track: TrackDto, url: String, format: Format) { + self.send(PlayerCommand::Play(TrackInfo { track, url, format })); + } + + pub fn pause(&self) { + self.send(PlayerCommand::Pause); + } + + pub fn resume(&self) { + self.send(PlayerCommand::Resume); + } + + pub fn stop(&self) { + self.send(PlayerCommand::Stop); + } + + pub fn set_volume(&self, vol: u8) { + self.status.volume.store(vol, Ordering::Relaxed); + self.send(PlayerCommand::SetVolume(vol)); + } +} + +fn player_loop(rx: std::sync::mpsc::Receiver, status: PlayerStatus) { + let mut stop_flag = Arc::new(AtomicBool::new(true)); + let paused = Arc::new(AtomicBool::new(false)); + + loop { + match rx.recv_timeout(Duration::from_millis(100)) { + Ok(cmd) => match cmd { + PlayerCommand::Play(info) => { + stop_flag.store(true, Ordering::SeqCst); + stop_flag = Arc::new(AtomicBool::new(false)); + paused.store(false, Ordering::SeqCst); + + *status.state.lock().unwrap() = PlayerState::Playing; + *status.current_track.lock().unwrap() = Some(info.track.clone()); + if let Some(dur) = info.track.duration { + status.duration_secs.store(dur as u64, Ordering::Relaxed); + } + status.position_secs.store(0, Ordering::Relaxed); + + let status_c = status.clone(); + let stop_c = stop_flag.clone(); + let paused_c = paused.clone(); + + std::thread::spawn(move || { + match decoder::play_track(&info.url, &status_c, &stop_c, &paused_c) { + Ok(()) => { + if !stop_c.load(Ordering::SeqCst) { + *status_c.state.lock().unwrap() = PlayerState::Idle; + status_c.track_finished.store(true, Ordering::SeqCst); + } + } + Err(e) => { + eprintln!("playback error: {e}"); + *status_c.state.lock().unwrap() = + PlayerState::Error(e.to_string()); + } + } + }); + } + PlayerCommand::Pause => { + paused.store(true, Ordering::SeqCst); + *status.state.lock().unwrap() = PlayerState::Paused; + } + PlayerCommand::Resume => { + paused.store(false, Ordering::SeqCst); + *status.state.lock().unwrap() = PlayerState::Playing; + } + PlayerCommand::Stop => { + stop_flag.store(true, Ordering::SeqCst); + *status.state.lock().unwrap() = PlayerState::Idle; + *status.current_track.lock().unwrap() = None; + status.position_secs.store(0, Ordering::Relaxed); + status.duration_secs.store(0, Ordering::Relaxed); + } + PlayerCommand::SetVolume(_) | PlayerCommand::Seek(_) => {} + }, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + } + } +} diff --git a/rust/src/player/output.rs b/rust/src/player/output.rs new file mode 100644 index 0000000..d9ca4fa --- /dev/null +++ b/rust/src/player/output.rs @@ -0,0 +1,81 @@ +use anyhow::{anyhow, Result}; +use cpal::{ + traits::{DeviceTrait, HostTrait, StreamTrait}, + StreamConfig, +}; +use rb::{RbConsumer, RbProducer, SpscRb, RB}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use symphonia::core::audio::AudioBufferRef; + +const RING_BUFFER_SIZE: usize = 32 * 1024; + +pub struct AudioOutput { + ring_buf_producer: rb::Producer, + _stream: cpal::Stream, +} + +impl AudioOutput { + pub fn try_open(sample_rate: u32, channels: usize) -> Result { + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or_else(|| anyhow!("no output device"))?; + + let config = StreamConfig { + channels: channels as u16, + sample_rate: cpal::SampleRate(sample_rate), + buffer_size: cpal::BufferSize::Default, + }; + + let ring = SpscRb::new(RING_BUFFER_SIZE); + let (producer, consumer) = (ring.producer(), ring.consumer()); + + let stream = device.build_output_stream( + &config, + move |data: &mut [f32], _| { + let n = consumer.read(data).unwrap_or(0); + for s in &mut data[n..] { + *s = 0.0; + } + }, + |e| eprintln!("audio stream error: {e}"), + None, + )?; + + stream.play()?; + + Ok(Self { + ring_buf_producer: producer, + _stream: stream, + }) + } + + pub fn write( + &mut self, + decoded: AudioBufferRef<'_>, + volume: f32, + stop: &Arc, + ) -> Result<()> { + let mut sample_buf = symphonia::core::audio::SampleBuffer::::new( + decoded.capacity() as u64, + *decoded.spec(), + ); + sample_buf.copy_interleaved_ref(decoded); + let samples: Vec = sample_buf.samples().iter().map(|s| s * volume).collect(); + + let mut remaining = &samples[..]; + while !remaining.is_empty() { + if stop.load(Ordering::SeqCst) { + return Ok(()); + } + match self.ring_buf_producer.write_blocking(remaining) { + Some(n) => remaining = &remaining[n..], + None => break, + } + } + Ok(()) + } +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt new file mode 100644 index 0000000..b461835 --- /dev/null +++ b/src/CMakeLists.txt @@ -0,0 +1,44 @@ +target_sources(qobuz-qt PRIVATE + # Entry point + main.cpp + + # Main window + mainwindow.hpp + mainwindow.cpp + + # Backend (Qt wrapper around Rust FFI) + backend/qobuzbackend.hpp + backend/qobuzbackend.cpp + + # Views + view/maintoolbar.hpp + view/maintoolbar.cpp + view/maincontent.hpp + view/maincontent.cpp + view/sidepanel/view.hpp + view/sidepanel/view.cpp + + # Lists + list/tracks.hpp + list/tracks.cpp + list/library.hpp + list/library.cpp + + # Models + model/tracklistmodel.hpp + model/tracklistmodel.cpp + + # Dialogs + dialog/login.hpp + dialog/login.cpp + dialog/settings.hpp + dialog/settings.cpp + + # Widgets (header-only) + widget/clickableslider.hpp + widget/volumebutton.hpp + + # Utils (header-only) + util/icon.hpp + util/settings.hpp +) diff --git a/src/backend/qobuzbackend.cpp b/src/backend/qobuzbackend.cpp new file mode 100644 index 0000000..1f59fa6 --- /dev/null +++ b/src/backend/qobuzbackend.cpp @@ -0,0 +1,221 @@ +#include "qobuzbackend.hpp" + +#include +#include +#include + +QobuzBackend::QobuzBackend(QObject *parent) + : QObject(parent) +{ + m_backend = qobuz_backend_new(&QobuzBackend::eventTrampoline, this); + + m_positionTimer = new QTimer(this); + m_positionTimer->setInterval(500); + connect(m_positionTimer, &QTimer::timeout, this, &QobuzBackend::onPositionTick); + m_positionTimer->start(); +} + +QobuzBackend::~QobuzBackend() +{ + if (m_backend) { + qobuz_backend_free(m_backend); + m_backend = nullptr; + } +} + +// ---- auth ---- + +void QobuzBackend::login(const QString &email, const QString &password) +{ + qobuz_backend_login(m_backend, email.toUtf8().constData(), password.toUtf8().constData()); +} + +void QobuzBackend::setToken(const QString &token) +{ + qobuz_backend_set_token(m_backend, token.toUtf8().constData()); +} + +// ---- catalog ---- + +void QobuzBackend::search(const QString &query, quint32 offset, quint32 limit) +{ + qobuz_backend_search(m_backend, query.toUtf8().constData(), offset, limit); +} + +void QobuzBackend::getAlbum(const QString &albumId) +{ + qobuz_backend_get_album(m_backend, albumId.toUtf8().constData()); +} + +void QobuzBackend::getArtist(qint64 artistId) +{ + qobuz_backend_get_artist(m_backend, artistId); +} + +void QobuzBackend::getPlaylist(qint64 playlistId, quint32 offset, quint32 limit) +{ + qobuz_backend_get_playlist(m_backend, playlistId, offset, limit); +} + +// ---- favorites ---- + +void QobuzBackend::getFavTracks(quint32 offset, quint32 limit) +{ + qobuz_backend_get_fav_tracks(m_backend, offset, limit); +} + +void QobuzBackend::getFavAlbums(quint32 offset, quint32 limit) +{ + qobuz_backend_get_fav_albums(m_backend, offset, limit); +} + +void QobuzBackend::getFavArtists(quint32 offset, quint32 limit) +{ + qobuz_backend_get_fav_artists(m_backend, offset, limit); +} + +void QobuzBackend::getUserPlaylists(quint32 offset, quint32 limit) +{ + qobuz_backend_get_user_playlists(m_backend, offset, limit); +} + +// ---- fav modification ---- + +void QobuzBackend::addFavTrack(qint64 trackId) +{ + qobuz_backend_add_fav_track(m_backend, trackId); +} + +void QobuzBackend::removeFavTrack(qint64 trackId) +{ + qobuz_backend_remove_fav_track(m_backend, trackId); +} + +void QobuzBackend::addFavAlbum(const QString &albumId) +{ + qobuz_backend_add_fav_album(m_backend, albumId.toUtf8().constData()); +} + +void QobuzBackend::removeFavAlbum(const QString &albumId) +{ + qobuz_backend_remove_fav_album(m_backend, albumId.toUtf8().constData()); +} + +// ---- playback ---- + +void QobuzBackend::playTrack(qint64 trackId, int formatId) +{ + qobuz_backend_play_track(m_backend, trackId, formatId); +} + +void QobuzBackend::pause() +{ + qobuz_backend_pause(m_backend); +} + +void QobuzBackend::resume() +{ + qobuz_backend_resume(m_backend); +} + +void QobuzBackend::stop() +{ + qobuz_backend_stop(m_backend); +} + +void QobuzBackend::setVolume(int volume) +{ + qobuz_backend_set_volume(m_backend, static_cast(qBound(0, volume, 100))); +} + +quint64 QobuzBackend::position() const { return qobuz_backend_get_position(m_backend); } +quint64 QobuzBackend::duration() const { return qobuz_backend_get_duration(m_backend); } +int QobuzBackend::volume() const { return qobuz_backend_get_volume(m_backend); } +int QobuzBackend::state() const { return qobuz_backend_get_state(m_backend); } + +// ---- private slots ---- + +void QobuzBackend::onPositionTick() +{ + emit positionChanged(position(), duration()); + + if (qobuz_backend_take_track_finished(m_backend)) + emit trackFinished(); +} + +void QobuzBackend::onEvent(int eventType, const QString &json) +{ + const QJsonObject obj = QJsonDocument::fromJson(json.toUtf8()).object(); + + switch (eventType) { + case EV_LOGIN_OK: + emit loginSuccess(obj["token"].toString(), obj["user"].toObject()); + break; + case EV_LOGIN_ERR: + emit loginError(obj["error"].toString()); + break; + case EV_SEARCH_OK: + emit searchResult(obj); + break; + case EV_SEARCH_ERR: + emit error(obj["error"].toString()); + break; + case EV_ALBUM_OK: + emit albumLoaded(obj); + break; + case EV_ALBUM_ERR: + emit error(obj["error"].toString()); + break; + case EV_ARTIST_OK: + emit artistLoaded(obj); + break; + case EV_ARTIST_ERR: + emit error(obj["error"].toString()); + break; + case EV_PLAYLIST_OK: + emit playlistLoaded(obj); + break; + case EV_PLAYLIST_ERR: + emit error(obj["error"].toString()); + break; + case EV_FAV_TRACKS_OK: + emit favTracksLoaded(obj); + break; + case EV_FAV_ALBUMS_OK: + emit favAlbumsLoaded(obj); + break; + case EV_FAV_ARTISTS_OK: + emit favArtistsLoaded(obj); + break; + case EV_PLAYLISTS_OK: + emit userPlaylistsLoaded(obj); + break; + case EV_TRACK_CHANGED: + emit trackChanged(obj); + break; + case EV_STATE_CHANGED: + emit stateChanged(obj["state"].toString()); + break; + case EV_GENERIC_ERR: + case EV_TRACK_URL_ERR: + emit error(obj["error"].toString()); + break; + default: + break; + } +} + +// ---- static trampoline ---- + +void QobuzBackend::eventTrampoline(void *userdata, int eventType, const char *json) +{ + auto *self = static_cast(userdata); + // Marshal from Rust thread → Qt main thread + QMetaObject::invokeMethod( + self, + "onEvent", + Qt::QueuedConnection, + Q_ARG(int, eventType), + Q_ARG(QString, QString::fromUtf8(json)) + ); +} diff --git a/src/backend/qobuzbackend.hpp b/src/backend/qobuzbackend.hpp new file mode 100644 index 0000000..f8a1534 --- /dev/null +++ b/src/backend/qobuzbackend.hpp @@ -0,0 +1,94 @@ +#pragma once + +#include "qobuz_backend.h" + +#include +#include +#include +#include + +/// Qt wrapper around the Rust qobuz-backend static library. +/// +/// All signals are emitted on the Qt main thread regardless of which thread +/// the Rust callback fires on (marshalled via QMetaObject::invokeMethod with +/// Qt::QueuedConnection). +class QobuzBackend : public QObject +{ + Q_OBJECT + +public: + explicit QobuzBackend(QObject *parent = nullptr); + ~QobuzBackend() override; + + // --- auth --- + void login(const QString &email, const QString &password); + void setToken(const QString &token); + + // --- catalog --- + void search(const QString &query, quint32 offset = 0, quint32 limit = 20); + void getAlbum(const QString &albumId); + void getArtist(qint64 artistId); + void getPlaylist(qint64 playlistId, quint32 offset = 0, quint32 limit = 500); + + // --- favorites --- + void getFavTracks(quint32 offset = 0, quint32 limit = 500); + void getFavAlbums(quint32 offset = 0, quint32 limit = 200); + void getFavArtists(quint32 offset = 0, quint32 limit = 200); + void getUserPlaylists(quint32 offset = 0, quint32 limit = 200); + + // --- fav modification --- + void addFavTrack(qint64 trackId); + void removeFavTrack(qint64 trackId); + void addFavAlbum(const QString &albumId); + void removeFavAlbum(const QString &albumId); + + // --- playback --- + void playTrack(qint64 trackId, int formatId = 6); + void pause(); + void resume(); + void stop(); + void setVolume(int volume); + + quint64 position() const; + quint64 duration() const; + int volume() const; + /// 1 = playing, 2 = paused, 0 = idle + int state() const; + +signals: + // auth + void loginSuccess(const QString &token, const QJsonObject &user); + void loginError(const QString &error); + + // catalog + void searchResult(const QJsonObject &result); + void albumLoaded(const QJsonObject &album); + void artistLoaded(const QJsonObject &artist); + void playlistLoaded(const QJsonObject &playlist); + + // favorites + void favTracksLoaded(const QJsonObject &result); + void favAlbumsLoaded(const QJsonObject &result); + void favArtistsLoaded(const QJsonObject &result); + void userPlaylistsLoaded(const QJsonObject &result); + + // playback + void trackChanged(const QJsonObject &track); + void stateChanged(const QString &state); + void positionChanged(quint64 position, quint64 duration); + void trackFinished(); + + // errors + void error(const QString &message); + +private slots: + Q_INVOKABLE void onEvent(int eventType, const QString &json); + void onPositionTick(); + +private: + QobuzBackendOpaque *m_backend = nullptr; + QTimer *m_positionTimer = nullptr; + + // Static trampoline called from Rust threads + static void eventTrampoline(void *userdata, int eventType, const char *json); +}; diff --git a/src/dialog/login.cpp b/src/dialog/login.cpp new file mode 100644 index 0000000..3e43176 --- /dev/null +++ b/src/dialog/login.cpp @@ -0,0 +1,80 @@ +#include "login.hpp" + +#include +#include +#include +#include + +LoginDialog::LoginDialog(QWidget *parent) : QDialog(parent) +{ + setWindowTitle(tr("Sign in to Qobuz")); + setMinimumWidth(360); + setModal(true); + + auto *layout = new QVBoxLayout(this); + layout->setSpacing(12); + + // Logo / title + auto *title = new QLabel(QStringLiteral("

Qobuz

"), this); + title->setAlignment(Qt::AlignCenter); + layout->addWidget(title); + + // Form + auto *form = new QFormLayout; + form->setLabelAlignment(Qt::AlignRight); + + m_email = new QLineEdit(this); + m_password = new QLineEdit(this); + m_password->setEchoMode(QLineEdit::Password); + + form->addRow(tr("E-mail:"), m_email); + form->addRow(tr("Password:"), m_password); + layout->addLayout(form); + + // Remember checkbox + m_remember = new QCheckBox(tr("Remember me"), this); + m_remember->setChecked(true); + layout->addWidget(m_remember); + + // Error label (hidden until needed) + m_errorLbl = new QLabel(this); + m_errorLbl->setStyleSheet("color: red;"); + m_errorLbl->setWordWrap(true); + m_errorLbl->hide(); + layout->addWidget(m_errorLbl); + + // Buttons + m_loginBtn = new QPushButton(tr("Sign in"), this); + m_loginBtn->setDefault(true); + auto *cancelBtn = new QPushButton(tr("Cancel"), this); + + auto *btnLayout = new QHBoxLayout; + btnLayout->addStretch(); + btnLayout->addWidget(cancelBtn); + btnLayout->addWidget(m_loginBtn); + layout->addLayout(btnLayout); + + connect(m_loginBtn, &QPushButton::clicked, this, [this] { + m_errorLbl->hide(); + emit loginRequested(m_email->text().trimmed(), m_password->text()); + }); + connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + + // Allow pressing Enter in the password field + connect(m_password, &QLineEdit::returnPressed, m_loginBtn, &QPushButton::click); +} + +void LoginDialog::setError(const QString &msg) +{ + m_errorLbl->setText(msg); + m_errorLbl->show(); + setBusy(false); +} + +void LoginDialog::setBusy(bool busy) +{ + m_loginBtn->setEnabled(!busy); + m_loginBtn->setText(busy ? tr("Signing in…") : tr("Sign in")); + m_email->setEnabled(!busy); + m_password->setEnabled(!busy); +} diff --git a/src/dialog/login.hpp b/src/dialog/login.hpp new file mode 100644 index 0000000..a24bb3b --- /dev/null +++ b/src/dialog/login.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include +#include +#include + +class LoginDialog : public QDialog +{ + Q_OBJECT + +public: + explicit LoginDialog(QWidget *parent = nullptr); + + QString email() const { return m_email->text(); } + QString password() const { return m_password->text(); } + bool remember() const { return m_remember->isChecked(); } + + void setError(const QString &msg); + void setBusy(bool busy); + +signals: + void loginRequested(const QString &email, const QString &password); + +private: + QLineEdit *m_email = nullptr; + QLineEdit *m_password = nullptr; + QCheckBox *m_remember = nullptr; + QPushButton *m_loginBtn = nullptr; + QLabel *m_errorLbl = nullptr; +}; diff --git a/src/dialog/settings.cpp b/src/dialog/settings.cpp new file mode 100644 index 0000000..9bf791d --- /dev/null +++ b/src/dialog/settings.cpp @@ -0,0 +1,54 @@ +#include "settings.hpp" +#include "../util/settings.hpp" + +#include +#include +#include +#include + +SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent) +{ + setWindowTitle(tr("Settings")); + setMinimumWidth(400); + + auto *layout = new QVBoxLayout(this); + + // --- Playback group --- + auto *playGroup = new QGroupBox(tr("Playback"), this); + auto *playLayout = new QFormLayout(playGroup); + + m_formatBox = new QComboBox(playGroup); + m_formatBox->addItem(tr("Hi-Res 24-bit/192kHz"), 27); + m_formatBox->addItem(tr("Hi-Res 24-bit/96kHz"), 7); + m_formatBox->addItem(tr("CD 16-bit"), 6); + m_formatBox->addItem(tr("MP3 320 kbps"), 5); + + const int currentFormat = AppSettings::instance().preferredFormat(); + for (int i = 0; i < m_formatBox->count(); ++i) { + if (m_formatBox->itemData(i).toInt() == currentFormat) { + m_formatBox->setCurrentIndex(i); + break; + } + } + + playLayout->addRow(tr("Preferred quality:"), m_formatBox); + layout->addWidget(playGroup); + + layout->addStretch(); + + auto *buttons = new QDialogButtonBox( + QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + layout->addWidget(buttons); + + connect(buttons, &QDialogButtonBox::accepted, this, [this] { + applyChanges(); + accept(); + }); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +void SettingsDialog::applyChanges() +{ + AppSettings::instance().setPreferredFormat( + m_formatBox->currentData().toInt()); +} diff --git a/src/dialog/settings.hpp b/src/dialog/settings.hpp new file mode 100644 index 0000000..365fcd7 --- /dev/null +++ b/src/dialog/settings.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include +#include +#include + +class SettingsDialog : public QDialog +{ + Q_OBJECT + +public: + explicit SettingsDialog(QWidget *parent = nullptr); + +private: + QComboBox *m_formatBox = nullptr; + + void applyChanges(); +}; diff --git a/src/list/library.cpp b/src/list/library.cpp new file mode 100644 index 0000000..68f2097 --- /dev/null +++ b/src/list/library.cpp @@ -0,0 +1,110 @@ +#include "library.hpp" + +#include +#include +#include + +namespace List +{ + +static constexpr int TypeRole = Qt::UserRole + 1; +static constexpr int IdRole = Qt::UserRole + 2; +static constexpr int NameRole = Qt::UserRole + 3; + +enum NodeType { + NodeFavTracks, + NodeFavAlbums, + NodeFavArtists, + NodePlaylist, +}; + +Library::Library(QobuzBackend *backend, QWidget *parent) + : QTreeWidget(parent) + , m_backend(backend) +{ + setHeaderHidden(true); + setRootIsDecorated(true); + setContextMenuPolicy(Qt::NoContextMenu); + + buildStaticNodes(); + + connect(m_backend, &QobuzBackend::userPlaylistsLoaded, + this, &Library::onUserPlaylistsLoaded); + connect(this, &QTreeWidget::itemClicked, + this, &Library::onItemClicked); + connect(this, &QTreeWidget::itemDoubleClicked, + this, &Library::onItemDoubleClicked); +} + +void Library::buildStaticNodes() +{ + // My Library + m_myLibNode = new QTreeWidgetItem(this, QStringList{tr("My Library")}); + m_myLibNode->setExpanded(true); + + auto *tracksItem = new QTreeWidgetItem(m_myLibNode, QStringList{tr("Favorite Tracks")}); + tracksItem->setData(0, TypeRole, NodeFavTracks); + + auto *albumsItem = new QTreeWidgetItem(m_myLibNode, QStringList{tr("Favorite Albums")}); + albumsItem->setData(0, TypeRole, NodeFavAlbums); + + auto *artistsItem = new QTreeWidgetItem(m_myLibNode, QStringList{tr("Favorite Artists")}); + artistsItem->setData(0, TypeRole, NodeFavArtists); + + // Playlists + m_playlistsNode = new QTreeWidgetItem(this, QStringList{tr("Playlists")}); + m_playlistsNode->setExpanded(true); +} + +void Library::refresh() +{ + // Remove old playlist children + while (m_playlistsNode->childCount() > 0) + delete m_playlistsNode->takeChild(0); + + m_backend->getUserPlaylists(); +} + +void Library::onUserPlaylistsLoaded(const QJsonObject &result) +{ + while (m_playlistsNode->childCount() > 0) + delete m_playlistsNode->takeChild(0); + + const QJsonArray items = result["items"].toArray(); + for (const auto &v : items) { + const QJsonObject pl = v.toObject(); + const QString name = pl["name"].toString(); + const qint64 id = static_cast(pl["id"].toDouble()); + + auto *item = new QTreeWidgetItem(m_playlistsNode, QStringList{name}); + item->setData(0, TypeRole, NodePlaylist); + item->setData(0, IdRole, id); + item->setData(0, NameRole, name); + } +} + +void Library::onItemClicked(QTreeWidgetItem *item, int) +{ + if (!item) return; + const int type = item->data(0, TypeRole).toInt(); + + switch (type) { + case NodeFavTracks: emit favTracksRequested(); break; + case NodeFavAlbums: emit favAlbumsRequested(); break; + case NodeFavArtists: emit favArtistsRequested(); break; + case NodePlaylist: { + const qint64 id = item->data(0, IdRole).toLongLong(); + const QString name = item->data(0, NameRole).toString(); + emit playlistRequested(id, name); + break; + } + default: break; + } +} + +void Library::onItemDoubleClicked(QTreeWidgetItem *item, int column) +{ + onItemClicked(item, column); +} + +} // namespace List diff --git a/src/list/library.hpp b/src/list/library.hpp new file mode 100644 index 0000000..fb6be76 --- /dev/null +++ b/src/list/library.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include "../backend/qobuzbackend.hpp" + +#include + +namespace List +{ + /// Left-sidebar library tree — mirrors List::Library from spotify-qt. + /// Shows: Favorites (tracks, albums, artists) and Playlists. + class Library : public QTreeWidget + { + Q_OBJECT + + public: + explicit Library(QobuzBackend *backend, QWidget *parent = nullptr); + + void refresh(); + + signals: + /// Emitted when the user selects a node that should load tracks. + void favTracksRequested(); + void favAlbumsRequested(); + void favArtistsRequested(); + void playlistRequested(qint64 playlistId, const QString &name); + + private slots: + void onUserPlaylistsLoaded(const QJsonObject &result); + void onItemClicked(QTreeWidgetItem *item, int column); + void onItemDoubleClicked(QTreeWidgetItem *item, int column); + + private: + QobuzBackend *m_backend = nullptr; + + QTreeWidgetItem *m_myLibNode = nullptr; + QTreeWidgetItem *m_playlistsNode = nullptr; + + void buildStaticNodes(); + }; +} diff --git a/src/list/tracks.cpp b/src/list/tracks.cpp new file mode 100644 index 0000000..8aad661 --- /dev/null +++ b/src/list/tracks.cpp @@ -0,0 +1,115 @@ +#include "tracks.hpp" + +#include +#include +#include +#include +#include + +namespace List +{ + +Tracks::Tracks(QobuzBackend *backend, QWidget *parent) + : QTreeView(parent) + , m_backend(backend) +{ + m_model = new TrackListModel(this); + setModel(m_model); + + setRootIsDecorated(false); + setAlternatingRowColors(true); + setSelectionMode(QAbstractItemView::ExtendedSelection); + setSelectionBehavior(QAbstractItemView::SelectRows); + setSortingEnabled(true); + setContextMenuPolicy(Qt::CustomContextMenu); + + header()->setStretchLastSection(false); + header()->setSectionResizeMode(TrackListModel::ColTitle, QHeaderView::Stretch); + header()->setSectionResizeMode(TrackListModel::ColArtist, QHeaderView::Stretch); + header()->setSectionResizeMode(TrackListModel::ColAlbum, QHeaderView::Stretch); + header()->setSectionResizeMode(TrackListModel::ColNumber, QHeaderView::ResizeToContents); + header()->setSectionResizeMode(TrackListModel::ColDuration, QHeaderView::ResizeToContents); + + connect(this, &QTreeView::doubleClicked, this, &Tracks::onDoubleClicked); + connect(this, &QTreeView::customContextMenuRequested, this, &Tracks::onContextMenu); +} + +void Tracks::loadTracks(const QJsonArray &tracks) +{ + m_model->setTracks(tracks); +} + +void Tracks::loadAlbum(const QJsonObject &album) +{ + const QJsonObject tracksWrapper = album["tracks"].toObject(); + const QJsonArray items = tracksWrapper["items"].toArray(); + m_model->setTracks(items); +} + +void Tracks::loadPlaylist(const QJsonObject &playlist) +{ + const QJsonObject tracksWrapper = playlist["tracks"].toObject(); + const QJsonArray items = tracksWrapper["items"].toArray(); + m_model->setTracks(items); +} + +void Tracks::loadSearchTracks(const QJsonArray &tracks) +{ + m_model->setTracks(tracks); +} + +void Tracks::setCurrentTrackId(qint64 id) +{ + m_currentTrackId = id; + // Trigger a repaint for the now-playing indicator + viewport()->update(); +} + +void Tracks::onDoubleClicked(const QModelIndex &index) +{ + const qint64 trackId = m_model->data(index, TrackListModel::TrackIdRole).toLongLong(); + if (trackId > 0) + emit playTrackRequested(trackId); +} + +void Tracks::onContextMenu(const QPoint &pos) +{ + const QModelIndex index = indexAt(pos); + if (!index.isValid()) return; + + const qint64 trackId = m_model->data(index, TrackListModel::TrackIdRole).toLongLong(); + const QJsonObject trackJson = m_model->data(index, TrackListModel::TrackJsonRole).toJsonObject(); + + QMenu menu(this); + + QAction *playNow = menu.addAction(tr("Play now")); + menu.addSeparator(); + QAction *addFav = menu.addAction(tr("Add to favorites")); + QAction *remFav = menu.addAction(tr("Remove from favorites")); + + connect(playNow, &QAction::triggered, this, [this, trackId] { + emit playTrackRequested(trackId); + }); + connect(addFav, &QAction::triggered, this, [this, trackId] { + m_backend->addFavTrack(trackId); + }); + connect(remFav, &QAction::triggered, this, [this, trackId] { + m_backend->removeFavTrack(trackId); + }); + + // Open artist page + const QJsonObject performer = trackJson["performer"].toObject(); + const qint64 artistId = static_cast(performer["id"].toDouble()); + if (artistId > 0) { + menu.addSeparator(); + QAction *openArtist = menu.addAction( + tr("Open artist: %1").arg(performer["name"].toString())); + connect(openArtist, &QAction::triggered, this, [this, artistId] { + m_backend->getArtist(artistId); + }); + } + + menu.exec(viewport()->mapToGlobal(pos)); +} + +} // namespace List diff --git a/src/list/tracks.hpp b/src/list/tracks.hpp new file mode 100644 index 0000000..cb1354f --- /dev/null +++ b/src/list/tracks.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include "../model/tracklistmodel.hpp" +#include "../backend/qobuzbackend.hpp" + +#include +#include +#include + +namespace List +{ + /// Track list view — mirrors the spotify-qt List::Tracks widget. + /// Displays a flat list of tracks for a playlist, album, search result, etc. + class Tracks : public QTreeView + { + Q_OBJECT + + public: + explicit Tracks(QobuzBackend *backend, QWidget *parent = nullptr); + + void loadTracks(const QJsonArray &tracks); + void loadAlbum(const QJsonObject &album); + void loadPlaylist(const QJsonObject &playlist); + void loadSearchTracks(const QJsonArray &tracks); + + void setCurrentTrackId(qint64 id); + + signals: + void playTrackRequested(qint64 trackId); + + private: + TrackListModel *m_model = nullptr; + QobuzBackend *m_backend = nullptr; + qint64 m_currentTrackId = 0; + + void onDoubleClicked(const QModelIndex &index); + void onContextMenu(const QPoint &pos); + void resizeColumnsToContent(); + }; +} diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..54ba51c --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,39 @@ +#include "mainwindow.hpp" +#include "backend/qobuzbackend.hpp" + +#include +#include + +int main(int argc, char *argv[]) +{ + QApplication app(argc, argv); + app.setApplicationName(QStringLiteral("qobuz-qt")); + app.setOrganizationName(QStringLiteral("qobuz-qt")); + app.setApplicationVersion(QStringLiteral("0.1.0")); + + // Dark palette consistent with spotify-qt style + app.setStyle(QStyleFactory::create(QStringLiteral("Fusion"))); + QPalette darkPalette; + darkPalette.setColor(QPalette::Window, QColor(35, 35, 35)); + darkPalette.setColor(QPalette::WindowText, Qt::white); + darkPalette.setColor(QPalette::Base, QColor(25, 25, 25)); + darkPalette.setColor(QPalette::AlternateBase, QColor(45, 45, 45)); + darkPalette.setColor(QPalette::ToolTipBase, Qt::white); + darkPalette.setColor(QPalette::ToolTipText, Qt::white); + darkPalette.setColor(QPalette::Text, Qt::white); + darkPalette.setColor(QPalette::Button, QColor(53, 53, 53)); + darkPalette.setColor(QPalette::ButtonText, Qt::white); + darkPalette.setColor(QPalette::BrightText, Qt::red); + darkPalette.setColor(QPalette::Link, QColor(42, 130, 218)); + darkPalette.setColor(QPalette::Highlight, QColor(42, 130, 218)); + darkPalette.setColor(QPalette::HighlightedText, Qt::black); + darkPalette.setColor(QPalette::Disabled, QPalette::Text, QColor(127, 127, 127)); + darkPalette.setColor(QPalette::Disabled, QPalette::ButtonText, QColor(127, 127, 127)); + app.setPalette(darkPalette); + + auto *backend = new QobuzBackend; + MainWindow window(backend); + window.show(); + + return app.exec(); +} diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp new file mode 100644 index 0000000..da70d07 --- /dev/null +++ b/src/mainwindow.cpp @@ -0,0 +1,251 @@ +#include "mainwindow.hpp" + +#include "dialog/login.hpp" +#include "dialog/settings.hpp" +#include "util/settings.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent) + : QMainWindow(parent) + , m_backend(backend) +{ + setWindowTitle(QStringLiteral("Qobuz")); + setMinimumSize(800, 500); + resize(defaultSize()); + + // ---- Toolbar ---- + m_toolBar = new MainToolBar(m_backend, this); + addToolBar(Qt::TopToolBarArea, m_toolBar); + + // ---- Central content ---- + m_content = new MainContent(m_backend, this); + setCentralWidget(m_content); + + // ---- Library dock (left) ---- + m_library = new List::Library(m_backend, this); + m_libraryDock = new QDockWidget(tr("Library"), this); + m_libraryDock->setObjectName(QStringLiteral("libraryDock")); + m_libraryDock->setFeatures(QDockWidget::DockWidgetMovable); + m_libraryDock->setWidget(m_library); + m_libraryDock->setMinimumWidth(200); + addDockWidget(Qt::LeftDockWidgetArea, m_libraryDock); + + // ---- Search side panel (right) ---- + m_sidePanel = new SidePanel::View(m_backend, this); + m_sidePanel->hide(); + addDockWidget(Qt::RightDockWidgetArea, m_sidePanel); + + // ---- Menu bar ---- + setupMenuBar(); + + // ---- Status bar ---- + statusBar()->showMessage(tr("Ready")); + + // ---- Wire signals ---- + connect(m_backend, &QobuzBackend::loginSuccess, this, &MainWindow::onLoginSuccess); + connect(m_backend, &QobuzBackend::loginError, this, &MainWindow::onLoginError); + connect(m_backend, &QobuzBackend::favTracksLoaded, this, &MainWindow::onFavTracksLoaded); + connect(m_backend, &QobuzBackend::favAlbumsLoaded, this, &MainWindow::onFavAlbumsLoaded); + connect(m_backend, &QobuzBackend::albumLoaded, this, &MainWindow::onAlbumLoaded); + connect(m_backend, &QobuzBackend::artistLoaded, this, &MainWindow::onArtistLoaded); + connect(m_backend, &QobuzBackend::playlistLoaded, this, &MainWindow::onPlaylistLoaded); + connect(m_backend, &QobuzBackend::error, this, [this](const QString &msg) { + statusBar()->showMessage(tr("Error: %1").arg(msg), 5000); + }); + + // Library → backend + connect(m_library, &List::Library::favTracksRequested, m_backend, [this] { + m_backend->getFavTracks(); + statusBar()->showMessage(tr("Loading favorite tracks…")); + }); + connect(m_library, &List::Library::favAlbumsRequested, m_backend, [this] { + m_backend->getFavAlbums(); + statusBar()->showMessage(tr("Loading favorite albums…")); + }); + connect(m_library, &List::Library::favArtistsRequested, m_backend, [this] { + m_backend->getFavArtists(); + statusBar()->showMessage(tr("Loading favorite artists…")); + }); + connect(m_library, &List::Library::playlistRequested, + this, [this](qint64 id, const QString &name) { + m_backend->getPlaylist(id); + statusBar()->showMessage(tr("Loading playlist: %1…").arg(name)); + }); + + // Track list → playback + connect(m_content->tracksList(), &List::Tracks::playTrackRequested, + this, &MainWindow::onPlayTrackRequested); + + // Search panel → backend + connect(m_sidePanel, &SidePanel::View::albumSelected, + this, &MainWindow::onSearchAlbumSelected); + connect(m_sidePanel, &SidePanel::View::artistSelected, + this, &MainWindow::onSearchArtistSelected); + connect(m_sidePanel, &SidePanel::View::trackPlayRequested, + this, &MainWindow::onPlayTrackRequested); + + // Toolbar search toggle → side panel visibility + connect(m_toolBar, &MainToolBar::searchToggled, this, &MainWindow::onSearchToggled); + + // Restore or prompt login + tryRestoreSession(); +} + +void MainWindow::setupMenuBar() +{ + auto *fileMenu = menuBar()->addMenu(tr("&File")); + fileMenu->addAction(tr("&Sign in…"), this, &MainWindow::showLoginDialog); + fileMenu->addSeparator(); + fileMenu->addAction(tr("&Settings…"), this, &MainWindow::showSettingsDialog); + fileMenu->addSeparator(); + fileMenu->addAction(tr("&Quit"), qApp, &QApplication::quit, QKeySequence::Quit); + + auto *viewMenu = menuBar()->addMenu(tr("&View")); + viewMenu->addAction(m_libraryDock->toggleViewAction()); + viewMenu->addAction(m_sidePanel->toggleViewAction()); + + auto *helpMenu = menuBar()->addMenu(tr("&Help")); + helpMenu->addAction(tr("&About"), this, [this] { + QMessageBox::about(this, tr("About Qobuz"), + tr("

qobuz-qt

" + "

A lightweight Qt client for the Qobuz streaming service.

" + "

Audio decoding powered by Symphonia (Rust).

")); + }); +} + +void MainWindow::tryRestoreSession() +{ + const QString token = AppSettings::instance().authToken(); + if (!token.isEmpty()) { + m_backend->setToken(token); + m_library->refresh(); + const QString name = AppSettings::instance().displayName(); + statusBar()->showMessage(tr("Signed in as %1").arg( + name.isEmpty() ? AppSettings::instance().userEmail() : name)); + } else { + // No saved session — prompt login after a short delay so the window is visible first + QTimer::singleShot(200, this, &MainWindow::showLoginDialog); + } +} + +// ---- slots ---- + +void MainWindow::showLoginDialog() +{ + auto *dlg = new LoginDialog(this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + + // Pre-fill if remembered + if (AppSettings::instance().rememberLogin()) + dlg->findChild(); // just show with empty fields + + connect(dlg, &LoginDialog::loginRequested, + this, [this, dlg](const QString &email, const QString &password) { + dlg->setBusy(true); + m_backend->login(email, password); + }); + + connect(m_backend, &QobuzBackend::loginSuccess, dlg, [dlg](const QString &, const QJsonObject &) { + dlg->accept(); + }); + connect(m_backend, &QobuzBackend::loginError, dlg, [dlg](const QString &err) { + dlg->setError(err); + }); + + dlg->exec(); +} + +void MainWindow::showSettingsDialog() +{ + SettingsDialog dlg(this); + dlg.exec(); +} + +void MainWindow::onLoginSuccess(const QString &token, const QJsonObject &user) +{ + AppSettings::instance().setAuthToken(token); + const QString displayName = user["display_name"].toString(); + const QString email = user["email"].toString(); + AppSettings::instance().setDisplayName(displayName); + AppSettings::instance().setUserEmail(email); + + statusBar()->showMessage(tr("Signed in as %1").arg( + displayName.isEmpty() ? email : displayName)); + + m_library->refresh(); +} + +void MainWindow::onLoginError(const QString &error) +{ + statusBar()->showMessage(tr("Login failed: %1").arg(error), 6000); +} + +void MainWindow::onFavTracksLoaded(const QJsonObject &result) +{ + m_content->showFavTracks(result); + statusBar()->showMessage(tr("Favorite tracks loaded."), 3000); +} + +void MainWindow::onFavAlbumsLoaded(const QJsonObject &result) +{ + // Show albums as a track list (each album as a row) + // For now just show the raw tracks from each album + // A proper album grid view would go here in a future iteration + const QJsonArray albums = result["items"].toArray(); + statusBar()->showMessage(tr("%1 favorite albums").arg(albums.size()), 3000); + // TODO: show album grid +} + +void MainWindow::onAlbumLoaded(const QJsonObject &album) +{ + m_content->showAlbum(album); + const QString title = album["title"].toString(); + statusBar()->showMessage(tr("Album: %1").arg(title), 3000); +} + +void MainWindow::onArtistLoaded(const QJsonObject &artist) +{ + // TODO: show artist view with top tracks + discography + statusBar()->showMessage(tr("Artist: %1").arg(artist["name"].toString()), 3000); +} + +void MainWindow::onPlaylistLoaded(const QJsonObject &playlist) +{ + m_content->showPlaylist(playlist); + const QString name = playlist["name"].toString(); + statusBar()->showMessage(tr("Playlist: %1").arg(name), 3000); +} + +void MainWindow::onPlayTrackRequested(qint64 trackId) +{ + const int format = AppSettings::instance().preferredFormat(); + m_backend->playTrack(trackId, format); + statusBar()->showMessage(tr("Loading track…")); +} + +void MainWindow::onSearchAlbumSelected(const QString &albumId) +{ + m_backend->getAlbum(albumId); + statusBar()->showMessage(tr("Loading album…")); +} + +void MainWindow::onSearchArtistSelected(qint64 artistId) +{ + m_backend->getArtist(artistId); + statusBar()->showMessage(tr("Loading artist…")); +} + +void MainWindow::onSearchToggled(bool visible) +{ + m_sidePanel->setVisible(visible); +} diff --git a/src/mainwindow.hpp b/src/mainwindow.hpp new file mode 100644 index 0000000..0e90c87 --- /dev/null +++ b/src/mainwindow.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include "backend/qobuzbackend.hpp" +#include "view/maintoolbar.hpp" +#include "view/maincontent.hpp" +#include "view/sidepanel/view.hpp" +#include "list/library.hpp" + +#include +#include +#include + +class MainWindow : public QMainWindow +{ + Q_OBJECT + +public: + explicit MainWindow(QobuzBackend *backend, QWidget *parent = nullptr); + + static QSize defaultSize() { return {1100, 700}; } + +private slots: + void onLoginSuccess(const QString &token, const QJsonObject &user); + void onLoginError(const QString &error); + + void onFavTracksLoaded(const QJsonObject &result); + void onFavAlbumsLoaded(const QJsonObject &result); + void onAlbumLoaded(const QJsonObject &album); + void onArtistLoaded(const QJsonObject &artist); + void onPlaylistLoaded(const QJsonObject &playlist); + + void onPlayTrackRequested(qint64 trackId); + void onSearchAlbumSelected(const QString &albumId); + void onSearchArtistSelected(qint64 artistId); + + void onSearchToggled(bool visible); + + void showLoginDialog(); + void showSettingsDialog(); + +private: + QobuzBackend *m_backend = nullptr; + MainToolBar *m_toolBar = nullptr; + MainContent *m_content = nullptr; + List::Library *m_library = nullptr; + SidePanel::View *m_sidePanel = nullptr; + + QDockWidget *m_libraryDock = nullptr; + + void setupMenuBar(); + void tryRestoreSession(); +}; diff --git a/src/model/tracklistmodel.cpp b/src/model/tracklistmodel.cpp new file mode 100644 index 0000000..7076a24 --- /dev/null +++ b/src/model/tracklistmodel.cpp @@ -0,0 +1,113 @@ +#include "tracklistmodel.hpp" + +#include +#include + +TrackListModel::TrackListModel(QObject *parent) + : QAbstractTableModel(parent) +{} + +void TrackListModel::setTracks(const QJsonArray &tracks) +{ + beginResetModel(); + m_tracks.clear(); + m_tracks.reserve(tracks.size()); + + for (const QJsonValue &v : tracks) { + const QJsonObject t = v.toObject(); + TrackItem item; + item.id = static_cast(t["id"].toDouble()); + item.number = t["track_number"].toInt(); + item.title = t["title"].toString(); + item.duration = static_cast(t["duration"].toDouble()); + item.hiRes = t["hires_streamable"].toBool(); + item.streamable = t["streamable"].toBool(); + item.raw = t; + + // Performer / artist + const QJsonObject performer = t["performer"].toObject(); + item.artist = performer["name"].toString(); + if (item.artist.isEmpty()) { + const QJsonObject album = t["album"].toObject(); + const QJsonObject artist = album["artist"].toObject(); + item.artist = artist["name"].toString(); + } + + // Album + const QJsonObject album = t["album"].toObject(); + item.album = album["title"].toString(); + item.albumId = album["id"].toString(); + + m_tracks.append(item); + } + endResetModel(); +} + +void TrackListModel::clear() +{ + beginResetModel(); + m_tracks.clear(); + endResetModel(); +} + +int TrackListModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) return 0; + return m_tracks.size(); +} + +int TrackListModel::columnCount(const QModelIndex &parent) const +{ + if (parent.isValid()) return 0; + return ColCount; +} + +QVariant TrackListModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= m_tracks.size()) + return {}; + + const TrackItem &t = m_tracks.at(index.row()); + + if (role == Qt::DisplayRole) { + switch (index.column()) { + case ColNumber: return t.number > 0 ? QString::number(t.number) : QString(); + case ColTitle: return t.title; + case ColArtist: return t.artist; + case ColAlbum: return t.album; + case ColDuration: return formatDuration(t.duration); + } + } + + if (role == Qt::ForegroundRole && !t.streamable) { + return QColor(Qt::gray); + } + + if (role == TrackIdRole) return t.id; + if (role == TrackJsonRole) return t.raw; + if (role == HiResRole) return t.hiRes; + + return {}; +} + +QVariant TrackListModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (orientation != Qt::Horizontal || role != Qt::DisplayRole) + return {}; + + switch (section) { + case ColNumber: return tr("#"); + case ColTitle: return tr("Title"); + case ColArtist: return tr("Artist"); + case ColAlbum: return tr("Album"); + case ColDuration: return tr("Duration"); + } + return {}; +} + +QString TrackListModel::formatDuration(qint64 secs) +{ + const int m = static_cast(secs / 60); + const int s = static_cast(secs % 60); + return QStringLiteral("%1:%2").arg(m).arg(s, 2, 10, QLatin1Char('0')); +} diff --git a/src/model/tracklistmodel.hpp b/src/model/tracklistmodel.hpp new file mode 100644 index 0000000..ba3cf44 --- /dev/null +++ b/src/model/tracklistmodel.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include +#include + +/// Flat data stored for each row in the track list. +struct TrackItem { + qint64 id = 0; + int number = 0; + QString title; + QString artist; + QString album; + QString albumId; + qint64 duration = 0; // seconds + bool hiRes = false; + bool streamable = false; + QJsonObject raw; // full JSON for context menus / playback +}; + +class TrackListModel : public QAbstractTableModel +{ + Q_OBJECT + +public: + enum Column { + ColNumber = 0, + ColTitle = 1, + ColArtist = 2, + ColAlbum = 3, + ColDuration = 4, + ColCount + }; + + enum Role { + TrackIdRole = Qt::UserRole + 1, + TrackJsonRole = Qt::UserRole + 2, + HiResRole = Qt::UserRole + 3, + }; + + explicit TrackListModel(QObject *parent = nullptr); + + void setTracks(const QJsonArray &tracks); + void clear(); + + const TrackItem &trackAt(int row) const { return m_tracks.at(row); } + int rowCount(const QModelIndex &parent = {}) const override; + int columnCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; + + static QString formatDuration(qint64 secs); + +private: + QVector m_tracks; +}; diff --git a/src/util/icon.hpp b/src/util/icon.hpp new file mode 100644 index 0000000..38ff42c --- /dev/null +++ b/src/util/icon.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +namespace Icon +{ + inline QIcon get(const QString &name) + { + // Try theme icon first, fall back to resource + if (QIcon::hasThemeIcon(name)) + return QIcon::fromTheme(name); + return QIcon(QStringLiteral(":/icons/%1.svg").arg(name)); + } + + // Convenient aliases for common icons used throughout the app + inline QIcon play() { return get("media-playback-start"); } + inline QIcon pause() { return get("media-playback-pause"); } + inline QIcon stop() { return get("media-playback-stop"); } + inline QIcon next() { return get("media-skip-forward"); } + inline QIcon previous() { return get("media-skip-backward"); } + inline QIcon shuffle() { return get("media-playlist-shuffle"); } + inline QIcon repeat() { return get("media-playlist-repeat"); } + inline QIcon volumeHigh() { return get("audio-volume-high"); } + inline QIcon volumeMid() { return get("audio-volume-medium"); } + inline QIcon volumeMute() { return get("audio-volume-muted"); } + inline QIcon search() { return get("edit-find"); } + inline QIcon heart() { return get("emblem-favorite"); } + inline QIcon album() { return get("media-optical"); } + inline QIcon artist() { return get("system-users"); } + inline QIcon playlist() { return get("view-list-symbolic"); } +} diff --git a/src/util/settings.hpp b/src/util/settings.hpp new file mode 100644 index 0000000..564cbfc --- /dev/null +++ b/src/util/settings.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include + +class AppSettings +{ +public: + static AppSettings &instance() + { + static AppSettings inst; + return inst; + } + + QString authToken() const { return m_settings.value("auth/token").toString(); } + void setAuthToken(const QString &t) { m_settings.setValue("auth/token", t); } + + QString userEmail() const { return m_settings.value("auth/email").toString(); } + void setUserEmail(const QString &e) { m_settings.setValue("auth/email", e); } + + QString displayName() const { return m_settings.value("user/display_name").toString(); } + void setDisplayName(const QString &n) { m_settings.setValue("user/display_name", n); } + + // 5 = MP3, 6 = CD, 7 = HiRes96, 27 = HiRes192 + int preferredFormat() const { return m_settings.value("playback/format", 6).toInt(); } + void setPreferredFormat(int f) { m_settings.setValue("playback/format", f); } + + int volume() const { return m_settings.value("playback/volume", 80).toInt(); } + void setVolume(int v) { m_settings.setValue("playback/volume", v); } + + bool rememberLogin() const { return m_settings.value("auth/remember", true).toBool(); } + void setRememberLogin(bool r) { m_settings.setValue("auth/remember", r); } + +private: + AppSettings() : m_settings("qobuz-qt", "qobuz-qt") {} + QSettings m_settings; +}; diff --git a/src/view/maincontent.cpp b/src/view/maincontent.cpp new file mode 100644 index 0000000..4b6ae88 --- /dev/null +++ b/src/view/maincontent.cpp @@ -0,0 +1,58 @@ +#include "maincontent.hpp" + +#include +#include + +MainContent::MainContent(QobuzBackend *backend, QWidget *parent) + : QWidget(parent) + , m_backend(backend) +{ + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + + m_stack = new QStackedWidget(this); + layout->addWidget(m_stack); + + m_welcome = new QLabel( + tr("

Welcome to Qobuz

" + "

Select something from the library on the left to get started.

"), + this); + m_welcome->setAlignment(Qt::AlignCenter); + + m_tracks = new List::Tracks(m_backend, this); + + m_stack->addWidget(m_welcome); // index 0 + m_stack->addWidget(m_tracks); // index 1 + + m_stack->setCurrentIndex(0); +} + +void MainContent::showWelcome() +{ + m_stack->setCurrentIndex(0); +} + +void MainContent::showAlbum(const QJsonObject &album) +{ + m_tracks->loadAlbum(album); + m_stack->setCurrentIndex(1); +} + +void MainContent::showPlaylist(const QJsonObject &playlist) +{ + m_tracks->loadPlaylist(playlist); + m_stack->setCurrentIndex(1); +} + +void MainContent::showFavTracks(const QJsonObject &result) +{ + const QJsonArray items = result["items"].toArray(); + m_tracks->loadTracks(items); + m_stack->setCurrentIndex(1); +} + +void MainContent::showSearchTracks(const QJsonArray &tracks) +{ + m_tracks->loadSearchTracks(tracks); + m_stack->setCurrentIndex(1); +} diff --git a/src/view/maincontent.hpp b/src/view/maincontent.hpp new file mode 100644 index 0000000..d16044d --- /dev/null +++ b/src/view/maincontent.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include "../list/tracks.hpp" +#include "../backend/qobuzbackend.hpp" + +#include +#include +#include +#include +#include + +/// Central content widget — mirrors MainContent from spotify-qt. +/// Displays either a track list, an album cover + tracks, or a search result. +class MainContent : public QWidget +{ + Q_OBJECT + +public: + explicit MainContent(QobuzBackend *backend, QWidget *parent = nullptr); + + List::Tracks *tracksList() const { return m_tracks; } + + void showWelcome(); + void showAlbum(const QJsonObject &album); + void showPlaylist(const QJsonObject &playlist); + void showFavTracks(const QJsonObject &result); + void showSearchTracks(const QJsonArray &tracks); + +private: + QobuzBackend *m_backend = nullptr; + QStackedWidget *m_stack = nullptr; + QLabel *m_welcome = nullptr; + List::Tracks *m_tracks = nullptr; +}; diff --git a/src/view/maintoolbar.cpp b/src/view/maintoolbar.cpp new file mode 100644 index 0000000..bef877b --- /dev/null +++ b/src/view/maintoolbar.cpp @@ -0,0 +1,173 @@ +#include "maintoolbar.hpp" +#include "../util/settings.hpp" +#include "../model/tracklistmodel.hpp" + +#include +#include + +MainToolBar::MainToolBar(QobuzBackend *backend, QWidget *parent) + : QToolBar(parent) + , m_backend(backend) +{ + setMovable(false); + setFloatable(false); + setContextMenuPolicy(Qt::PreventContextMenu); + + // Previous + m_previous = addAction(Icon::previous(), tr("Previous")); + connect(m_previous, &QAction::triggered, this, &MainToolBar::onPrevious); + + // Play/Pause + m_playPause = addAction(Icon::play(), tr("Play")); + m_playPause->setCheckable(false); + connect(m_playPause, &QAction::triggered, this, [this](bool) { onPlayPause(false); }); + + // Next + m_next = addAction(Icon::next(), tr("Next")); + connect(m_next, &QAction::triggered, this, &MainToolBar::onNext); + + addSeparator(); + + // Track label + m_trackLabel = new QLabel(tr("Not playing"), this); + m_trackLabel->setMinimumWidth(200); + m_trackLabel->setMaximumWidth(320); + addWidget(m_trackLabel); + + addSeparator(); + + // Progress slider + m_progress = new ClickableSlider(Qt::Horizontal, this); + m_progress->setRange(0, 1000); + m_progress->setValue(0); + m_progress->setMinimumWidth(180); + m_progress->setMaximumWidth(400); + addWidget(m_progress); + + connect(m_progress, &QSlider::sliderPressed, this, [this] { m_seeking = true; }); + connect(m_progress, &QSlider::sliderReleased, this, &MainToolBar::onProgressReleased); + + // Time label + m_timeLabel = new QLabel("0:00 / 0:00", this); + addWidget(m_timeLabel); + + addSeparator(); + + // Volume button + m_volume = new VolumeButton(this); + m_volume->setValue(AppSettings::instance().volume()); + addWidget(m_volume); + connect(m_volume, &VolumeButton::volumeChanged, this, &MainToolBar::onVolumeChanged); + + addSeparator(); + + // Search toggle + m_search = addAction(Icon::search(), tr("Search")); + m_search->setCheckable(true); + connect(m_search, &QAction::toggled, this, &MainToolBar::searchToggled); + + // Connect to backend signals + connect(m_backend, &QobuzBackend::stateChanged, this, &MainToolBar::onBackendStateChanged); + connect(m_backend, &QobuzBackend::trackChanged, this, &MainToolBar::onTrackChanged); + connect(m_backend, &QobuzBackend::positionChanged, this, &MainToolBar::onPositionChanged); + connect(m_backend, &QobuzBackend::trackFinished, this, &MainToolBar::onTrackFinished); +} + +void MainToolBar::setPlaying(bool playing) +{ + m_playing = playing; + m_playPause->setIcon(playing ? Icon::pause() : Icon::play()); + m_playPause->setText(playing ? tr("Pause") : tr("Play")); +} + +void MainToolBar::setCurrentTrack(const QJsonObject &track) +{ + const QString title = track["title"].toString(); + const QJsonObject performer = track["performer"].toObject(); + const QString artist = performer["name"].toString(); + + if (title.isEmpty()) { + m_trackLabel->setText(tr("Not playing")); + } else if (artist.isEmpty()) { + m_trackLabel->setText(title); + } else { + m_trackLabel->setText(QStringLiteral("%1 — %2").arg(artist, title)); + } +} + +void MainToolBar::updateProgress(quint64 position, quint64 duration) +{ + if (m_seeking) return; + + const int sliderPos = duration > 0 + ? static_cast(position * 1000 / duration) + : 0; + m_progress->blockSignals(true); + m_progress->setValue(sliderPos); + m_progress->blockSignals(false); + + const QString pos = TrackListModel::formatDuration(static_cast(position)); + const QString dur = TrackListModel::formatDuration(static_cast(duration)); + m_timeLabel->setText(QStringLiteral("%1 / %2").arg(pos, dur)); +} + +void MainToolBar::setVolume(int volume) +{ + m_volume->setValue(volume); +} + +// --- private slots --- + +void MainToolBar::onPlayPause(bool) +{ + if (m_playing) { + m_backend->pause(); + } else { + m_backend->resume(); + } +} + +void MainToolBar::onPrevious() +{ + // TODO: wire up to a queue / playlist +} + +void MainToolBar::onNext() +{ + // TODO: wire up to a queue / playlist +} + +void MainToolBar::onProgressReleased() +{ + m_seeking = false; + // Seek is not yet implemented in the Rust player, but we can at least + // update the displayed position optimistically. +} + +void MainToolBar::onVolumeChanged(int volume) +{ + m_backend->setVolume(volume); + AppSettings::instance().setVolume(volume); +} + +void MainToolBar::onBackendStateChanged(const QString &state) +{ + setPlaying(state == QStringLiteral("playing")); +} + +void MainToolBar::onTrackChanged(const QJsonObject &track) +{ + setCurrentTrack(track); +} + +void MainToolBar::onPositionChanged(quint64 position, quint64 duration) +{ + updateProgress(position, duration); +} + +void MainToolBar::onTrackFinished() +{ + setPlaying(false); + m_progress->setValue(0); + m_timeLabel->setText("0:00 / 0:00"); +} diff --git a/src/view/maintoolbar.hpp b/src/view/maintoolbar.hpp new file mode 100644 index 0000000..e038bf1 --- /dev/null +++ b/src/view/maintoolbar.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include "../backend/qobuzbackend.hpp" +#include "../widget/volumebutton.hpp" +#include "../widget/clickableslider.hpp" +#include "../util/icon.hpp" + +#include +#include +#include +#include +#include + +/// Main playback toolbar — mirrors MainToolBar from spotify-qt. +class MainToolBar : public QToolBar +{ + Q_OBJECT + +public: + explicit MainToolBar(QobuzBackend *backend, QWidget *parent = nullptr); + + void setPlaying(bool playing); + void setCurrentTrack(const QJsonObject &track); + void updateProgress(quint64 position, quint64 duration); + void setVolume(int volume); + +signals: + void searchToggled(bool visible); + +private slots: + void onPlayPause(bool checked); + void onPrevious(); + void onNext(); + void onProgressReleased(); + void onVolumeChanged(int volume); + + void onBackendStateChanged(const QString &state); + void onTrackChanged(const QJsonObject &track); + void onPositionChanged(quint64 position, quint64 duration); + void onTrackFinished(); + +private: + QobuzBackend *m_backend = nullptr; + + QAction *m_previous = nullptr; + QAction *m_playPause = nullptr; + QAction *m_next = nullptr; + QAction *m_search = nullptr; + + ClickableSlider *m_progress = nullptr; + QLabel *m_timeLabel = nullptr; + QLabel *m_trackLabel = nullptr; + VolumeButton *m_volume = nullptr; + + bool m_playing = false; + bool m_seeking = false; + + // Playback queue (track IDs) for next/prev + QVector m_queue; + int m_queueIdx = -1; + + void addSpacerWidget(); +}; diff --git a/src/view/sidepanel/view.cpp b/src/view/sidepanel/view.cpp new file mode 100644 index 0000000..176507c --- /dev/null +++ b/src/view/sidepanel/view.cpp @@ -0,0 +1,139 @@ +#include "view.hpp" + +#include +#include +#include +#include + +static constexpr int IdRole = Qt::UserRole + 1; +static constexpr int TypeRole = Qt::UserRole + 2; + +namespace SidePanel +{ + +// ---- SearchTab ---- + +SearchTab::SearchTab(QobuzBackend *backend, QWidget *parent) + : QWidget(parent) + , m_backend(backend) +{ + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(4, 4, 4, 4); + + // Search bar + auto *barLayout = new QHBoxLayout; + m_searchBox = new QLineEdit(this); + m_searchBox->setPlaceholderText(tr("Search Qobuz…")); + m_searchBox->setClearButtonEnabled(true); + auto *searchBtn = new QPushButton(tr("Go"), this); + barLayout->addWidget(m_searchBox); + barLayout->addWidget(searchBtn); + layout->addLayout(barLayout); + + // Result tabs + m_resultTabs = new QTabWidget(this); + + m_trackResults = new QTreeWidget(this); + m_trackResults->setHeaderLabels({tr("Title"), tr("Artist"), tr("Album")}); + m_trackResults->setRootIsDecorated(false); + + m_albumResults = new QTreeWidget(this); + m_albumResults->setHeaderLabels({tr("Album"), tr("Artist")}); + m_albumResults->setRootIsDecorated(false); + + m_artistResults = new QTreeWidget(this); + m_artistResults->setHeaderLabels({tr("Artist")}); + m_artistResults->setRootIsDecorated(false); + + m_resultTabs->addTab(m_trackResults, tr("Tracks")); + m_resultTabs->addTab(m_albumResults, tr("Albums")); + m_resultTabs->addTab(m_artistResults, tr("Artists")); + layout->addWidget(m_resultTabs); + + connect(searchBtn, &QPushButton::clicked, this, &SearchTab::onSearchSubmit); + connect(m_searchBox, &QLineEdit::returnPressed, this, &SearchTab::onSearchSubmit); + + connect(m_backend, &QobuzBackend::searchResult, this, &SearchTab::onSearchResult); + + connect(m_trackResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked); + connect(m_albumResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked); + connect(m_artistResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked); +} + +void SearchTab::onSearchSubmit() +{ + const QString q = m_searchBox->text().trimmed(); + if (!q.isEmpty()) + m_backend->search(q, 0, 20); +} + +void SearchTab::onSearchResult(const QJsonObject &result) +{ + // Populate tracks + m_trackResults->clear(); + const QJsonArray tracks = result["tracks"].toObject()["items"].toArray(); + for (const auto &v : tracks) { + const QJsonObject t = v.toObject(); + const QString performer = t["performer"].toObject()["name"].toString(); + const QString album = t["album"].toObject()["title"].toString(); + auto *item = new QTreeWidgetItem(m_trackResults, + QStringList{t["title"].toString(), performer, album}); + item->setData(0, IdRole, static_cast(t["id"].toDouble())); + item->setData(0, TypeRole, QStringLiteral("track")); + } + + // Populate albums + m_albumResults->clear(); + const QJsonArray albums = result["albums"].toObject()["items"].toArray(); + for (const auto &v : albums) { + const QJsonObject a = v.toObject(); + const QString artist = a["artist"].toObject()["name"].toString(); + auto *item = new QTreeWidgetItem(m_albumResults, + QStringList{a["title"].toString(), artist}); + item->setData(0, IdRole, a["id"].toString()); + item->setData(0, TypeRole, QStringLiteral("album")); + } + + // Populate artists + m_artistResults->clear(); + const QJsonArray artists = result["artists"].toObject()["items"].toArray(); + for (const auto &v : artists) { + const QJsonObject ar = v.toObject(); + auto *item = new QTreeWidgetItem(m_artistResults, + QStringList{ar["name"].toString()}); + item->setData(0, IdRole, static_cast(ar["id"].toDouble())); + item->setData(0, TypeRole, QStringLiteral("artist")); + } +} + +void SearchTab::onItemDoubleClicked(QTreeWidgetItem *item, int) +{ + if (!item) return; + const QString type = item->data(0, TypeRole).toString(); + + if (type == QStringLiteral("track")) { + emit trackPlayRequested(item->data(0, IdRole).toLongLong()); + } else if (type == QStringLiteral("album")) { + emit albumSelected(item->data(0, IdRole).toString()); + } else if (type == QStringLiteral("artist")) { + emit artistSelected(item->data(0, IdRole).toLongLong()); + } +} + +// ---- View ---- + +View::View(QobuzBackend *backend, QWidget *parent) + : QDockWidget(tr("Search"), parent) +{ + setObjectName(QStringLiteral("searchPanel")); + setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetClosable); + + m_search = new SearchTab(backend, this); + setWidget(m_search); + + connect(m_search, &SearchTab::albumSelected, this, &View::albumSelected); + connect(m_search, &SearchTab::artistSelected, this, &View::artistSelected); + connect(m_search, &SearchTab::trackPlayRequested, this, &View::trackPlayRequested); +} + +} // namespace SidePanel diff --git a/src/view/sidepanel/view.hpp b/src/view/sidepanel/view.hpp new file mode 100644 index 0000000..31432ce --- /dev/null +++ b/src/view/sidepanel/view.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include "../../backend/qobuzbackend.hpp" + +#include +#include +#include +#include +#include +#include + +namespace SidePanel +{ + class SearchTab : public QWidget + { + Q_OBJECT + public: + explicit SearchTab(QobuzBackend *backend, QWidget *parent = nullptr); + + signals: + void albumSelected(const QString &albumId); + void artistSelected(qint64 artistId); + void trackPlayRequested(qint64 trackId); + + private slots: + void onSearchResult(const QJsonObject &result); + void onSearchSubmit(); + void onItemDoubleClicked(QTreeWidgetItem *item, int column); + + private: + QobuzBackend *m_backend = nullptr; + QLineEdit *m_searchBox = nullptr; + QTabWidget *m_resultTabs = nullptr; + QTreeWidget *m_trackResults = nullptr; + QTreeWidget *m_albumResults = nullptr; + QTreeWidget *m_artistResults = nullptr; + }; + + class View : public QDockWidget + { + Q_OBJECT + public: + explicit View(QobuzBackend *backend, QWidget *parent = nullptr); + + SearchTab *searchTab() const { return m_search; } + + signals: + void albumSelected(const QString &albumId); + void artistSelected(qint64 artistId); + void trackPlayRequested(qint64 trackId); + + private: + SearchTab *m_search = nullptr; + }; +} diff --git a/src/widget/clickableslider.hpp b/src/widget/clickableslider.hpp new file mode 100644 index 0000000..a247ee5 --- /dev/null +++ b/src/widget/clickableslider.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +/// A QSlider that jumps to the clicked position instead of only moving one step. +class ClickableSlider : public QSlider +{ + Q_OBJECT + +public: + explicit ClickableSlider(Qt::Orientation orientation, QWidget *parent = nullptr) + : QSlider(orientation, parent) {} + +protected: + void mousePressEvent(QMouseEvent *event) override + { + if (event->button() == Qt::LeftButton) { + int val; + if (orientation() == Qt::Horizontal) { + val = minimum() + (maximum() - minimum()) * event->pos().x() / width(); + } else { + val = minimum() + (maximum() - minimum()) * (height() - event->pos().y()) / height(); + } + setValue(val); + emit sliderMoved(val); + event->accept(); + } else { + QSlider::mousePressEvent(event); + } + } +}; diff --git a/src/widget/volumebutton.hpp b/src/widget/volumebutton.hpp new file mode 100644 index 0000000..2354190 --- /dev/null +++ b/src/widget/volumebutton.hpp @@ -0,0 +1,75 @@ +#pragma once + +#include "clickableslider.hpp" +#include "../util/icon.hpp" + +#include +#include +#include +#include +#include + +/// A toolbar button that shows a volume slider popup when clicked. +class VolumeButton : public QToolButton +{ + Q_OBJECT + +public: + explicit VolumeButton(QWidget *parent = nullptr) : QToolButton(parent) + { + setPopupMode(QToolButton::InstantPopup); + setIcon(Icon::volumeHigh()); + + auto *menu = new QMenu(this); + auto *widget = new QWidget(menu); + auto *layout = new QVBoxLayout(widget); + layout->setContentsMargins(6, 6, 6, 6); + + m_label = new QLabel("80%", widget); + m_label->setAlignment(Qt::AlignCenter); + + m_slider = new ClickableSlider(Qt::Vertical, widget); + m_slider->setRange(0, 100); + m_slider->setValue(80); + m_slider->setFixedHeight(120); + + layout->addWidget(m_label); + layout->addWidget(m_slider); + + auto *action = new QWidgetAction(menu); + action->setDefaultWidget(widget); + menu->addAction(action); + setMenu(menu); + + connect(m_slider, &QSlider::valueChanged, this, [this](int v) { + m_label->setText(QString::number(v) + "%"); + updateIcon(v); + emit volumeChanged(v); + }); + } + + int value() const { return m_slider->value(); } + + void setValue(int v) + { + m_slider->blockSignals(true); + m_slider->setValue(v); + m_slider->blockSignals(false); + m_label->setText(QString::number(v) + "%"); + updateIcon(v); + } + +signals: + void volumeChanged(int volume); + +private: + ClickableSlider *m_slider = nullptr; + QLabel *m_label = nullptr; + + void updateIcon(int v) + { + if (v == 0) setIcon(Icon::volumeMute()); + else if (v < 50) setIcon(Icon::volumeMid()); + else setIcon(Icon::volumeHigh()); + } +};