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` | |
+| `playbackDevices` | All audio playback devices. | `AudioDevice[]` | |
+
+#### 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))
}