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 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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user