Initial implementation of qobuz-qt
- 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 <noreply@anthropic.com>
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
build/
|
||||||
|
target/
|
||||||
|
*.o
|
||||||
|
*.a
|
||||||
|
*.so
|
||||||
|
Cargo.lock
|
||||||
83
BUILD.md
Normal file
83
BUILD.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
95
CMakeLists.txt
Normal file
95
CMakeLists.txt
Normal file
@@ -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 ()
|
||||||
7
Cargo.toml
Normal file
7
Cargo.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[workspace]
|
||||||
|
members = ["rust"]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = "thin"
|
||||||
|
opt-level = 3
|
||||||
6
res.qrc
Normal file
6
res.qrc
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<RCC>
|
||||||
|
<qresource prefix="/">
|
||||||
|
<!-- Icons will be served from the system theme; this is a fallback stub. -->
|
||||||
|
<!-- Add .svg files to res/icons/ and list them here if needed. -->
|
||||||
|
</qresource>
|
||||||
|
</RCC>
|
||||||
33
rust/Cargo.toml
Normal file
33
rust/Cargo.toml
Normal file
@@ -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
|
||||||
79
rust/include/qobuz_backend.h
Normal file
79
rust/include/qobuz_backend.h
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#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
|
||||||
371
rust/src/api/client.rs
Normal file
371
rust/src/api/client.rs
Normal file
@@ -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<String>,
|
||||||
|
app_id: String,
|
||||||
|
app_secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QobuzClient {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
Self::new_with_config(None, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_with_config(app_id: Option<&str>, app_secret: Option<&str>) -> Result<Self> {
|
||||||
|
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<Value> {
|
||||||
|
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<OAuthLoginResponse> {
|
||||||
|
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<OAuthLoginResponse> {
|
||||||
|
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<OAuthLoginResponse> {
|
||||||
|
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<OAuthLoginResponse> {
|
||||||
|
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<UserDto> {
|
||||||
|
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<TrackDto> {
|
||||||
|
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<TrackFileUrlDto> {
|
||||||
|
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<AlbumDto> {
|
||||||
|
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<ArtistDto> {
|
||||||
|
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<SearchCatalogDto> {
|
||||||
|
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<UserPlaylistsDto> {
|
||||||
|
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<PlaylistDto> {
|
||||||
|
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<SearchResultItems<TrackDto>> {
|
||||||
|
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<SearchResultItems<AlbumDto>> {
|
||||||
|
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<SearchResultItems<FavArtistDto>> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
5
rust/src/api/mod.rs
Normal file
5
rust/src/api/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod client;
|
||||||
|
pub mod models;
|
||||||
|
|
||||||
|
pub use client::QobuzClient;
|
||||||
|
pub use models::*;
|
||||||
260
rust/src/api/models.rs
Normal file
260
rust/src/api/models.rs
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||||
|
pub struct OAuthDto {
|
||||||
|
pub token_type: Option<String>,
|
||||||
|
pub access_token: Option<String>,
|
||||||
|
pub refresh_token: Option<String>,
|
||||||
|
pub expires_in: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct OAuthLoginResponse {
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub user: Option<UserDto>,
|
||||||
|
pub oauth2: Option<OAuthDto>,
|
||||||
|
pub user_auth_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- User ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||||
|
pub struct UserDto {
|
||||||
|
pub id: Option<i64>,
|
||||||
|
pub login: Option<String>,
|
||||||
|
pub firstname: Option<String>,
|
||||||
|
pub lastname: Option<String>,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub country_code: Option<String>,
|
||||||
|
pub subscription: Option<SubscriptionDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||||
|
pub struct SubscriptionDto {
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub end_date: Option<String>,
|
||||||
|
pub is_recurring: Option<bool>,
|
||||||
|
pub offer: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Track ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||||
|
pub struct TrackDto {
|
||||||
|
pub id: i64,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub duration: Option<i64>,
|
||||||
|
pub track_number: Option<i32>,
|
||||||
|
pub album: Option<AlbumDto>,
|
||||||
|
pub performer: Option<ArtistDto>,
|
||||||
|
pub composer: Option<ArtistDto>,
|
||||||
|
pub work: Option<String>,
|
||||||
|
pub media_number: Option<i32>,
|
||||||
|
pub streamable: Option<bool>,
|
||||||
|
pub purchasable: Option<bool>,
|
||||||
|
pub hires: Option<bool>,
|
||||||
|
pub hires_streamable: Option<bool>,
|
||||||
|
pub audio_info: Option<AudioInfoDto>,
|
||||||
|
pub maximum_bit_depth: Option<i32>,
|
||||||
|
pub maximum_sampling_rate: Option<f64>,
|
||||||
|
pub maximum_channel_count: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||||
|
pub struct AudioInfoDto {
|
||||||
|
pub replaygain_track_gain: Option<f64>,
|
||||||
|
pub replaygain_track_peak: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||||
|
pub struct TrackFileUrlDto {
|
||||||
|
pub track_id: Option<i64>,
|
||||||
|
pub duration: Option<i64>,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub format_id: Option<i32>,
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
pub sampling_rate: Option<f64>,
|
||||||
|
pub bit_depth: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Album ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||||
|
pub struct AlbumDto {
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<ArtistDto>,
|
||||||
|
pub tracks_count: Option<i32>,
|
||||||
|
pub duration: Option<i64>,
|
||||||
|
pub genre: Option<GenreDto>,
|
||||||
|
pub image: Option<ImageDto>,
|
||||||
|
pub label: Option<LabelDto>,
|
||||||
|
pub release_date_original: Option<String>,
|
||||||
|
pub maximum_bit_depth: Option<i32>,
|
||||||
|
pub maximum_sampling_rate: Option<f64>,
|
||||||
|
pub hires_streamable: Option<bool>,
|
||||||
|
pub streamable: Option<bool>,
|
||||||
|
pub tracks: Option<TracksWrapper>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||||
|
pub struct TracksWrapper {
|
||||||
|
pub items: Option<Vec<TrackDto>>,
|
||||||
|
pub total: Option<i32>,
|
||||||
|
pub offset: Option<i32>,
|
||||||
|
pub limit: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Artist ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||||
|
pub struct ArtistDto {
|
||||||
|
pub id: Option<i64>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub albums_count: Option<i32>,
|
||||||
|
pub image: Option<ImageDto>,
|
||||||
|
pub biography: Option<BiographyDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||||
|
pub struct BiographyDto {
|
||||||
|
pub content: Option<String>,
|
||||||
|
pub summary: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Genre ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||||
|
pub struct GenreDto {
|
||||||
|
pub id: Option<i64>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub slug: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Image ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||||
|
pub struct ImageDto {
|
||||||
|
pub small: Option<String>,
|
||||||
|
pub thumbnail: Option<String>,
|
||||||
|
pub large: Option<String>,
|
||||||
|
pub back: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Label ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||||
|
pub struct LabelDto {
|
||||||
|
pub id: Option<i64>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Search ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct SearchCatalogDto {
|
||||||
|
pub query: Option<String>,
|
||||||
|
pub albums: Option<SearchResultItems<AlbumDto>>,
|
||||||
|
pub tracks: Option<SearchResultItems<TrackDto>>,
|
||||||
|
pub artists: Option<SearchResultItems<ArtistDto>>,
|
||||||
|
pub playlists: Option<SearchResultItems<PlaylistDto>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||||
|
pub struct SearchResultItems<T> {
|
||||||
|
pub items: Option<Vec<T>>,
|
||||||
|
pub total: Option<i32>,
|
||||||
|
pub offset: Option<i32>,
|
||||||
|
pub limit: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Playlist ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||||
|
pub struct PlaylistDto {
|
||||||
|
pub id: Option<i64>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub tracks_count: Option<i32>,
|
||||||
|
pub duration: Option<i64>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub owner: Option<PlaylistOwnerDto>,
|
||||||
|
pub image_rectangle: Option<Vec<String>>,
|
||||||
|
pub tracks: Option<TracksWrapper>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||||
|
pub struct PlaylistOwnerDto {
|
||||||
|
pub id: Option<i64>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- User library ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct UserPlaylistsDto {
|
||||||
|
pub playlists: Option<SearchResultItems<PlaylistDto>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct FavArtistDto {
|
||||||
|
pub id: Option<i64>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub albums_count: Option<i32>,
|
||||||
|
pub image: Option<ImageDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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<QwsToken>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||||
|
pub struct QwsToken {
|
||||||
|
pub exp: Option<i64>,
|
||||||
|
pub jwt: Option<String>,
|
||||||
|
pub endpoint: Option<String>,
|
||||||
|
}
|
||||||
486
rust/src/lib.rs
Normal file
486
rust/src/lib.rs
Normal file
@@ -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<Mutex<QobuzClient>>,
|
||||||
|
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<F>(inner: &BackendInner, f: F)
|
||||||
|
where
|
||||||
|
F: std::future::Future<Output = ()> + 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()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
207
rust/src/player/decoder.rs
Normal file
207
rust/src/player/decoder.rs
Normal file
@@ -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<u8>,
|
||||||
|
reader_pos: u64,
|
||||||
|
pos: u64,
|
||||||
|
content_length: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpStreamSource {
|
||||||
|
fn new(response: reqwest::blocking::Response, content_length: Option<u64>) -> 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<usize> {
|
||||||
|
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<u64> {
|
||||||
|
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<u64> {
|
||||||
|
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<AtomicBool>,
|
||||||
|
paused: &Arc<AtomicBool>,
|
||||||
|
) -> 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(())
|
||||||
|
}
|
||||||
186
rust/src/player/mod.rs
Normal file
186
rust/src/player/mod.rs
Normal file
@@ -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<std::sync::Mutex<PlayerState>>,
|
||||||
|
pub position_secs: Arc<AtomicU64>,
|
||||||
|
pub duration_secs: Arc<AtomicU64>,
|
||||||
|
pub volume: Arc<AtomicU8>,
|
||||||
|
pub current_track: Arc<std::sync::Mutex<Option<TrackDto>>>,
|
||||||
|
/// Set to true by the decode thread when a track finishes naturally.
|
||||||
|
pub track_finished: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<TrackDto> {
|
||||||
|
self.current_track.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Player {
|
||||||
|
pub cmd_tx: std::sync::mpsc::SyncSender<PlayerCommand>,
|
||||||
|
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<PlayerCommand>, 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
rust/src/player/output.rs
Normal file
81
rust/src/player/output.rs
Normal file
@@ -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<f32>,
|
||||||
|
_stream: cpal::Stream,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioOutput {
|
||||||
|
pub fn try_open(sample_rate: u32, channels: usize) -> Result<Self> {
|
||||||
|
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<AtomicBool>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut sample_buf = symphonia::core::audio::SampleBuffer::<f32>::new(
|
||||||
|
decoded.capacity() as u64,
|
||||||
|
*decoded.spec(),
|
||||||
|
);
|
||||||
|
sample_buf.copy_interleaved_ref(decoded);
|
||||||
|
let samples: Vec<f32> = 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/CMakeLists.txt
Normal file
44
src/CMakeLists.txt
Normal file
@@ -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
|
||||||
|
)
|
||||||
221
src/backend/qobuzbackend.cpp
Normal file
221
src/backend/qobuzbackend.cpp
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
#include "qobuzbackend.hpp"
|
||||||
|
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QMetaObject>
|
||||||
|
|
||||||
|
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<quint8>(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<QobuzBackend *>(userdata);
|
||||||
|
// Marshal from Rust thread → Qt main thread
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
self,
|
||||||
|
"onEvent",
|
||||||
|
Qt::QueuedConnection,
|
||||||
|
Q_ARG(int, eventType),
|
||||||
|
Q_ARG(QString, QString::fromUtf8(json))
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/backend/qobuzbackend.hpp
Normal file
94
src/backend/qobuzbackend.hpp
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "qobuz_backend.h"
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
};
|
||||||
80
src/dialog/login.cpp
Normal file
80
src/dialog/login.cpp
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
#include "login.hpp"
|
||||||
|
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
#include <QFormLayout>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QDialogButtonBox>
|
||||||
|
|
||||||
|
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("<h2>Qobuz</h2>"), 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);
|
||||||
|
}
|
||||||
32
src/dialog/login.hpp
Normal file
32
src/dialog/login.hpp
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QDialog>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QCheckBox>
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
54
src/dialog/settings.cpp
Normal file
54
src/dialog/settings.cpp
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#include "settings.hpp"
|
||||||
|
#include "../util/settings.hpp"
|
||||||
|
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
#include <QFormLayout>
|
||||||
|
#include <QDialogButtonBox>
|
||||||
|
#include <QGroupBox>
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
18
src/dialog/settings.hpp
Normal file
18
src/dialog/settings.hpp
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QDialog>
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QLabel>
|
||||||
|
|
||||||
|
class SettingsDialog : public QDialog
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit SettingsDialog(QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QComboBox *m_formatBox = nullptr;
|
||||||
|
|
||||||
|
void applyChanges();
|
||||||
|
};
|
||||||
110
src/list/library.cpp
Normal file
110
src/list/library.cpp
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
#include "library.hpp"
|
||||||
|
|
||||||
|
#include <QHeaderView>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonObject>
|
||||||
|
|
||||||
|
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<qint64>(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
|
||||||
40
src/list/library.hpp
Normal file
40
src/list/library.hpp
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "../backend/qobuzbackend.hpp"
|
||||||
|
|
||||||
|
#include <QTreeWidget>
|
||||||
|
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
}
|
||||||
115
src/list/tracks.cpp
Normal file
115
src/list/tracks.cpp
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
#include "tracks.hpp"
|
||||||
|
|
||||||
|
#include <QHeaderView>
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QAction>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonObject>
|
||||||
|
|
||||||
|
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<qint64>(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
|
||||||
40
src/list/tracks.hpp
Normal file
40
src/list/tracks.hpp
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "../model/tracklistmodel.hpp"
|
||||||
|
#include "../backend/qobuzbackend.hpp"
|
||||||
|
|
||||||
|
#include <QTreeView>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonObject>
|
||||||
|
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
}
|
||||||
39
src/main.cpp
Normal file
39
src/main.cpp
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
#include "mainwindow.hpp"
|
||||||
|
#include "backend/qobuzbackend.hpp"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QStyleFactory>
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
251
src/mainwindow.cpp
Normal file
251
src/mainwindow.cpp
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
#include "mainwindow.hpp"
|
||||||
|
|
||||||
|
#include "dialog/login.hpp"
|
||||||
|
#include "dialog/settings.hpp"
|
||||||
|
#include "util/settings.hpp"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QMenuBar>
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QAction>
|
||||||
|
#include <QStatusBar>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QMessageBox>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QJsonArray>
|
||||||
|
|
||||||
|
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("<h3>qobuz-qt</h3>"
|
||||||
|
"<p>A lightweight Qt client for the Qobuz streaming service.</p>"
|
||||||
|
"<p>Audio decoding powered by Symphonia (Rust).</p>"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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<QLineEdit *>(); // 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);
|
||||||
|
}
|
||||||
52
src/mainwindow.hpp
Normal file
52
src/mainwindow.hpp
Normal file
@@ -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 <QMainWindow>
|
||||||
|
#include <QDockWidget>
|
||||||
|
#include <QJsonObject>
|
||||||
|
|
||||||
|
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();
|
||||||
|
};
|
||||||
113
src/model/tracklistmodel.cpp
Normal file
113
src/model/tracklistmodel.cpp
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
#include "tracklistmodel.hpp"
|
||||||
|
|
||||||
|
#include <QJsonValue>
|
||||||
|
#include <QColor>
|
||||||
|
|
||||||
|
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<qint64>(t["id"].toDouble());
|
||||||
|
item.number = t["track_number"].toInt();
|
||||||
|
item.title = t["title"].toString();
|
||||||
|
item.duration = static_cast<qint64>(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<int>(secs / 60);
|
||||||
|
const int s = static_cast<int>(secs % 60);
|
||||||
|
return QStringLiteral("%1:%2").arg(m).arg(s, 2, 10, QLatin1Char('0'));
|
||||||
|
}
|
||||||
57
src/model/tracklistmodel.hpp
Normal file
57
src/model/tracklistmodel.hpp
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QAbstractTableModel>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
|
/// 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<TrackItem> m_tracks;
|
||||||
|
};
|
||||||
32
src/util/icon.hpp
Normal file
32
src/util/icon.hpp
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QIcon>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
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"); }
|
||||||
|
}
|
||||||
37
src/util/settings.hpp
Normal file
37
src/util/settings.hpp
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <QSettings>
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
58
src/view/maincontent.cpp
Normal file
58
src/view/maincontent.cpp
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
#include "maincontent.hpp"
|
||||||
|
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
#include <QJsonArray>
|
||||||
|
|
||||||
|
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("<h2>Welcome to Qobuz</h2>"
|
||||||
|
"<p>Select something from the library on the left to get started.</p>"),
|
||||||
|
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);
|
||||||
|
}
|
||||||
34
src/view/maincontent.hpp
Normal file
34
src/view/maincontent.hpp
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "../list/tracks.hpp"
|
||||||
|
#include "../backend/qobuzbackend.hpp"
|
||||||
|
|
||||||
|
#include <QWidget>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QStackedWidget>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonObject>
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
};
|
||||||
173
src/view/maintoolbar.cpp
Normal file
173
src/view/maintoolbar.cpp
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
#include "maintoolbar.hpp"
|
||||||
|
#include "../util/settings.hpp"
|
||||||
|
#include "../model/tracklistmodel.hpp"
|
||||||
|
|
||||||
|
#include <QWidget>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
|
||||||
|
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<int>(position * 1000 / duration)
|
||||||
|
: 0;
|
||||||
|
m_progress->blockSignals(true);
|
||||||
|
m_progress->setValue(sliderPos);
|
||||||
|
m_progress->blockSignals(false);
|
||||||
|
|
||||||
|
const QString pos = TrackListModel::formatDuration(static_cast<qint64>(position));
|
||||||
|
const QString dur = TrackListModel::formatDuration(static_cast<qint64>(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");
|
||||||
|
}
|
||||||
63
src/view/maintoolbar.hpp
Normal file
63
src/view/maintoolbar.hpp
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "../backend/qobuzbackend.hpp"
|
||||||
|
#include "../widget/volumebutton.hpp"
|
||||||
|
#include "../widget/clickableslider.hpp"
|
||||||
|
#include "../util/icon.hpp"
|
||||||
|
|
||||||
|
#include <QToolBar>
|
||||||
|
#include <QToolButton>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QAction>
|
||||||
|
#include <QJsonObject>
|
||||||
|
|
||||||
|
/// 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<qint64> m_queue;
|
||||||
|
int m_queueIdx = -1;
|
||||||
|
|
||||||
|
void addSpacerWidget();
|
||||||
|
};
|
||||||
139
src/view/sidepanel/view.cpp
Normal file
139
src/view/sidepanel/view.cpp
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
#include "view.hpp"
|
||||||
|
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QJsonArray>
|
||||||
|
|
||||||
|
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<qint64>(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<qint64>(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
|
||||||
55
src/view/sidepanel/view.hpp
Normal file
55
src/view/sidepanel/view.hpp
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "../../backend/qobuzbackend.hpp"
|
||||||
|
|
||||||
|
#include <QWidget>
|
||||||
|
#include <QDockWidget>
|
||||||
|
#include <QTabWidget>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QTreeWidget>
|
||||||
|
#include <QJsonObject>
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
32
src/widget/clickableslider.hpp
Normal file
32
src/widget/clickableslider.hpp
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QSlider>
|
||||||
|
#include <QMouseEvent>
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
75
src/widget/volumebutton.hpp
Normal file
75
src/widget/volumebutton.hpp
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "clickableslider.hpp"
|
||||||
|
#include "../util/icon.hpp"
|
||||||
|
|
||||||
|
#include <QToolButton>
|
||||||
|
#include <QWidgetAction>
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
/// 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());
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user