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:
4 files changed
+95
-7
No files matched your search
@@ -128,8 +128,10 @@ async fn enter_shell(
|
||||
{
|
||||
let settings_ctx = settings_ctx.clone();
|
||||
let weak = weak.clone();
|
||||
let runtime = runtime.clone();
|
||||
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) => {
|
||||
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}"),
|
||||
}
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -793,6 +793,19 @@ impl SlintQconnectService {
|
||||
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.
|
||||
/// 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
|
||||
|
||||
@@ -28,7 +28,7 @@ use slint::{ComponentHandle, ModelRc, SharedString, VecModel};
|
||||
|
||||
use crate::adapter::SlintAdapter;
|
||||
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`).
|
||||
/// 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})");
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// settings and push them onto `SettingsState`. Called after a backend or
|
||||
/// 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
|
||||
// consistent push.
|
||||
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;
|
||||
}
|
||||
"device" => {
|
||||
@@ -966,6 +1024,9 @@ pub async fn handle_select(
|
||||
// ALSA plugin gates the Hardware Volume Control row.
|
||||
push_conditional_flags(&ctx, &weak);
|
||||
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" => {
|
||||
let behavior = RETRY_BEHAVIORS.get(index).map(|(_, v)| *v).unwrap_or("ask");
|
||||
|
||||
@@ -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-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 {
|
||||
Rectangle { height: 6px; }
|
||||
|
||||
@@ -746,7 +754,7 @@ export component PlayerBar inherits Rectangle {
|
||||
tooltip: NowPlayingState.muted ? @tr("Unmute") : @tr("Mute");
|
||||
active: NowPlayingState.muted;
|
||||
clicked => {
|
||||
if (!NowPlayingState.volume-locked) {
|
||||
if (!root.vol-locked) {
|
||||
NowPlayingState.toggle-mute();
|
||||
}
|
||||
}
|
||||
@@ -754,14 +762,14 @@ export component PlayerBar inherits Rectangle {
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
opacity: NowPlayingState.volume-locked ? 0.4 : 1.0;
|
||||
opacity: root.vol-locked ? 0.4 : 1.0;
|
||||
QbzSlider {
|
||||
width: 90px;
|
||||
minimum: 0;
|
||||
maximum: 100;
|
||||
value: NowPlayingState.volume * 100;
|
||||
changed(v) => {
|
||||
if (!NowPlayingState.volume-locked) {
|
||||
if (!root.vol-locked) {
|
||||
NowPlayingState.set-volume(v / 100.0);
|
||||
}
|
||||
}
|
||||
@@ -775,7 +783,7 @@ export component PlayerBar inherits Rectangle {
|
||||
btn-width: 28px;
|
||||
tooltip: @tr("Volume down");
|
||||
clicked => {
|
||||
if (!NowPlayingState.volume-locked) {
|
||||
if (!root.vol-locked) {
|
||||
NowPlayingState.set-volume(Math.max(0.0, NowPlayingState.volume - 0.05));
|
||||
}
|
||||
}
|
||||
@@ -789,7 +797,7 @@ export component PlayerBar inherits Rectangle {
|
||||
btn-width: 28px;
|
||||
tooltip: @tr("Volume up");
|
||||
clicked => {
|
||||
if (!NowPlayingState.volume-locked) {
|
||||
if (!root.vol-locked) {
|
||||
NowPlayingState.set-volume(Math.min(1.0, NowPlayingState.volume + 0.05));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user