diff --git a/Cargo.lock b/Cargo.lock index 29afaccf..a6b5b0af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -6809,6 +6809,7 @@ dependencies = [ "tracing", "tracing-subscriber", "windows 0.58.0", + "windows-core 0.58.0", ] [[package]] diff --git a/README.md b/README.md index 8c439247..8abe1b12 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ In some cases, updating to the latest Microsoft Webview2 version is needed ([sta Through the `zebar` NPM package, Zebar exposes various system information via reactive "providers". Providers are a collection of functions and variables that can change over time. +- [audio](#Audio) - [battery](#Battery) - [cpu](#CPU) - [date](#Date) @@ -72,6 +73,30 @@ Through the `zebar` NPM package, Zebar exposes various system information via re - [network](#Network) - [weather](#Weather) +### Audio + +#### Config + +No config options. + +#### Outputs + +| Variable | Description | Return type | Supported OS | +| ------------------- | ----------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `defaultPlaybackDevice` | Default audio playback device. | `AudioDevice \| null` | microsoft icon | +| `playbackDevices` | All audio playback devices. | `AudioDevice[]` | microsoft icon | + +#### Return types + +#### `AudioDevice` + +| Variable | Description | Return type | +| ------------------ | ----------------------------- | ----------------------- | +| `deviceId` | Device ID. | `string` | +| `name` | Friendly display name of device. | `string` | +| `volume` | Volume as a % of maximum volume. Returned value is between `0` and `100`. | `number` | +| `isDefault` | `true` if the device is selected as the default playback device.| `boolean` | + ### Battery #### Config diff --git a/examples/boilerplate-solid-ts/src/index.tsx b/examples/boilerplate-solid-ts/src/index.tsx index 5448a27b..1a6ce453 100644 --- a/examples/boilerplate-solid-ts/src/index.tsx +++ b/examples/boilerplate-solid-ts/src/index.tsx @@ -5,6 +5,7 @@ import { createStore } from 'solid-js/store'; import * as zebar from 'zebar'; const providers = zebar.createProviderGroup({ + audio: { type: 'audio' }, cpu: { type: 'cpu' }, battery: { type: 'battery' }, memory: { type: 'memory' }, @@ -21,6 +22,10 @@ function App() { return (
+
+ {output.audio?.defaultPlaybackDevice?.name} - + {output.audio?.defaultPlaybackDevice?.volume} +
Media: {output.media?.session?.title} - {output.media?.session?.artist} diff --git a/packages/client-api/src/providers/audio/audio-provider-types.ts b/packages/client-api/src/providers/audio/audio-provider-types.ts new file mode 100644 index 00000000..dab6d41d --- /dev/null +++ b/packages/client-api/src/providers/audio/audio-provider-types.ts @@ -0,0 +1,19 @@ +import type { Provider } from '../create-base-provider'; + +export interface AudioProviderConfig { + type: 'audio'; +} + +export type AudioProvider = Provider; + +export interface AudioOutput { + defaultPlaybackDevice: AudioDevice; + playbackDevices: AudioDevice[]; +} + +export interface AudioDevice { + deviceId: string; + name: string; + volume: number; + isDefault: boolean; +} diff --git a/packages/client-api/src/providers/audio/create-audio-provider.ts b/packages/client-api/src/providers/audio/create-audio-provider.ts new file mode 100644 index 00000000..a96af3c7 --- /dev/null +++ b/packages/client-api/src/providers/audio/create-audio-provider.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +import { createBaseProvider } from '../create-base-provider'; +import { onProviderEmit } from '~/desktop'; +import type { + AudioOutput, + AudioProvider, + AudioProviderConfig, +} from './audio-provider-types'; + +const audioProviderConfigSchema = z.object({ + type: z.literal('audio'), +}); + +export function createAudioProvider( + config: AudioProviderConfig, +): AudioProvider { + const mergedConfig = audioProviderConfigSchema.parse(config); + + return createBaseProvider(mergedConfig, async queue => { + return onProviderEmit(mergedConfig, ({ result }) => { + if ('error' in result) { + queue.error(result.error); + } else { + queue.output(result.output); + } + }); + }); +} diff --git a/packages/client-api/src/providers/create-provider.ts b/packages/client-api/src/providers/create-provider.ts index 0439aebf..577fc0e6 100644 --- a/packages/client-api/src/providers/create-provider.ts +++ b/packages/client-api/src/providers/create-provider.ts @@ -1,3 +1,8 @@ +import { createAudioProvider } from './audio/create-audio-provider'; +import type { + AudioProviderConfig, + AudioProvider, +} from './audio/audio-provider-types'; import { createBatteryProvider } from './battery/create-battery-provider'; import type { BatteryProviderConfig, @@ -62,6 +67,7 @@ import type { } from './disk/disk-provider-types'; export interface ProviderConfigMap { + audio: AudioProviderConfig; battery: BatteryProviderConfig; cpu: CpuProviderConfig; date: DateProviderConfig; @@ -78,6 +84,7 @@ export interface ProviderConfigMap { } export interface ProviderMap { + audio: AudioProvider; battery: BatteryProvider; cpu: CpuProvider; date: DateProvider; @@ -114,6 +121,8 @@ export function createProvider( config: T, ): ProviderMap[T['type']] { switch (config.type) { + case 'audio': + return createAudioProvider(config) as any; case 'battery': return createBatteryProvider(config) as any; case 'cpu': diff --git a/packages/desktop/Cargo.toml b/packages/desktop/Cargo.toml index f0905d19..b545ec88 100644 --- a/packages/desktop/Cargo.toml +++ b/packages/desktop/Cargo.toml @@ -39,15 +39,22 @@ regex = "1" [target.'cfg(target_os = "windows")'.dependencies] komorebi-client = { git = "https://github.com/LGUG2Z/komorebi", tag = "v0.1.28" } +windows-core = "0.58" windows = { version = "0.58", features = [ "Foundation", + "implement", + "Win32_Devices_FunctionDiscovery", "Media_Control", "Win32_Globalization", + "Win32_Media", + "Win32_Media_Audio", + "Win32_Media_Audio_Endpoints", "Win32_System_Console", "Win32_System_SystemServices", - "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_Shell_PropertiesSystem", "Win32_UI_TextServices", + "Win32_UI_WindowsAndMessaging", "Win32_NetworkManagement_WiFi", ] } diff --git a/packages/desktop/src/providers/audio/audio_provider.rs b/packages/desktop/src/providers/audio/audio_provider.rs new file mode 100644 index 00000000..3c71e1c4 --- /dev/null +++ b/packages/desktop/src/providers/audio/audio_provider.rs @@ -0,0 +1,404 @@ +use std::{ + collections::HashMap, + ops::Mul, + sync::{Arc, Mutex, OnceLock}, + time::Duration, +}; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use tokio::{ + sync::mpsc::{self, Sender}, + task, + time::sleep, +}; +use tracing::debug; +use windows::Win32::{ + Devices::FunctionDiscovery::PKEY_Device_FriendlyName, + Media::Audio::{ + eMultimedia, eRender, EDataFlow, ERole, + Endpoints::{ + IAudioEndpointVolume, IAudioEndpointVolumeCallback, + IAudioEndpointVolumeCallback_Impl, + }, + IMMDevice, IMMDeviceEnumerator, IMMNotificationClient, + IMMNotificationClient_Impl, MMDeviceEnumerator, DEVICE_STATE, + DEVICE_STATE_ACTIVE, + }, + System::Com::{ + CoCreateInstance, CoInitializeEx, CLSCTX_ALL, COINIT_MULTITHREADED, + STGM_READ, + }, + UI::Shell::PropertiesSystem::{IPropertyStore, PROPERTYKEY}, +}; +use windows_core::PCWSTR; + +use crate::providers::{Provider, ProviderOutput, ProviderResult}; + +static PROVIDER_TX: OnceLock> = + OnceLock::new(); + +static AUDIO_STATE: OnceLock>> = OnceLock::new(); + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct AudioProviderConfig {} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AudioDevice { + pub name: String, + pub device_id: String, + pub volume: u32, + pub is_default: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AudioOutput { + pub playback_devices: Vec, + pub default_playback_device: Option, +} + +impl AudioOutput { + fn new() -> Self { + Self { + playback_devices: Vec::new(), + default_playback_device: None, + } + } +} + +#[derive(Clone)] +struct DeviceInfo { + name: String, + endpoint_volume: IAudioEndpointVolume, +} + +#[derive(Clone)] +#[windows::core::implement( + IMMNotificationClient, + IAudioEndpointVolumeCallback +)] +struct MediaDeviceEventHandler { + enumerator: IMMDeviceEnumerator, + device_state: Arc>>, + current_device: String, + update_tx: mpsc::Sender<(String, u32)>, +} + +impl MediaDeviceEventHandler { + fn new( + enumerator: IMMDeviceEnumerator, + update_tx: mpsc::Sender<(String, u32)>, + ) -> Self { + Self { + enumerator, + device_state: Arc::new(Mutex::new(HashMap::new())), + current_device: String::new(), + update_tx, + } + } + + fn get_device_name(device: &IMMDevice) -> windows::core::Result { + unsafe { + let store: IPropertyStore = device.OpenPropertyStore(STGM_READ)?; + let value = store.GetValue(&PKEY_Device_FriendlyName)?; + Ok(value.to_string()) + } + } + + fn get_device_info( + &self, + device: &IMMDevice, + ) -> windows::core::Result { + unsafe { + let device_id = device.GetId()?.to_string()?; + let mut device_state = self.device_state.lock().unwrap(); + + if !device_state.contains_key(&device_id) { + let new_device = self.register_new_device(device)?; + device_state.insert(device_id.clone(), new_device); + } + + let device_info = device_state.get(&device_id).unwrap(); + let is_default = self.is_default_device(&device_id)?; + let volume = device_info + .endpoint_volume + .GetMasterVolumeLevelScalar() + .unwrap_or(0.0) + .mul(100.0) + .round() as u32; + + Ok(AudioDevice { + device_id, + name: device_info.name.clone(), + volume, + is_default, + }) + } + } + + fn register_new_device( + &self, + device: &IMMDevice, + ) -> windows::core::Result { + unsafe { + let device_name = Self::get_device_name(device)?; + let endpoint_volume: IAudioEndpointVolume = + device.Activate(CLSCTX_ALL, None)?; + + let mut handler = self.clone(); + handler.current_device = device.GetId()?.to_string()?; + endpoint_volume.RegisterControlChangeNotify( + &IAudioEndpointVolumeCallback::from(handler), + )?; + + Ok(DeviceInfo { + name: device_name, + endpoint_volume, + }) + } + } + + fn is_default_device( + &self, + device_id: &str, + ) -> windows::core::Result { + unsafe { + let default = self + .enumerator + .GetDefaultAudioEndpoint(eRender, eMultimedia)?; + let default_id = default.GetId()?.to_string()?; + Ok(default_id == device_id) + } + } + + fn enumerate_devices(&self) -> windows::core::Result<()> { + unsafe { + let collection = self + .enumerator + .EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE)?; + + let mut devices = Vec::new(); + let mut default_device = None; + + // Get info for all active devices. + for i in 0..collection.GetCount()? { + if let Ok(device) = collection.Item(i) { + let info = self.get_device_info(&device)?; + if info.is_default { + default_device = Some(info.clone()); + } + devices.push(info); + } + } + + if let Some(state) = AUDIO_STATE.get() { + let mut audio_state = state.lock().unwrap(); + audio_state.playback_devices = devices; + audio_state.default_playback_device = default_device; + } + + AudioProvider::emit_volume(); + Ok(()) + } + } +} + +impl Drop for MediaDeviceEventHandler { + fn drop(&mut self) { + unsafe { + let device_state = self.device_state.lock().unwrap(); + for (_, device_info) in device_state.iter() { + let _ = device_info.endpoint_volume.UnregisterControlChangeNotify( + &IAudioEndpointVolumeCallback::from(self.clone()), + ); + } + } + } +} + +impl IAudioEndpointVolumeCallback_Impl for MediaDeviceEventHandler_Impl { + fn OnNotify( + &self, + data: *mut windows::Win32::Media::Audio::AUDIO_VOLUME_NOTIFICATION_DATA, + ) -> windows::core::Result<()> { + if let Some(data) = unsafe { data.as_ref() } { + let device_id = self.current_device.clone(); + let volume = data.fMasterVolume.mul(100.0).round() as u32; + + let _ = self.update_tx.blocking_send((device_id, volume)); + } + Ok(()) + } +} + +impl IMMNotificationClient_Impl for MediaDeviceEventHandler_Impl { + fn OnDefaultDeviceChanged( + &self, + data_flow: EDataFlow, + role: ERole, + _default_device_id: &PCWSTR, + ) -> windows::core::Result<()> { + if data_flow == eRender && role == eMultimedia { + self.enumerate_devices()?; + } + Ok(()) + } + + fn OnDeviceStateChanged( + &self, + _device_id: &PCWSTR, + _new_state: DEVICE_STATE, + ) -> windows::core::Result<()> { + self.enumerate_devices() + } + + fn OnDeviceAdded( + &self, + _device_id: &PCWSTR, + ) -> windows::core::Result<()> { + self.enumerate_devices() + } + + fn OnDeviceRemoved( + &self, + _device_id: &PCWSTR, + ) -> windows::core::Result<()> { + self.enumerate_devices() + } + + fn OnPropertyValueChanged( + &self, + _device_id: &PCWSTR, + _key: &PROPERTYKEY, + ) -> windows::core::Result<()> { + Ok(()) + } +} + +pub struct AudioProvider { + _config: AudioProviderConfig, +} + +impl AudioProvider { + pub fn new(config: AudioProviderConfig) -> Self { + Self { _config: config } + } + + fn emit_volume() { + if let Some(tx) = PROVIDER_TX.get() { + let output = AUDIO_STATE.get().unwrap().lock().unwrap().clone(); + let _ = tx.try_send(Ok(ProviderOutput::Audio(output)).into()); + } + } + + async fn handle_volume_updates(mut rx: mpsc::Receiver<(String, u32)>) { + const PROCESS_DELAY: Duration = Duration::from_millis(50); + let mut latest_updates = HashMap::new(); + + while let Some((device_id, volume)) = rx.recv().await { + latest_updates.insert(device_id, volume); + + // Collect any additional pending updates without waiting. + while let Ok((device_id, volume)) = rx.try_recv() { + latest_updates.insert(device_id, volume); + } + + // Brief delay to collect more potential updates. + sleep(PROCESS_DELAY).await; + + // Process all collected updates. + if let Some(state) = AUDIO_STATE.get() { + { + let mut output = state.lock().unwrap(); + for (device_id, volume) in latest_updates.drain() { + debug!( + "Updating volume to {} for device: {}", + volume, device_id + ); + + // Update device in the devices list. + if let Some(device) = output + .playback_devices + .iter_mut() + .find(|d| d.device_id == device_id) + { + device.volume = volume; + } + + // Update default device if it matches. + if let Some(default_device) = + &mut output.default_playback_device + { + if default_device.device_id == device_id { + default_device.volume = volume; + } + } + } + } + + Self::emit_volume(); + } + } + } + + fn create_audio_manager( + update_tx: mpsc::Sender<(String, u32)>, + ) -> anyhow::Result<()> { + unsafe { + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + let enumerator: IMMDeviceEnumerator = + CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)?; + let handler = + MediaDeviceEventHandler::new(enumerator.clone(), update_tx); + + handler.enumerate_devices()?; + + let device_notification_callback = + IMMNotificationClient::from(handler.clone()); + enumerator.RegisterEndpointNotificationCallback( + &device_notification_callback, + )?; + + loop { + std::thread::sleep(std::time::Duration::from_secs(1)); + } + } + } +} + +#[async_trait] +impl Provider for AudioProvider { + async fn run(&self, emit_result_tx: Sender) { + PROVIDER_TX + .set(emit_result_tx.clone()) + .expect("Error setting provider tx in audio provider"); + + AUDIO_STATE + .set(Arc::new(Mutex::new(AudioOutput::new()))) + .expect("Error setting initial audio state"); + + // Create a channel for volume updates. + let (update_tx, update_rx) = mpsc::channel(100); + + // Spawn both tasks. + let update_handler = + task::spawn(Self::handle_volume_updates(update_rx)); + + let manager = task::spawn_blocking(move || { + if let Err(err) = Self::create_audio_manager(update_tx) { + emit_result_tx + .blocking_send(Err(err).into()) + .expect("Error with media provider"); + } + }); + + // Wait for either task to complete (though they should run forever). + tokio::select! { + _ = manager => debug!("Audio manager stopped unexpectedly"), + _ = update_handler => debug!("Update handler stopped unexpectedly"), + } + } +} diff --git a/packages/desktop/src/providers/audio/mod.rs b/packages/desktop/src/providers/audio/mod.rs new file mode 100644 index 00000000..f59b57a3 --- /dev/null +++ b/packages/desktop/src/providers/audio/mod.rs @@ -0,0 +1,3 @@ +mod audio_provider; + +pub use audio_provider::*; diff --git a/packages/desktop/src/providers/mod.rs b/packages/desktop/src/providers/mod.rs index f827ee96..bad74540 100644 --- a/packages/desktop/src/providers/mod.rs +++ b/packages/desktop/src/providers/mod.rs @@ -1,3 +1,5 @@ +#[cfg(windows)] +mod audio; mod battery; mod cpu; mod disk; diff --git a/packages/desktop/src/providers/provider_config.rs b/packages/desktop/src/providers/provider_config.rs index 1b351b89..2a35a1f5 100644 --- a/packages/desktop/src/providers/provider_config.rs +++ b/packages/desktop/src/providers/provider_config.rs @@ -1,20 +1,22 @@ use serde::Deserialize; +#[cfg(windows)] +use super::{ + audio::AudioProviderConfig, keyboard::KeyboardProviderConfig, + komorebi::KomorebiProviderConfig, media::MediaProviderConfig, +}; use super::{ battery::BatteryProviderConfig, cpu::CpuProviderConfig, disk::DiskProviderConfig, host::HostProviderConfig, ip::IpProviderConfig, memory::MemoryProviderConfig, network::NetworkProviderConfig, weather::WeatherProviderConfig, }; -#[cfg(windows)] -use super::{ - keyboard::KeyboardProviderConfig, komorebi::KomorebiProviderConfig, - media::MediaProviderConfig, -}; #[derive(Deserialize, Debug)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ProviderConfig { + #[cfg(windows)] + Audio(AudioProviderConfig), Battery(BatteryProviderConfig), Cpu(CpuProviderConfig), Host(HostProviderConfig), diff --git a/packages/desktop/src/providers/provider_output.rs b/packages/desktop/src/providers/provider_output.rs index 59ce73fd..c922fdd8 100644 --- a/packages/desktop/src/providers/provider_output.rs +++ b/packages/desktop/src/providers/provider_output.rs @@ -1,18 +1,21 @@ use serde::Serialize; +#[cfg(windows)] +use super::{ + audio::AudioOutput, keyboard::KeyboardOutput, komorebi::KomorebiOutput, + media::MediaOutput, +}; use super::{ battery::BatteryOutput, cpu::CpuOutput, disk::DiskOutput, host::HostOutput, ip::IpOutput, memory::MemoryOutput, network::NetworkOutput, weather::WeatherOutput, }; -#[cfg(windows)] -use super::{ - keyboard::KeyboardOutput, komorebi::KomorebiOutput, media::MediaOutput, -}; #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(untagged)] pub enum ProviderOutput { + #[cfg(windows)] + Audio(AudioOutput), Battery(BatteryOutput), Cpu(CpuOutput), Host(HostOutput), diff --git a/packages/desktop/src/providers/provider_ref.rs b/packages/desktop/src/providers/provider_ref.rs index 78c576c4..b8b94cae 100644 --- a/packages/desktop/src/providers/provider_ref.rs +++ b/packages/desktop/src/providers/provider_ref.rs @@ -10,17 +10,17 @@ use tokio::{ }; use tracing::{info, warn}; +#[cfg(windows)] +use super::{ + audio::AudioProvider, keyboard::KeyboardProvider, + komorebi::KomorebiProvider, media::MediaProvider, +}; use super::{ battery::BatteryProvider, cpu::CpuProvider, disk::DiskProvider, host::HostProvider, ip::IpProvider, memory::MemoryProvider, network::NetworkProvider, weather::WeatherProvider, Provider, ProviderConfig, ProviderOutput, SharedProviderState, }; -#[cfg(windows)] -use super::{ - keyboard::KeyboardProvider, komorebi::KomorebiProvider, - media::MediaProvider, -}; /// Reference to an active provider. pub struct ProviderRef { @@ -164,6 +164,10 @@ impl ProviderRef { shared_state: SharedProviderState, ) -> anyhow::Result> { let provider: Box = match config { + #[cfg(windows)] + ProviderConfig::Audio(config) => { + Box::new(AudioProvider::new(config)) + } ProviderConfig::Battery(config) => { Box::new(BatteryProvider::new(config)) }