feat(qbz-slint): port the local bit-perfect volume lock + force-100 (QConnect parity)

Port the rest of Tauri's volume logic to the Slint bar.

PlayerBar gains a derived vol-locked reproducing Tauri's handleVolumeChange
formula exactly: (alsaDirectHw && !isRemote) || volumeLocked. The local
ALSA-Direct (bit-perfect) lock is lifted while controlling a peer (the
!is-remote term) so the user can adjust the remote renderer; the sink-pushed
NowPlayingState.volume-locked keeps meaning only the peer-disallow half. All
five volume-interaction gates and the slider opacity now read vol-locked.

Force the local player to unity gain (set_volume(1.0)) in bit-perfect, mirroring
Tauri's playerSetVolume(100): when the backend is ALSA + the hw plugin AND a
peer is not the active renderer, set core volume to 1.0 and push
NowPlayingState.volume = 1.0. Applied (a) at the startup audio-settings load and
(b) on backend / alsa-plugin change. Gated on a new is_peer_active() so the
force-100 is skipped while controlling a peer. core().set_volume is the safe
seam; the protected device-init is untouched.

cc
This commit is contained in:
vicrodh committed 2026-06-03 10:47:38 -06:00
1 parent a1b054c387
commit 9bb5e6db26
4 files changed
+95 -7

No files matched your search

