diff --git a/crates/winisland-platform-windows/src/capture.rs b/crates/winisland-platform-windows/src/capture.rs index 9af05dc..a0d0be0 100644 --- a/crates/winisland-platform-windows/src/capture.rs +++ b/crates/winisland-platform-windows/src/capture.rs @@ -23,8 +23,8 @@ use windows::{ Direct3D11::{ D3D11_BOX, D3D11_CPU_ACCESS_READ, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_MAP_FLAG_DO_NOT_WAIT, D3D11_MAP_READ, D3D11_MAPPED_SUBRESOURCE, - D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, D3D11_USAGE_STAGING, D3D11CreateDevice, - ID3D11Device, ID3D11Device1, ID3D11Device5, ID3D11DeviceContext, + D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, D3D11_USAGE_STAGING, + D3D11CreateDevice, ID3D11Device, ID3D11Device1, ID3D11Device5, ID3D11DeviceContext, ID3D11DeviceContext4, ID3D11Fence, ID3D11Multithread, ID3D11Texture2D, }, Dxgi::{ @@ -49,6 +49,7 @@ use windows::{ use winisland_platform::{BackdropCaptureFrame, BackdropCaptureRequest, BackdropShare, Rect}; const FRAME_BUFFERS: i32 = 2; +const PIXEL_RATE_LIMIT: u32 = 60; const PROBE_STEP: usize = 2; const PROBE_ALIGN: u32 = 64; const BORDER_UNKNOWN: u8 = 0; @@ -58,9 +59,15 @@ const BORDER_REQUIRED: u8 = 3; static BORDER_ACCESS: AtomicU8 = AtomicU8::new(BORDER_UNKNOWN); +#[derive(Clone)] +enum Frame { + Window(Direct3D11CaptureFrame), + Pixels(Arc), +} + #[derive(Default)] struct Latest { - frame: Option, + frame: Option, sequence: u64, } @@ -105,6 +112,9 @@ struct Import { pub(crate) struct BackdropCapture { hwnd: HWND, magnifier_mode: Cell, + pixel_mode: Cell, + pixels: RefCell>, + upload: RefCell>, device: ID3D11Device5, context: ID3D11DeviceContext4, shared: Arc, @@ -133,14 +143,18 @@ impl BackdropCapture { let winrt_device: IDirect3DDevice = unsafe { CreateDirect3D11DeviceFromDXGIDevice(&dxgi) } .and_then(|device| device.cast()) .map_err(|error| format!("WinRT Direct3D device creation failed: {error}"))?; - let magnifier_mode = visible_to_capture; - if !magnifier_mode { - exclude_from_capture(hwnd)?; + let mut magnifier_mode = visible_to_capture; + if !magnifier_mode && let Err(error) = exclude_from_capture(hwnd) { + log::warn!("{error}; the liquid glass backdrop uses the magnifier instead"); + magnifier_mode = true; } let d3d = AgileContext(device.clone().into(), context.clone().into()); let capture = Self { hwnd, magnifier_mode: Cell::new(magnifier_mode), + pixel_mode: Cell::new(false), + pixels: RefCell::new(None), + upload: RefCell::new(None), device, context, shared: Arc::new(Shared { @@ -172,20 +186,30 @@ impl BackdropCapture { log::warn!("Backdrop capture restart failed: {error}"); return None; } - let origin = self.session.borrow().as_ref()?.origin.get(); - let interest = Rect { - left: request.interest.left - origin.0, - top: request.interest.top - origin.1, - right: request.interest.right - origin.0, - bottom: request.interest.bottom - origin.1, - }; - *self.shared.interest.lock() = interest; + let session_origin = self + .session + .borrow() + .as_ref() + .map(|session| session.origin.get()); + if let Some(origin) = session_origin { + *self.shared.interest.lock() = relative(request.interest, origin); + } + if let Some(magnifier) = self.pixels.borrow().as_ref() + && let Some(source) = monitor_clamped(self.hwnd, request.interest) + { + magnifier.set_source(source); + } self.shared.pending.store(false, Ordering::Release); self.ensure_import(request.share)?; let (frame, sequence) = { let latest = self.shared.latest.lock(); (latest.frame.clone()?, latest.sequence) }; + let origin = match &frame { + Frame::Pixels(pixels) => pixels.origin, + Frame::Window(_) => session_origin?, + }; + let interest = relative(request.interest, origin); let key = ( request.share.generation, sequence, @@ -207,14 +231,22 @@ impl BackdropCapture { fn copy( &self, - frame: &Direct3D11CaptureFrame, + frame: &Frame, request: BackdropCaptureRequest, key: (u64, u64, i32, i32), ) -> Option<()> { let import = self.import.borrow(); let import = import.as_ref()?; - let source = frame_texture(frame).ok()?; - let content = frame.ContentSize().ok()?; + let (source, content) = match frame { + Frame::Window(frame) => (frame_texture(frame).ok()?, frame.ContentSize().ok()?), + Frame::Pixels(pixels) => ( + self.upload_pixels(pixels)?, + SizeInt32 { + Width: pixels.width as i32, + Height: pixels.height as i32, + }, + ), + }; let (local_x, local_y) = (key.2, key.3); let left = local_x.max(0); let top = local_y.max(0); @@ -267,6 +299,59 @@ impl BackdropCapture { Some(()) } + fn upload_pixels(&self, pixels: &magnifier::PixelFrame) -> Option { + if self + .upload + .borrow() + .as_ref() + .is_none_or(|(_, width, height)| *width < pixels.width || *height < pixels.height) + { + let desc = D3D11_TEXTURE2D_DESC { + Width: pixels.width.max(1), + Height: pixels.height.max(1), + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_B8G8R8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { + Count: 1, + Quality: 0, + }, + Usage: D3D11_USAGE_DEFAULT, + ..Default::default() + }; + let mut texture = None; + // SAFETY: The descriptor is initialized and the output Option is owned by this call. + if let Err(error) = + unsafe { self.device.CreateTexture2D(&desc, None, Some(&mut texture)) } + { + log::warn!("Backdrop upload texture failed: {error}"); + return None; + } + *self.upload.borrow_mut() = Some((texture?, desc.Width, desc.Height)); + } + let texture = self.upload.borrow().as_ref()?.0.clone(); + let region = D3D11_BOX { + left: 0, + top: 0, + front: 0, + right: pixels.width, + bottom: pixels.height, + back: 1, + }; + // SAFETY: The box fits the texture and `pixels` holds `height` rows of `width` BGRA texels. + unsafe { + self.context.UpdateSubresource( + &texture, + 0, + Some(®ion), + pixels.pixels.as_ptr().cast(), + pixels.width * 4, + 0, + ); + } + Some(texture) + } + fn ensure_import(&self, share: BackdropShare) -> Option<()> { if self .import @@ -317,6 +402,9 @@ impl BackdropCapture { } fn ensure_session(&self, rate_hz: u32) -> Result<(), String> { + if self.pixel_mode.get() { + return self.ensure_pixel_session(rate_hz); + } if self.magnifier_mode.get() { match self.ensure_magnifier_session(rate_hz) { Ok(()) => return Ok(()), @@ -383,12 +471,21 @@ impl BackdropCapture { self.session.borrow_mut().take(); *self.shared.latest.lock() = Latest::default(); self.copied.set(None); - let magnifier = magnifier::Magnifier::start(rect, rate_hz)?; + let magnifier = magnifier::Magnifier::start(rect, rate_hz, None)?; let interop = windows::core::factory::() .map_err(|error| format!("Capture interop is unavailable: {error}"))?; // SAFETY: The magnifier host is a live window owned by this process. - let item: GraphicsCaptureItem = unsafe { interop.CreateForWindow(magnifier.host()) } - .map_err(|error| format!("Magnifier capture item failed: {error}"))?; + let item: GraphicsCaptureItem = match unsafe { interop.CreateForWindow(magnifier.host()) } { + Ok(item) => item, + Err(error) => { + log::info!( + "Magnifier window capture is unavailable ({error}); reading magnifier frames directly" + ); + drop(magnifier); + self.pixel_mode.set(true); + return self.ensure_pixel_session(rate_hz); + } + }; // SAFETY: hwnd is the live overlay window; the call only reads its placement. let monitor = unsafe { MonitorFromWindow(self.hwnd, MONITOR_DEFAULTTONEAREST) }; let session = Session::open( @@ -405,6 +502,28 @@ impl BackdropCapture { } } +impl BackdropCapture { + fn ensure_pixel_session(&self, rate_hz: u32) -> Result<(), String> { + let rate_hz = rate_hz.min(PIXEL_RATE_LIMIT); + if let Some(magnifier) = self.pixels.borrow().as_ref() { + magnifier.set_rate(rate_hz); + return Ok(()); + } + self.session.borrow_mut().take(); + *self.shared.latest.lock() = Latest::default(); + self.copied.set(None); + let weak = Arc::downgrade(&self.shared); + let sink: magnifier::FrameSink = Box::new(move |frame| { + if let Some(shared) = weak.upgrade() { + shared.receive_pixels(frame); + } + }); + let magnifier = magnifier::Magnifier::start(window_rect(self.hwnd)?, rate_hz, Some(sink))?; + *self.pixels.borrow_mut() = Some(magnifier); + Ok(()) + } +} + impl Session { fn start(monitor: HMONITOR, shared: &Arc, rate_hz: u32) -> Result { let mut info = MONITORINFO { @@ -499,6 +618,25 @@ impl Drop for Session { } impl Shared { + fn receive_pixels(&self, frame: magnifier::PixelFrame) { + { + let mut latest = self.latest.lock(); + if let Some(Frame::Pixels(previous)) = latest.frame.as_ref() + && previous.origin == frame.origin + && previous.width == frame.width + && previous.height == frame.height + && previous.pixels == frame.pixels + { + return; + } + latest.frame = Some(Frame::Pixels(Arc::new(frame))); + latest.sequence += 1; + } + if !self.pending.swap(true, Ordering::AcqRel) { + crate::window::wake(); + } + } + fn probe_changed(&self, frame: &Direct3D11CaptureFrame, interest: Rect) -> bool { let content = *self.content.lock(); let rect = Rect { @@ -658,7 +796,7 @@ impl Shared { || (unknown && self.probe_changed(&frame, interest)); { let mut latest = self.latest.lock(); - latest.frame = Some(frame); + latest.frame = Some(Frame::Window(frame)); if relevant { latest.sequence += 1; } @@ -672,6 +810,7 @@ impl Shared { impl Drop for BackdropCapture { fn drop(&mut self) { self.session.borrow_mut().take(); + self.pixels.borrow_mut().take(); *self.shared.latest.lock() = Latest::default(); self.import.borrow_mut().take(); // SAFETY: hwnd may already be destroyed; the call fails harmlessly in that case. @@ -839,6 +978,38 @@ fn exclude_from_capture(hwnd: HWND) -> Result<(), String> { .map_err(|error| format!("Capture exclusion failed: {error}")) } +/// Clips a screen rectangle to the overlay's monitor; the magnifier image callback is only fed +/// on-screen sources. +fn monitor_clamped(hwnd: HWND, rect: Rect) -> Option { + // SAFETY: hwnd is the live overlay window; the call only reads its placement. + let monitor = unsafe { MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) }; + let mut info = MONITORINFO { + cbSize: size_of::() as u32, + ..Default::default() + }; + // SAFETY: monitor came from MonitorFromWindow and info is a correctly sized output. + if !unsafe { GetMonitorInfoW(monitor, &mut info) }.as_bool() { + return None; + } + let bounds = info.rcMonitor; + let clamped = RECT { + left: rect.left.max(bounds.left), + top: rect.top.max(bounds.top), + right: rect.right.min(bounds.right), + bottom: rect.bottom.min(bounds.bottom), + }; + (clamped.right > clamped.left && clamped.bottom > clamped.top).then_some(clamped) +} + +fn relative(rect: Rect, origin: (i32, i32)) -> Rect { + Rect { + left: rect.left - origin.0, + top: rect.top - origin.1, + right: rect.right - origin.0, + bottom: rect.bottom - origin.1, + } +} + fn window_rect(hwnd: HWND) -> Result { let mut rect = RECT::default(); // SAFETY: hwnd is the live overlay window and rect is a valid output. diff --git a/crates/winisland-platform-windows/src/capture/magnifier.rs b/crates/winisland-platform-windows/src/capture/magnifier.rs index c062900..fd105f9 100644 --- a/crates/winisland-platform-windows/src/capture/magnifier.rs +++ b/crates/winisland-platform-windows/src/capture/magnifier.rs @@ -1,3 +1,5 @@ +use std::cell::RefCell; +use std::ffi::c_void; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::thread::JoinHandle; @@ -6,26 +8,42 @@ use std::time::{Duration, Instant}; use parking_lot::Mutex; use windows::Win32::Foundation::{COLORREF, HWND, LPARAM, LRESULT, RECT, WPARAM}; use windows::Win32::Graphics::Dwm::{DWMWA_CLOAK, DwmSetWindowAttribute}; -use windows::Win32::Graphics::Gdi::InvalidateRect; +use windows::Win32::Graphics::Gdi::{HRGN, InvalidateRect}; use windows::Win32::System::LibraryLoader::GetModuleHandleW; use windows::Win32::System::Threading::GetCurrentProcessId; use windows::Win32::UI::HiDpi::{ DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, SetThreadDpiAwarenessContext, }; use windows::Win32::UI::Magnification::{ - MAGTRANSFORM, MW_FILTERMODE_EXCLUDE, MagInitialize, MagSetWindowFilterList, MagSetWindowSource, - MagSetWindowTransform, MagUninitialize, WC_MAGNIFIERW, + MAGIMAGEHEADER, MAGTRANSFORM, MW_FILTERMODE_EXCLUDE, MagInitialize, MagSetImageScalingCallback, + MagSetWindowFilterList, MagSetWindowSource, MagSetWindowTransform, MagUninitialize, + WC_MAGNIFIERW, }; use windows::Win32::UI::WindowsAndMessaging::{ CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, EnumWindows, - GetWindowThreadProcessId, LWA_ALPHA, MSG, PM_REMOVE, PeekMessageW, RegisterClassW, - SWP_NOACTIVATE, SWP_NOZORDER, SetLayeredWindowAttributes, SetWindowPos, TranslateMessage, - WINDOW_EX_STYLE, WNDCLASSW, WS_CHILD, WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, - WS_POPUP, WS_VISIBLE, + GetWindowThreadProcessId, IsWindowVisible, LWA_ALPHA, MSG, PM_REMOVE, PeekMessageW, + RegisterClassW, SWP_NOACTIVATE, SWP_NOZORDER, SetLayeredWindowAttributes, SetWindowPos, + TranslateMessage, WINDOW_EX_STYLE, WNDCLASSW, WS_CHILD, WS_EX_LAYERED, WS_EX_NOACTIVATE, + WS_EX_TOOLWINDOW, WS_POPUP, WS_VISIBLE, }; use windows::core::{BOOL, PCWSTR, w}; -const FILTER_REFRESH: Duration = Duration::from_secs(1); +const FILTER_REFRESH: Duration = Duration::from_millis(500); + +/// One frame from the magnifier image callback as tightly packed BGRA rows (the header reports +/// RGBA, but the bytes are BGRA). +pub(super) struct PixelFrame { + pub(super) pixels: Vec, + pub(super) width: u32, + pub(super) height: u32, + pub(super) origin: (i32, i32), +} + +pub(super) type FrameSink = Box; + +thread_local! { + static SINK: RefCell> = const { RefCell::new(None) }; +} struct State { source: Mutex, @@ -33,8 +51,10 @@ struct State { stop: AtomicBool, } -/// Cloaked magnifier window that DWM fills with the desktop beneath `source`, without this -/// process's windows; Windows.Graphics.Capture reads it as an ordinary window. +/// Magnifier that renders the desktop beneath `source` without this process's windows. With a +/// sink, frames arrive through the image callback from a hidden host, which works where +/// Windows.Graphics.Capture cannot read cloaked windows (Windows 10); otherwise the host is +/// cloaked and captured as an ordinary window. pub(super) struct Magnifier { state: Arc, host: HWND, @@ -42,7 +62,11 @@ pub(super) struct Magnifier { } impl Magnifier { - pub(super) fn start(source: RECT, rate_hz: u32) -> Result { + pub(super) fn start( + source: RECT, + rate_hz: u32, + sink: Option, + ) -> Result { let state = Arc::new(State { source: Mutex::new(source), rate_hz: AtomicU32::new(rate_hz.max(1)), @@ -52,7 +76,7 @@ impl Magnifier { let thread_state = state.clone(); let thread = std::thread::Builder::new() .name("liquid-magnifier".into()) - .spawn(move || run(thread_state, sender)) + .spawn(move || run(thread_state, sender, sink)) .map_err(|error| format!("Magnifier thread failed: {error}"))?; match receiver.recv() { Ok(Ok(host)) => Ok(Self { @@ -98,7 +122,59 @@ extern "system" fn host_proc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARA unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) } } -fn run(state: Arc, ready: std::sync::mpsc::Sender>) { +unsafe extern "system" fn on_frame( + _hwnd: HWND, + source: *mut c_void, + header: MAGIMAGEHEADER, + _destination: *mut c_void, + _destination_header: MAGIMAGEHEADER, + _unclipped: RECT, + _clipped: RECT, + _dirty: HRGN, +) -> BOOL { + let row = header.width as usize * 4; + let stride = header.stride as usize; + let height = header.height as usize; + let end = (header.offset as usize) + .saturating_add(stride.saturating_mul(height.saturating_sub(1))) + .saturating_add(row); + if source.is_null() || row == 0 || height == 0 || stride < row || end > header.cbSize { + return BOOL(1); + } + SINK.with(|slot| { + let slot = slot.borrow(); + let Some((sink, origin)) = slot.as_ref() else { + return; + }; + let mut pixels = Vec::with_capacity(row * header.height as usize); + for y in 0..header.height as usize { + // SAFETY: The magnifier guarantees `height` rows of `stride` bytes after `offset`, and + // each copied row stays within its stride. + pixels.extend_from_slice(unsafe { + std::slice::from_raw_parts( + source + .cast::() + .add(header.offset as usize + y * header.stride as usize), + row, + ) + }); + } + sink(PixelFrame { + pixels, + width: header.width, + height: header.height, + origin: *origin, + }); + }); + BOOL(1) +} + +fn run( + state: Arc, + ready: std::sync::mpsc::Sender>, + sink: Option, +) { + let callback = sink.is_some(); // SAFETY: Every handle below is created, used, and destroyed on this thread; the magnifier // runtime is initialized before the control is created and released after it is destroyed. unsafe { @@ -121,7 +197,11 @@ fn run(state: Arc, ready: std::sync::mpsc::Sender>) WS_EX_LAYERED | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, class, w!("WinIsland Liquid Backdrop"), - WS_POPUP | WS_VISIBLE, + if callback { + WS_POPUP + } else { + WS_POPUP | WS_VISIBLE + }, source.left, source.top, (source.right - source.left).max(1), @@ -133,13 +213,15 @@ fn run(state: Arc, ready: std::sync::mpsc::Sender>) ) .map_err(|error| format!("Magnifier host failed: {error}"))?; let _ = SetLayeredWindowAttributes(host, COLORREF(0), 255, LWA_ALPHA); - let cloak = 1_i32; - let _ = DwmSetWindowAttribute( - host, - DWMWA_CLOAK, - (&cloak as *const i32).cast(), - size_of::() as u32, - ); + if !callback { + let cloak = 1_i32; + let _ = DwmSetWindowAttribute( + host, + DWMWA_CLOAK, + (&cloak as *const i32).cast(), + size_of::() as u32, + ); + } let magnifier = CreateWindowExW( WINDOW_EX_STYLE(0), PCWSTR(WC_MAGNIFIERW.as_ptr()), @@ -173,13 +255,19 @@ fn run(state: Arc, ready: std::sync::mpsc::Sender>) return; } }; + if let Some(sink) = sink { + let source = *state.source.lock(); + SINK.with(|slot| *slot.borrow_mut() = Some((sink, (source.left, source.top)))); + let _ = MagSetImageScalingCallback(magnifier, Some(on_frame)); + } let mut excluded = own_windows(); - apply_filter(magnifier, &mut excluded); + let mut filter_ok = apply_filter(magnifier, &excluded, true); let _ = ready.send(Ok(host.0 as isize)); let mut size = (0, 0); let mut next = Instant::now(); let mut filter_at = Instant::now(); + let mut refilter = false; let mut message = MSG::default(); while !state.stop.load(Ordering::Acquire) { let source = *state.source.lock(); @@ -204,15 +292,20 @@ fn run(state: Arc, ready: std::sync::mpsc::Sender>) size.1, SWP_NOZORDER | SWP_NOACTIVATE, ); + refilter = true; } - if filter_at.elapsed() >= FILTER_REFRESH { + let windows = own_windows(); + if refilter || windows != excluded || filter_at.elapsed() >= FILTER_REFRESH { + refilter = false; filter_at = Instant::now(); - let mut windows = own_windows(); - if windows != excluded { - excluded = windows.clone(); - apply_filter(magnifier, &mut windows); - } + excluded = windows; + filter_ok = apply_filter(magnifier, &excluded, filter_ok); } + SINK.with(|slot| { + if let Some((_, origin)) = slot.borrow_mut().as_mut() { + *origin = (source.left, source.top); + } + }); let _ = MagSetWindowSource(magnifier, source); let _ = InvalidateRect(Some(magnifier), None, false); while PeekMessageW(&mut message, None, 0, 0, PM_REMOVE).as_bool() { @@ -230,33 +323,43 @@ fn run(state: Arc, ready: std::sync::mpsc::Sender>) } let _ = DestroyWindow(host); let _ = MagUninitialize(); + SINK.with(|slot| slot.borrow_mut().take()); } } -fn apply_filter(magnifier: HWND, windows: &mut [HWND]) { - if windows.is_empty() { - return; +/// Re-sends the exclusion list. DWM can drop a window's exclusion when it is hidden and shown +/// again or when the magnifier resizes, so this runs on every change and on a timer. Returns +/// whether the call succeeded and warns once when it starts failing. +fn apply_filter(magnifier: HWND, windows: &[(HWND, bool)], was_ok: bool) -> bool { + let mut handles: Vec = windows.iter().map(|(hwnd, _)| *hwnd).collect(); + if handles.is_empty() { + return was_ok; } // SAFETY: The magnifier control is live on this thread and the list outlives the call. - unsafe { - let _ = MagSetWindowFilterList( + let ok = unsafe { + MagSetWindowFilterList( magnifier, MW_FILTERMODE_EXCLUDE, - windows.len() as i32, - windows.as_mut_ptr(), - ); + handles.len() as i32, + handles.as_mut_ptr(), + ) + } + .as_bool(); + if was_ok && !ok { + log::warn!("Magnifier could not exclude WinIsland windows"); } + ok } -fn own_windows() -> Vec { +fn own_windows() -> Vec<(HWND, bool)> { extern "system" fn visit(hwnd: HWND, data: LPARAM) -> BOOL { // SAFETY: data points to the Vec owned by own_windows for the duration of EnumWindows. unsafe { - let windows = &mut *(data.0 as *mut Vec); + let windows = &mut *(data.0 as *mut Vec<(HWND, bool)>); let mut process = 0; GetWindowThreadProcessId(hwnd, Some(&mut process)); if process == GetCurrentProcessId() { - windows.push(hwnd); + windows.push((hwnd, IsWindowVisible(hwnd).as_bool())); } } BOOL(1) @@ -264,7 +367,10 @@ fn own_windows() -> Vec { let mut windows = Vec::new(); // SAFETY: The callback only appends to the local Vec passed through LPARAM. unsafe { - let _ = EnumWindows(Some(visit), LPARAM(&mut windows as *mut Vec as isize)); + let _ = EnumWindows( + Some(visit), + LPARAM(&mut windows as *mut Vec<(HWND, bool)> as isize), + ); } windows } diff --git a/src/core/audio.rs b/src/core/audio.rs index cf35f7b..b336e69 100644 --- a/src/core/audio.rs +++ b/src/core/audio.rs @@ -436,6 +436,7 @@ impl AudioProcessor { spawn_worker("audio-fallback-capture", move || { let host = cpal::default_host(); let mut current_device_name = None; + let mut announced_device_name = None; let mut current_stream: Option = None; let mut current_stream_failed: Option> = None; let mut stream_running = false; @@ -490,15 +491,18 @@ impl AudioProcessor { continue; } next_device_refresh = now + DEVICE_REFRESH_INTERVAL; - let default_device = host.default_output_device(); - let default_device_name = default_device - .as_ref() + let default_device_name = host + .default_output_device() .and_then(|d| d.description().map(|desc| desc.name().to_string()).ok()); if default_device_name != current_device_name { - log::info!( - "Audio capture: default device changed from {current_device_name:?} to {default_device_name:?}" - ); + let announce = default_device_name != announced_device_name; + if announce { + log::info!( + "Audio capture: default device changed from {announced_device_name:?} to {default_device_name:?}" + ); + announced_device_name = default_device_name.clone(); + } // Releasing old stream and session current_stream = None; @@ -506,16 +510,21 @@ impl AudioProcessor { stream_running = false; current_device_name = None; - if let Some(device) = default_device { + if let Some(device) = default_device_name + .as_deref() + .and_then(|name| output_device_named(&host, name)) + { let device_name = default_device_name .clone() .unwrap_or_else(|| "unknown".to_string()); let config = match device.default_output_config() { Ok(c) => c, Err(e) => { - log::warn!( - "Audio capture: no default output config for '{device_name}': {e:?}" - ); + if announce { + log::warn!( + "Audio capture: no default output config for '{device_name}': {e:?}" + ); + } std::thread::sleep(AUDIO_RETRY_INTERVAL); continue; } @@ -694,6 +703,16 @@ where ) } +/// Resolves the default output to its concrete endpoint, which activates synchronously; the +/// virtual default device activates asynchronously and can keep failing after a switch. +fn output_device_named(host: &cpal::Host, name: &str) -> Option { + host.output_devices().ok()?.find(|device| { + device + .description() + .is_ok_and(|description| description.name() == name) + }) +} + fn spawn_worker(name: &str, work: impl FnOnce() + Send + 'static) { if let Err(error) = std::thread::Builder::new() .name(name.to_string())