feat(qbzd): audio settings API + remote settings routing (Phase 5e)
Daemon: 5 new audio settings endpoints: - GET /api/audio/settings (full settings) - PATCH /api/audio/settings (partial update) - GET /api/audio/backends (available backends) - GET /api/audio/devices?backend= (devices for backend) - GET /api/audio/hardware-status (current hw state) Frontend: commandRouter gains audio settings commands that route to daemon API when in remote mode, local invoke when local. Total daemon endpoints: 32. cc
This commit is contained in:
4 files changed
+201
-2
No files matched your search
@@ -0,0 +1,127 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use axum::Json;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::daemon::DaemonCore;
|
||||||
|
|
||||||
|
pub async fn get_settings(_daemon: Arc<DaemonCore>) -> Json<serde_json::Value> {
|
||||||
|
let result = qbz_audio::settings::AudioSettingsStore::new()
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.get_settings().ok());
|
||||||
|
match result {
|
||||||
|
Some(settings) => Json(serde_json::to_value(settings).unwrap_or_default()),
|
||||||
|
None => Json(serde_json::json!({"error": "no audio settings"})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_backends(_daemon: Arc<DaemonCore>) -> Json<serde_json::Value> {
|
||||||
|
use qbz_audio::{AudioBackendType, BackendManager};
|
||||||
|
|
||||||
|
let backends: Vec<serde_json::Value> = BackendManager::available_backends()
|
||||||
|
.into_iter()
|
||||||
|
.map(|bt| {
|
||||||
|
let backend = BackendManager::create_backend(bt);
|
||||||
|
let (available, description) = match backend {
|
||||||
|
Ok(b) => (b.is_available(), b.description().to_string()),
|
||||||
|
Err(_) => (false, "Not available".to_string()),
|
||||||
|
};
|
||||||
|
let name = match bt {
|
||||||
|
AudioBackendType::PipeWire => "PipeWire",
|
||||||
|
AudioBackendType::Alsa => "ALSA Direct",
|
||||||
|
AudioBackendType::Pulse => "PulseAudio",
|
||||||
|
AudioBackendType::SystemDefault => "System Audio",
|
||||||
|
};
|
||||||
|
serde_json::json!({
|
||||||
|
"backend_type": bt,
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"is_available": available,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Json(serde_json::json!(backends))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct DevicesQuery {
|
||||||
|
pub backend: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_devices(
|
||||||
|
_daemon: Arc<DaemonCore>,
|
||||||
|
axum::extract::Query(q): axum::extract::Query<DevicesQuery>,
|
||||||
|
) -> Result<Json<serde_json::Value>, String> {
|
||||||
|
use qbz_audio::{AudioBackendType, BackendManager};
|
||||||
|
|
||||||
|
let bt = match q.backend.as_deref() {
|
||||||
|
Some("pipewire") | Some("PipeWire") => AudioBackendType::PipeWire,
|
||||||
|
Some("alsa") | Some("Alsa") => AudioBackendType::Alsa,
|
||||||
|
Some("pulse") | Some("PulseAudio") => AudioBackendType::Pulse,
|
||||||
|
_ => AudioBackendType::SystemDefault,
|
||||||
|
};
|
||||||
|
|
||||||
|
let backend = BackendManager::create_backend(bt).map_err(|e| e.to_string())?;
|
||||||
|
let devices = backend.enumerate_devices().map_err(|e| e.to_string())?;
|
||||||
|
Ok(Json(serde_json::to_value(devices).unwrap_or_default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_hardware_status(daemon: Arc<DaemonCore>) -> Json<serde_json::Value> {
|
||||||
|
let player = daemon.core.player();
|
||||||
|
let state = &player.state;
|
||||||
|
let sr = state.get_sample_rate();
|
||||||
|
let bd = state.get_bit_depth();
|
||||||
|
let playing = state.is_playing();
|
||||||
|
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"is_active": playing && sr > 0,
|
||||||
|
"hardware_sample_rate": if sr > 0 { Some(sr) } else { None::<u32> },
|
||||||
|
"hardware_format": if sr > 0 && bd > 0 {
|
||||||
|
Some(format!("{}-bit / {:.1}kHz", bd, sr as f64 / 1000.0))
|
||||||
|
} else {
|
||||||
|
None::<String>
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateSettingsRequest {
|
||||||
|
pub backend_type: Option<String>,
|
||||||
|
pub output_device: Option<String>,
|
||||||
|
pub exclusive_mode: Option<bool>,
|
||||||
|
pub dac_passthrough: Option<bool>,
|
||||||
|
pub volume_normalization: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_settings(
|
||||||
|
_daemon: Arc<DaemonCore>,
|
||||||
|
Json(req): Json<UpdateSettingsRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, String> {
|
||||||
|
let store = qbz_audio::settings::AudioSettingsStore::new()
|
||||||
|
.map_err(|e| format!("Failed to open settings: {}", e))?;
|
||||||
|
|
||||||
|
if let Some(ref bt) = req.backend_type {
|
||||||
|
let backend = match bt.as_str() {
|
||||||
|
"PipeWire" | "pipewire" => Some(qbz_audio::AudioBackendType::PipeWire),
|
||||||
|
"Alsa" | "alsa" => Some(qbz_audio::AudioBackendType::Alsa),
|
||||||
|
"Pulse" | "pulse" => Some(qbz_audio::AudioBackendType::Pulse),
|
||||||
|
_ => Some(qbz_audio::AudioBackendType::SystemDefault),
|
||||||
|
};
|
||||||
|
store.set_backend_type(backend).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
if let Some(ref device) = req.output_device {
|
||||||
|
let d = if device.is_empty() { None } else { Some(device.as_str()) };
|
||||||
|
store.set_output_device(d).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
if let Some(exc) = req.exclusive_mode {
|
||||||
|
store.set_exclusive_mode(exc).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
if let Some(dac) = req.dac_passthrough {
|
||||||
|
store.set_dac_passthrough(dac).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
// volume_normalization: no individual setter yet, skip for now
|
||||||
|
|
||||||
|
// Return updated settings
|
||||||
|
let updated = store.get_settings().unwrap_or_default();
|
||||||
|
Ok(Json(serde_json::to_value(updated).unwrap_or_default()))
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod audio;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod playback;
|
pub mod playback;
|
||||||
|
|||||||
@@ -333,8 +333,8 @@ macro_rules! with_daemon {
|
|||||||
|
|
||||||
/// Build the Axum HTTP router.
|
/// Build the Axum HTTP router.
|
||||||
fn build_router(daemon: Arc<DaemonCore>) -> axum::Router {
|
fn build_router(daemon: Arc<DaemonCore>) -> axum::Router {
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post, patch};
|
||||||
use crate::api::{playback, queue, search, catalog};
|
use crate::api::{audio, playback, queue, search, catalog};
|
||||||
|
|
||||||
axum::Router::new()
|
axum::Router::new()
|
||||||
// System
|
// System
|
||||||
@@ -369,6 +369,12 @@ fn build_router(daemon: Arc<DaemonCore>) -> axum::Router {
|
|||||||
.route("/api/artists/{id}", get(with_daemon!(daemon, catalog::get_artist, path)))
|
.route("/api/artists/{id}", get(with_daemon!(daemon, catalog::get_artist, path)))
|
||||||
.route("/api/tracks/{id}", get(with_daemon!(daemon, catalog::get_track, path)))
|
.route("/api/tracks/{id}", get(with_daemon!(daemon, catalog::get_track, path)))
|
||||||
.route("/api/tracks/batch", get(with_daemon!(daemon, catalog::get_tracks_batch, query)))
|
.route("/api/tracks/batch", get(with_daemon!(daemon, catalog::get_tracks_batch, query)))
|
||||||
|
// Audio settings
|
||||||
|
.route("/api/audio/settings", get(with_daemon!(daemon, audio::get_settings)))
|
||||||
|
.route("/api/audio/settings", patch(with_daemon!(daemon, audio::update_settings, json)))
|
||||||
|
.route("/api/audio/backends", get(with_daemon!(daemon, audio::get_backends)))
|
||||||
|
.route("/api/audio/devices", get(with_daemon!(daemon, audio::get_devices, query)))
|
||||||
|
.route("/api/audio/hardware-status", get(with_daemon!(daemon, audio::get_hardware_status)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ping_handler() -> &'static str {
|
async fn ping_handler() -> &'static str {
|
||||||
|
|||||||
@@ -13,6 +13,19 @@ import { invoke } from '@tauri-apps/api/core';
|
|||||||
import { getTarget } from '$lib/stores/playbackTargetStore';
|
import { getTarget } from '$lib/stores/playbackTargetStore';
|
||||||
import { remotePost, remoteGet } from '$lib/services/remoteApi';
|
import { remotePost, remoteGet } from '$lib/services/remoteApi';
|
||||||
|
|
||||||
|
// ==================== Remote-Aware Helpers ====================
|
||||||
|
|
||||||
|
/** Check if currently targeting a remote daemon */
|
||||||
|
export function isRemote(): boolean {
|
||||||
|
return getTarget().type === 'qbzd';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch from remote API (GET), or null if local */
|
||||||
|
export async function remoteGetOrNull<T>(path: string): Promise<T | null> {
|
||||||
|
if (!isRemote()) return null;
|
||||||
|
return remoteGet<T>(path);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Playback ====================
|
// ==================== Playback ====================
|
||||||
|
|
||||||
export async function cmdPause(): Promise<void> {
|
export async function cmdPause(): Promise<void> {
|
||||||
@@ -168,3 +181,55 @@ export async function cmdSetRepeatMode(mode: string): Promise<void> {
|
|||||||
await invoke('v2_set_repeat_mode', { mode: v2Mode });
|
await invoke('v2_set_repeat_mode', { mode: v2Mode });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Audio Settings ====================
|
||||||
|
|
||||||
|
export async function cmdGetAudioSettings(): Promise<unknown> {
|
||||||
|
const target = getTarget();
|
||||||
|
if (target.type === 'qbzd') {
|
||||||
|
return remoteGet('/api/audio/settings');
|
||||||
|
} else {
|
||||||
|
return invoke('v2_get_audio_settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdGetAvailableBackends(): Promise<unknown> {
|
||||||
|
const target = getTarget();
|
||||||
|
if (target.type === 'qbzd') {
|
||||||
|
return remoteGet('/api/audio/backends');
|
||||||
|
} else {
|
||||||
|
return invoke('v2_get_available_backends');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdGetDevicesForBackend(backendType: string): Promise<unknown> {
|
||||||
|
const target = getTarget();
|
||||||
|
if (target.type === 'qbzd') {
|
||||||
|
return remoteGet(`/api/audio/devices?backend=${encodeURIComponent(backendType)}`);
|
||||||
|
} else {
|
||||||
|
return invoke('v2_get_devices_for_backend', { backendType });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdGetHardwareAudioStatus(): Promise<unknown> {
|
||||||
|
const target = getTarget();
|
||||||
|
if (target.type === 'qbzd') {
|
||||||
|
return remoteGet('/api/audio/hardware-status');
|
||||||
|
} else {
|
||||||
|
return invoke('v2_get_hardware_audio_status');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdUpdateAudioSettings(patch: Record<string, unknown>): Promise<unknown> {
|
||||||
|
const target = getTarget();
|
||||||
|
if (target.type === 'qbzd') {
|
||||||
|
return remotePost('/api/audio/settings', patch);
|
||||||
|
} else {
|
||||||
|
// Local: individual invoke calls per field
|
||||||
|
if ('backend_type' in patch) await invoke('v2_set_audio_backend', { backendType: patch.backend_type });
|
||||||
|
if ('output_device' in patch) await invoke('v2_set_audio_device', { deviceName: patch.output_device });
|
||||||
|
if ('exclusive_mode' in patch) await invoke('v2_set_exclusive_mode', { enabled: patch.exclusive_mode });
|
||||||
|
if ('dac_passthrough' in patch) await invoke('v2_set_dac_passthrough', { enabled: patch.dac_passthrough });
|
||||||
|
return invoke('v2_get_audio_settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user