+7 -1
View File
@@ -128,8 +128,10 @@ async fn enter_shell(
{ {
let settings_ctx = settings_ctx.clone(); let settings_ctx = settings_ctx.clone();
let weak = weak.clone(); let weak = weak.clone();
let runtime = runtime.clone();
tokio::spawn(async move { tokio::spawn(async move {
match tokio::task::spawn_blocking(move || settings::load_snapshot(&settings_ctx)).await let ctx_for_load = settings_ctx.clone();
match tokio::task::spawn_blocking(move || settings::load_snapshot(&ctx_for_load)).await
{ {
Ok(snap) => { Ok(snap) => {
let _ = weak.upgrade_in_event_loop(move |w| { let _ = weak.upgrade_in_event_loop(move |w| {
@@ -138,6 +140,10 @@ async fn enter_shell(
} }
Err(e) => log::error!("[qbz-slint] settings load task failed: {e}"), Err(e) => log::error!("[qbz-slint] settings load task failed: {e}"),
} }
// Bit-perfect (ALSA + hw) forces local volume to 100% at startup so
// the bar reflects unity gain before Settings is ever opened. No-op
// otherwise (and while controlling a peer). Mirrors Tauri.
settings::apply_startup_bitperfect_volume(&settings_ctx, &runtime, &weak).await;
}); });
} }
+13
View File
@@ -793,6 +793,19 @@ impl SlintQconnectService {
Ok(Some((renderer, queue, session))) Ok(Some((renderer, queue, session)))
} }
/// True when a PEER renderer currently owns playback (controller mode);
/// false when not connected or when this device is the active renderer.
/// Used by the audio-settings force-100 path to SKIP forcing local volume
/// to 100% while controlling a peer (the bit-perfect lock is lifted then).
pub async fn is_peer_active(&self) -> bool {
let guard = self.inner.lock().await;
let Some(runtime) = guard.runtime.as_ref() else {
return false;
};
let state = runtime.sync_state.lock().await;
is_peer_renderer_active(&state.session)
}
/// Reduced peer-renderer playback snapshot for the now-playing seek bar. /// Reduced peer-renderer playback snapshot for the now-playing seek bar.
/// Returns `Some` ONLY when a PEER renderer is active (controller mode); the /// Returns `Some` ONLY when a PEER renderer is active (controller mode); the
/// poll loop then drives the bar from the peer and skips its local body. When /// poll loop then drives the bar from the peer and skips its local body. When
+62 -1
View File
@@ -28,7 +28,7 @@ use slint::{ComponentHandle, ModelRc, SharedString, VecModel};
use crate::adapter::SlintAdapter; use crate::adapter::SlintAdapter;
use crate::ui_prefs::{self, STREAMING_QUALITIES}; use crate::ui_prefs::{self, STREAMING_QUALITIES};
use crate::{AppWindow, SettingsState}; use crate::{AppWindow, NowPlayingState, SettingsState};
/// Maximum-sample-rate dropdown options. Index 0 is "No limit" (`None`). /// Maximum-sample-rate dropdown options. Index 0 is "No limit" (`None`).
/// Backs `device_max_sample_rate`. /// Backs `device_max_sample_rate`.
@@ -635,6 +635,61 @@ fn apply_audio(ctx: &SettingsCtx, runtime: &AppRuntime<SlintAdapter>, apply: App
log::info!("[qbz-slint] audio settings applied to player (reinit={reinit})"); log::info!("[qbz-slint] audio settings applied to player (reinit={reinit})");
} }
/// Force the local player volume to 100% in bit-perfect, mirroring Tauri's
/// `playerSetVolume(100)`. Bit-perfect (ALSA backend + `hw` plugin) requires the
/// software volume out of the path, so the player runs at unity gain and the
/// hardware/DAC controls level. Gated on NOT controlling a peer — while a peer
/// renderer owns playback the local lock is lifted and the user adjusts the
/// remote renderer, so forcing local 100 there would be wrong.
///
/// `core().set_volume` is the safe seam (it does NOT touch the protected
/// device-init). Pushes `NowPlayingState.volume = 1.0` so the bar reflects it.
async fn maybe_force_bitperfect_volume(
ctx: &SettingsCtx,
runtime: &AppRuntime<SlintAdapter>,
weak: &slint::Weak<AppWindow>,
) {
let audio = match with_audio(&ctx.audio, |s| s.get_settings()) {
Ok(s) => s,
Err(e) => {
log::error!("[qbz-slint] re-read audio for force-100 failed: {e}");
return;
}
};
let is_alsa_direct_hw = audio.backend_type.unwrap_or_default() == AudioBackendType::Alsa
&& audio.alsa_plugin.unwrap_or(AlsaPlugin::Hw) == AlsaPlugin::Hw;
if !is_alsa_direct_hw {
return;
}
// Skip while controlling a peer — the bit-perfect lock is lifted then.
let controlling_peer = match crate::qconnect_service::service() {
Some(svc) => svc.is_peer_active().await,
None => false,
};
if controlling_peer {
return;
}
if let Err(e) = runtime.core().set_volume(1.0) {
log::error!("[qbz-slint] force bit-perfect volume to 100 failed: {e}");
return;
}
log::info!("[qbz-slint] bit-perfect: forced local volume to 100%");
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<NowPlayingState>().set_volume(1.0);
});
}
/// Public entry for the startup audio-settings load: apply the bit-perfect
/// force-100 once the player is seeded, so the bar reflects unity gain before
/// the user ever opens Settings. No-op unless ALSA-direct-hw and not controlling.
pub async fn apply_startup_bitperfect_volume(
ctx: &SettingsCtx,
runtime: &AppRuntime<SlintAdapter>,
weak: &slint::Weak<AppWindow>,
) {
maybe_force_bitperfect_volume(ctx, runtime, weak).await;
}
/// Recompute the backend/ALSA conditional flags from the current audio /// Recompute the backend/ALSA conditional flags from the current audio
/// settings and push them onto `SettingsState`. Called after a backend or /// settings and push them onto `SettingsState`. Called after a backend or
/// ALSA-plugin change so the `.slint` panels re-gate the conditional rows. /// ALSA-plugin change so the `.slint` panels re-gate the conditional rows.
@@ -934,6 +989,9 @@ pub async fn handle_select(
// gapless) and the conditional flags all reach the UI in one // gapless) and the conditional flags all reach the UI in one
// consistent push. // consistent push.
apply_audio(&ctx, &runtime, Apply::Reinit); apply_audio(&ctx, &runtime, Apply::Reinit);
// Bit-perfect (ALSA + hw) forces local volume to 100%; lifted while
// controlling a peer. Mirrors Tauri's playerSetVolume(100).
maybe_force_bitperfect_volume(&ctx, &runtime, &weak).await;
rebuild_and_push(ctx, weak).await; rebuild_and_push(ctx, weak).await;
} }
"device" => { "device" => {
@@ -966,6 +1024,9 @@ pub async fn handle_select(
// ALSA plugin gates the Hardware Volume Control row. // ALSA plugin gates the Hardware Volume Control row.
push_conditional_flags(&ctx, &weak); push_conditional_flags(&ctx, &weak);
apply_audio(&ctx, &runtime, Apply::Reinit); apply_audio(&ctx, &runtime, Apply::Reinit);
// Switching to/from the `hw` plugin changes bit-perfect status;
// re-apply the force-100 (no-op when not ALSA-direct-hw).
maybe_force_bitperfect_volume(&ctx, &runtime, &weak).await;
} }
"retry-behavior" => { "retry-behavior" => {
let behavior = RETRY_BEHAVIORS.get(index).map(|(_, v)| *v).unwrap_or("ask"); let behavior = RETRY_BEHAVIORS.get(index).map(|(_, v)| *v).unwrap_or("ask");
+13 -5
View File
@@ -138,6 +138,14 @@ export component PlayerBar inherits Rectangle {
property <float> col-side: ShellState.npb-mode == 1 ? 0.324 : root.side-frac; property <float> col-side: ShellState.npb-mode == 1 ? 0.324 : root.side-frac;
property <float> col-centre: 1.0 - 2.0 * root.col-side; property <float> col-centre: 1.0 - 2.0 * root.col-side;
// Volume lock, mirroring Tauri's handleVolumeChange formula:
// (isAlsaDirectHw && !peerActive) || (peerActive && !remoteVolumeAllowed)
// The local ALSA-Direct lock (bit-perfect) is LIFTED when controlling a peer
// (the `!is-remote` term) so the user CAN adjust the remote renderer; the
// peer half is the sink-pushed NowPlayingState.volume-locked.
property <bool> vol-locked: (SettingsState.alsa-plugin-is-hw && !NowPlayingState.is-remote)
|| NowPlayingState.volume-locked;
VerticalLayout { VerticalLayout {
Rectangle { height: 6px; } Rectangle { height: 6px; }
@@ -746,7 +754,7 @@ export component PlayerBar inherits Rectangle {
tooltip: NowPlayingState.muted ? @tr("Unmute") : @tr("Mute"); tooltip: NowPlayingState.muted ? @tr("Unmute") : @tr("Mute");
active: NowPlayingState.muted; active: NowPlayingState.muted;
clicked => { clicked => {
if (!NowPlayingState.volume-locked) { if (!root.vol-locked) {
NowPlayingState.toggle-mute(); NowPlayingState.toggle-mute();
} }
} }
@@ -754,14 +762,14 @@ export component PlayerBar inherits Rectangle {
} }
VerticalLayout { VerticalLayout {
alignment: center; alignment: center;
opacity: NowPlayingState.volume-locked ? 0.4 : 1.0; opacity: root.vol-locked ? 0.4 : 1.0;
QbzSlider { QbzSlider {
width: 90px; width: 90px;
minimum: 0; minimum: 0;
maximum: 100; maximum: 100;
value: NowPlayingState.volume * 100; value: NowPlayingState.volume * 100;
changed(v) => { changed(v) => {
if (!NowPlayingState.volume-locked) { if (!root.vol-locked) {
NowPlayingState.set-volume(v / 100.0); NowPlayingState.set-volume(v / 100.0);
} }
} }
@@ -775,7 +783,7 @@ export component PlayerBar inherits Rectangle {
btn-width: 28px; btn-width: 28px;
tooltip: @tr("Volume down"); tooltip: @tr("Volume down");
clicked => { clicked => {
if (!NowPlayingState.volume-locked) { if (!root.vol-locked) {
NowPlayingState.set-volume(Math.max(0.0, NowPlayingState.volume - 0.05)); NowPlayingState.set-volume(Math.max(0.0, NowPlayingState.volume - 0.05));
} }
} }
@@ -789,7 +797,7 @@ export component PlayerBar inherits Rectangle {
btn-width: 28px; btn-width: 28px;
tooltip: @tr("Volume up"); tooltip: @tr("Volume up");
clicked => { clicked => {
if (!NowPlayingState.volume-locked) { if (!root.vol-locked) {
NowPlayingState.set-volume(Math.min(1.0, NowPlayingState.volume + 0.05)); NowPlayingState.set-volume(Math.min(1.0, NowPlayingState.volume + 0.05));
} }
} }