feat(audio): release/refresh output-device button (#263)
After bit-perfect ALSA Direct grabs a DAC exclusively (hw: + D-Bus reservation), the device stays sequestered and is invisible to PipeWire, so switching back to the PipeWire backend can't see it without restarting the app. Tauri had a refresh affordance for hot-plugged DACs; this is the refresh plus an explicit release, in one button. Backend: Player::release_device() / AudioCommand::ReleaseDevice tears the stream down immediately (cancelling the deferred drop), freeing the hw: grab + its reservation, then un-suspends the parked sink and resets any forced graph clock (both self-gating). It does NOT reopen, unlike reinit_device which would re-grab the device. UI: a refresh-cw icon button beside the Output device select, propagated through the settings callback chain to handle_release_device, which releases, waits for PipeWire to reclaim, then re-enumerates so a freed or hot-plugged DAC reappears without an app restart. cc
This commit is contained in:
7 files changed
+150
-15
No files matched your search
@@ -88,6 +88,11 @@ enum AudioCommand {
|
||||
Seek(u64),
|
||||
/// Reinitialize audio device (releases and re-acquires)
|
||||
ReinitDevice { device_name: Option<String> },
|
||||
/// Release the output device WITHOUT reopening it: drops the active
|
||||
/// stream (freeing an exclusive ALSA `hw:` grab + its D-Bus reservation)
|
||||
/// and un-suspends / un-forces anything QBZ parked, so PipeWire can
|
||||
/// reclaim a device QBZ was holding. User-triggered from settings.
|
||||
ReleaseDevice,
|
||||
/// Append next track to current engine for gapless playback (Rodio only)
|
||||
PlayNext {
|
||||
data: Vec<u8>,
|
||||
@@ -2902,6 +2907,30 @@ impl Player {
|
||||
// Keep current_audio_data and current_streaming_source
|
||||
// intact so Resume can recreate the engine and seek.
|
||||
}
|
||||
AudioCommand::ReleaseDevice => {
|
||||
log::info!("Audio thread: releasing output device (user-requested)");
|
||||
// Cancel any deferred drop and tear the stream down NOW so
|
||||
// the device is freed immediately (no warm-stream lingering).
|
||||
*pause_suspend_deadline = None;
|
||||
if let Some(engine) = current_engine.take() {
|
||||
engine.stop();
|
||||
}
|
||||
drop(stream_opt.take());
|
||||
// Undo anything QBZ parked so PipeWire / WirePlumber can
|
||||
// reclaim the device (e.g. a DAC left invisible to other
|
||||
// apps after bit-perfect ALSA Direct held it exclusively).
|
||||
// Both calls are self-gating no-ops if QBZ didn't set them.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
qbz_audio::alsa_backend::resume_suspended_sink();
|
||||
qbz_audio::pipewire_backend::PipeWireBackend::reset_pipewire_clock();
|
||||
}
|
||||
thread_state.pause_playback_timer();
|
||||
thread_state.is_playing.store(false, Ordering::SeqCst);
|
||||
// Keep current_audio_data / current_streaming_source intact
|
||||
// so a later Play / Resume reopens and continues.
|
||||
log::info!("Audio thread: output device released");
|
||||
}
|
||||
AudioCommand::PlayNext {
|
||||
data,
|
||||
track_id,
|
||||
@@ -4096,6 +4125,19 @@ impl Player {
|
||||
.map_err(|e| format!("Failed to send reinit command: {}", e))
|
||||
}
|
||||
|
||||
/// Release the output device without reopening it. Drops the active
|
||||
/// stream — freeing an exclusive ALSA `hw:` grab and its D-Bus
|
||||
/// reservation — and un-suspends / un-forces anything QBZ parked, so
|
||||
/// PipeWire/WirePlumber can reclaim a device QBZ was holding (e.g. a DAC
|
||||
/// left invisible to other apps after bit-perfect ALSA Direct). Pair
|
||||
/// with a device re-enumeration in the UI to surface a freed or
|
||||
/// hot-plugged DAC without restarting the app.
|
||||
pub fn release_device(&self) -> Result<(), String> {
|
||||
self.tx
|
||||
.send(AudioCommand::ReleaseDevice)
|
||||
.map_err(|e| format!("Failed to send release command: {}", e))
|
||||
}
|
||||
|
||||
/// Reload audio settings from fresh config (e.g., after database update)
|
||||
/// Call this before reinit_device() to ensure Player uses latest settings
|
||||
pub fn reload_settings(&self, settings: AudioSettings) -> Result<(), String> {
|
||||
|
||||
@@ -2198,6 +2198,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
|
||||
// Settings — the output-device refresh/release button: free a device QBZ
|
||||
// holds exclusively (ALSA Direct) and re-enumerate, so a freed or
|
||||
// hot-plugged DAC reappears without an app restart.
|
||||
{
|
||||
let runtime = app_runtime.clone();
|
||||
let settings_ctx = settings_ctx.clone();
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window.on_settings_release_device(move || {
|
||||
let runtime = runtime.clone();
|
||||
let settings_ctx = settings_ctx.clone();
|
||||
let weak = weak.clone();
|
||||
handle.spawn(async move {
|
||||
settings::handle_release_device(settings_ctx, runtime, weak).await;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Context-menu / overlay media actions — route play / queue actions
|
||||
// into the playback controller; favorite / download stay logged.
|
||||
{
|
||||
|
||||
@@ -937,6 +937,36 @@ pub async fn handle_reset(
|
||||
apply_audio(&ctx, &runtime, Apply::Reinit);
|
||||
}
|
||||
|
||||
/// Release the held output device, then re-enumerate. Frees a device QBZ is
|
||||
/// holding exclusively (ALSA Direct, which leaves the DAC invisible to
|
||||
/// PipeWire/other apps) and rebuilds the snapshot so a freed or hot-plugged
|
||||
/// DAC shows up in the list without restarting the app — the Tauri
|
||||
/// "refresh" affordance plus an explicit release, in one action.
|
||||
pub async fn handle_release_device(
|
||||
ctx: Arc<SettingsCtx>,
|
||||
runtime: Arc<AppRuntime<SlintAdapter>>,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
) {
|
||||
if let Err(e) = runtime.core().player().release_device() {
|
||||
log::error!("[qbz-slint] player.release_device failed: {e}");
|
||||
}
|
||||
// Let PipeWire/WirePlumber reclaim the just-freed device before we list.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
let snap = {
|
||||
let ctx = ctx.clone();
|
||||
match tokio::task::spawn_blocking(move || load_snapshot(&ctx)).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::error!("[qbz-slint] release-device rebuild task failed: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
apply_snapshot(&w, snap);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -53,6 +53,7 @@ export component AppWindow inherits Window {
|
||||
callback settings-select(string, int);
|
||||
callback settings-slider(string, int);
|
||||
callback settings-reset();
|
||||
callback settings-release-device();
|
||||
callback logout();
|
||||
callback close-app();
|
||||
|
||||
@@ -101,6 +102,9 @@ export component AppWindow inherits Window {
|
||||
settings-reset => {
|
||||
root.settings-reset();
|
||||
}
|
||||
settings-release-device => {
|
||||
root.settings-release-device();
|
||||
}
|
||||
logout => {
|
||||
root.logout();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Radius } from "../foundation/radius.slint";
|
||||
import { SettingsState } from "../state.slint";
|
||||
import { QbzToggle } from "../primitives/QbzToggle.slint";
|
||||
import { QbzSelect } from "../primitives/QbzSelect.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { SettingRow } from "SettingRow.slint";
|
||||
|
||||
component GroupHeader inherits Text {
|
||||
@@ -28,6 +29,10 @@ export component AudioSettings inherits VerticalLayout {
|
||||
callback settings-select(string, int);
|
||||
// Emitted by the Reset button.
|
||||
callback settings-reset();
|
||||
// Emitted by the refresh/release button next to the output device:
|
||||
// frees a device QBZ is holding (ALSA exclusive) and re-enumerates so a
|
||||
// freed or hot-plugged DAC appears without restarting the app.
|
||||
callback settings-release-device();
|
||||
|
||||
spacing: 4px;
|
||||
|
||||
@@ -93,21 +98,49 @@ export component AudioSettings inherits VerticalLayout {
|
||||
SettingRow {
|
||||
label: @tr("Output device");
|
||||
description: @tr("The DAC or sound device that receives audio.");
|
||||
QbzSelect {
|
||||
menu-width: 300px;
|
||||
// Device labels run long; open the list wider so the full
|
||||
// name (card + ALSA PCM id) is readable.
|
||||
popup-width: 480px;
|
||||
// Output lists can be long (multiple DACs / HDMI sinks); a
|
||||
// filter box + capped, scrolling list keeps it usable.
|
||||
searchable: true;
|
||||
options: SettingsState.devices;
|
||||
badges: SettingsState.device-bp;
|
||||
groups: SettingsState.device-groups;
|
||||
current-index: SettingsState.device-index;
|
||||
selected(i) => {
|
||||
SettingsState.device-index = i;
|
||||
root.settings-select("device", i);
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
alignment: center;
|
||||
// Refresh / release: frees a device QBZ is holding exclusively
|
||||
// (ALSA Direct) and re-enumerates, so a DAC that's invisible to
|
||||
// PipeWire — or one hot-plugged while QBZ ran — shows up without
|
||||
// an app restart.
|
||||
release-btn := Rectangle {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: Radius.sm;
|
||||
border-width: 1px;
|
||||
border-color: Theme.border-subtle;
|
||||
background: release-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
||||
QbzIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
source: @image-url("../assets/icons/refresh-cw.svg");
|
||||
tint: release-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
release-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.settings-release-device();
|
||||
}
|
||||
}
|
||||
}
|
||||
QbzSelect {
|
||||
menu-width: 300px;
|
||||
// Device labels run long; open the list wider so the full
|
||||
// name (card + ALSA PCM id) is readable.
|
||||
popup-width: 480px;
|
||||
// Output lists can be long (multiple DACs / HDMI sinks); a
|
||||
// filter box + capped, scrolling list keeps it usable.
|
||||
searchable: true;
|
||||
options: SettingsState.devices;
|
||||
badges: SettingsState.device-bp;
|
||||
groups: SettingsState.device-groups;
|
||||
current-index: SettingsState.device-index;
|
||||
selected(i) => {
|
||||
SettingsState.device-index = i;
|
||||
root.settings-select("device", i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ export component SettingsView inherits Rectangle {
|
||||
callback settings-select(string, int);
|
||||
callback settings-slider(string, int);
|
||||
callback settings-reset();
|
||||
callback settings-release-device();
|
||||
|
||||
background: Theme.surface-main;
|
||||
|
||||
@@ -178,6 +179,9 @@ export component SettingsView inherits Rectangle {
|
||||
settings-reset => {
|
||||
root.settings-reset();
|
||||
}
|
||||
settings-release-device => {
|
||||
root.settings-release-device();
|
||||
}
|
||||
}
|
||||
if !SettingsState.loading && SettingsState.section == 1: PlaybackSettings {
|
||||
settings-bool(k, v) => {
|
||||
|
||||
@@ -54,6 +54,7 @@ export component AppShell inherits Rectangle {
|
||||
callback settings-select(string, int);
|
||||
callback settings-slider(string, int);
|
||||
callback settings-reset();
|
||||
callback settings-release-device();
|
||||
callback logout();
|
||||
callback close-app();
|
||||
|
||||
@@ -255,6 +256,9 @@ export component AppShell inherits Rectangle {
|
||||
settings-reset => {
|
||||
root.settings-reset();
|
||||
}
|
||||
settings-release-device => {
|
||||
root.settings-release-device();
|
||||
}
|
||||
}
|
||||
if NavState.view == ContentView.search: SearchResultsView {
|
||||
open-album(id) => {
|
||||
|
||||
Reference in new issue
Block a user