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:
vicrodh committed 2026-05-31 17:41:01 -06:00
1 parent bdb9cadad6
commit d1ebf8ea1c
7 files changed
+150 -15

No files matched your search

+42
View File
@@ -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> {
+18
View File
@@ -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.
{
+30
View File
@@ -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::*;
+4
View File
@@ -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) => {
+4
View File
@@ -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) => {