things
This commit is contained in:
Generated
+113
@@ -2335,6 +2335,69 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-complex",
|
||||||
|
"num-integer",
|
||||||
|
"num-iter",
|
||||||
|
"num-rational",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-bigint"
|
||||||
|
version = "0.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||||
|
dependencies = [
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-complex"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-integer"
|
||||||
|
version = "0.1.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-iter"
|
||||||
|
version = "0.1.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
|
||||||
|
dependencies = [
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-rational"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -2765,6 +2828,48 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pipewire-native"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "db4da5bc428cfd0d93a1f2c7f996bb2a7ce62151f773fea900a636e5c3aeaa67"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"libc",
|
||||||
|
"parking_lot",
|
||||||
|
"paste",
|
||||||
|
"pipewire-native-macros",
|
||||||
|
"pipewire-native-spa",
|
||||||
|
"pkg-config",
|
||||||
|
"tinyjson",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pipewire-native-macros"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9a3b131b8b239675111a3d501d877fec5c8fda9239fef4132a764c103dc0cf7"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pipewire-native-spa"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6f7901f35d9a00e9ced18907b4a4c79aa7293028274311fad001b048f644dd50"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"libloading",
|
||||||
|
"num",
|
||||||
|
"pipewire-native-macros",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
@@ -3396,6 +3501,8 @@ dependencies = [
|
|||||||
"common",
|
"common",
|
||||||
"hyprland",
|
"hyprland",
|
||||||
"iced",
|
"iced",
|
||||||
|
"pipewire-native",
|
||||||
|
"pipewire-native-spa",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -3822,6 +3929,12 @@ dependencies = [
|
|||||||
"strict-num",
|
"strict-num",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinyjson"
|
||||||
|
version = "2.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ab95735ea2c8fd51154d01e39cf13912a78071c2d89abc49a7ef102a7dd725a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub struct BarbarConfig {
|
|||||||
#[derive(serde::Deserialize, Serialize, Clone, Copy, Debug)]
|
#[derive(serde::Deserialize, Serialize, Clone, Copy, Debug)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum Modules {
|
pub enum Modules {
|
||||||
|
Audio,
|
||||||
Clock,
|
Clock,
|
||||||
Weather,
|
Weather,
|
||||||
Workspaces,
|
Workspaces,
|
||||||
@@ -24,6 +25,7 @@ pub enum Modules {
|
|||||||
impl Modules {
|
impl Modules {
|
||||||
pub fn to_string(&self) -> &str {
|
pub fn to_string(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
|
Modules::Audio => "audio",
|
||||||
Modules::Clock => "clock",
|
Modules::Clock => "clock",
|
||||||
Modules::Weather => "weather",
|
Modules::Weather => "weather",
|
||||||
Modules::Workspaces => "workspaces",
|
Modules::Workspaces => "workspaces",
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
//! Keeps the bar surface's geometry and renderer flags in one place, so `core`
|
//! Keeps the bar surface's geometry and renderer flags in one place, so `core`
|
||||||
//! only reads config and hands the result straight to the daemon.
|
//! only reads config and hands the result straight to the daemon.
|
||||||
|
|
||||||
use iced::Pixels;
|
use iced::{Font, Pixels};
|
||||||
use iced_layershell::reexport::{Anchor, KeyboardInteractivity};
|
use iced_layershell::reexport::{Anchor, KeyboardInteractivity};
|
||||||
use iced_layershell::settings::{LayerShellSettings, Settings, StartMode};
|
use iced_layershell::settings::{LayerShellSettings, Settings, StartMode};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Bar text font, matching quickshell's `CaskaydiaCove NFM`.
|
||||||
|
const DEFAULT_FONT: &str = "CaskaydiaCove NFM";
|
||||||
/// Bar height in logical px when `[display].height` is omitted.
|
/// Bar height in logical px when `[display].height` is omitted.
|
||||||
const DEFAULT_HEIGHT: u32 = 36;
|
const DEFAULT_HEIGHT: u32 = 36;
|
||||||
/// Default text size in logical px.
|
/// Default text size in logical px.
|
||||||
@@ -75,6 +77,7 @@ impl Display {
|
|||||||
pub fn settings(&self) -> Settings {
|
pub fn settings(&self) -> Settings {
|
||||||
Settings {
|
Settings {
|
||||||
layer_settings: self.layer_settings(),
|
layer_settings: self.layer_settings(),
|
||||||
|
default_font: Font::with_name(DEFAULT_FONT),
|
||||||
default_text_size: Pixels(self.default_text_size),
|
default_text_size: Pixels(self.default_text_size),
|
||||||
antialiasing: self.antialiasing,
|
antialiasing: self.antialiasing,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
mod datetime;
|
mod datetime;
|
||||||
mod hyprland;
|
mod hyprland;
|
||||||
|
mod pipewire;
|
||||||
mod weather;
|
mod weather;
|
||||||
|
|
||||||
pub use datetime::*;
|
pub use datetime::*;
|
||||||
pub use hyprland::*;
|
pub use hyprland::*;
|
||||||
|
pub use pipewire::*;
|
||||||
pub use weather::*;
|
pub use weather::*;
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
//! PipeWire audio messages.
|
||||||
|
|
||||||
|
use crate::Service;
|
||||||
|
|
||||||
|
/// Route key a module subscribes under to receive the audio state.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||||
|
pub enum PipewireMsg {
|
||||||
|
State,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PipewireMsg {
|
||||||
|
/// Route key a module subscribes under to receive this service's state.
|
||||||
|
pub fn key(self) -> Service {
|
||||||
|
Service(match self {
|
||||||
|
Self::State => "pipewire.state",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One audio node's level, as PipeWire reports it.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Debug, Default)]
|
||||||
|
pub struct AudioNode {
|
||||||
|
/// Linear volume fraction: 1.0 is 100%, above 1.0 is boost.
|
||||||
|
pub volume: f32,
|
||||||
|
/// Node is muted; `volume` is still the level it will unmute to.
|
||||||
|
pub muted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default sink and default source state, published whenever either changes.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Debug, Default)]
|
||||||
|
pub struct PipewireState {
|
||||||
|
/// Default playback device.
|
||||||
|
pub sink: AudioNode,
|
||||||
|
/// Default capture device.
|
||||||
|
pub source: AudioNode,
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
//! Audio module: the default sink and source as icons, drawn only while
|
||||||
|
//! something is muted — the signal the quickshell Audio widget gives.
|
||||||
|
|
||||||
|
use iced::widget::{container, row, space, text};
|
||||||
|
use iced::{border, Alignment, Element, Length, Task, Theme};
|
||||||
|
|
||||||
|
use common::{AudioNode, BarModule, ModuleEffect, PipewireMsg, PipewireState, Service, Wire};
|
||||||
|
|
||||||
|
/// Glyphs exactly as the Quickshell Audio widget draws them: the speaker is a
|
||||||
|
/// Material `md-volume` icon, the mic a Font Awesome one.
|
||||||
|
const SINK_ON: &str = "\u{f057e}"; // md-volume_high
|
||||||
|
const SINK_MUTED: &str = "\u{f0581}"; // md-volume_off
|
||||||
|
const MIC_ON: &str = "\u{f130}"; // fa-microphone
|
||||||
|
const MIC_MUTED: &str = "\u{f131}"; // fa-microphone_slash
|
||||||
|
|
||||||
|
/// Quickshell's geometry at its 20 px reference bar: a 24 px slot per icon
|
||||||
|
/// and 4 px between them. Heights and the pill fill the bar through layout,
|
||||||
|
/// but font size is a fixed px value iced cannot derive from the space it is
|
||||||
|
/// given — if the bar ever leaves 20 px, scale the two sizes here.
|
||||||
|
const REF_SLOT_W: f32 = 24.0;
|
||||||
|
const REF_SPACING: f32 = 4.0;
|
||||||
|
const REF_RADIUS: f32 = 3.5;
|
||||||
|
/// The speaker is re-drawn in the mono face, whose ink is 1168/1496 the size
|
||||||
|
/// of the proportional face quickshell uses, so it is pre-compensated.
|
||||||
|
const REF_SINK_SIZE: f32 = 20.0 * 1496.0 / 1168.0;
|
||||||
|
const REF_MIC_SIZE: f32 = 30.0;
|
||||||
|
|
||||||
|
pub struct Audio {
|
||||||
|
state: PipewireState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Audio {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
state: PipewireState::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BarModule for Audio {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"audio"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, _window_id: Option<iced::window::Id>) -> Element<'_, Wire> {
|
||||||
|
let (sink, source) = (self.state.sink, self.state.source);
|
||||||
|
if !sink.muted && !source.muted {
|
||||||
|
// Nothing is muted, so there is nothing to show.
|
||||||
|
return space::horizontal().into();
|
||||||
|
}
|
||||||
|
|
||||||
|
let icons = row![
|
||||||
|
icon(glyph(sink, SINK_ON, SINK_MUTED), REF_SINK_SIZE),
|
||||||
|
icon(glyph(source, MIC_ON, MIC_MUTED), REF_MIC_SIZE),
|
||||||
|
]
|
||||||
|
.height(Length::Fill)
|
||||||
|
.spacing(REF_SPACING);
|
||||||
|
|
||||||
|
container(icons)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.style(pill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, msg: Wire) -> Task<ModuleEffect> {
|
||||||
|
if let Some(state) = msg.downcast::<PipewireState>() {
|
||||||
|
self.state = *state;
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn services(&self) -> Vec<Service> {
|
||||||
|
vec![PipewireMsg::State.key()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One glyph centred in a fixed-width slot that fills the bar's height.
|
||||||
|
fn icon<'a>(glyph: &'static str, size: f32) -> Element<'a, Wire> {
|
||||||
|
container(
|
||||||
|
text(glyph)
|
||||||
|
.size(size)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.align_y(Alignment::Center),
|
||||||
|
)
|
||||||
|
.center_x(Length::Fixed(REF_SLOT_W))
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The muted glyph when the node is muted, its normal glyph otherwise.
|
||||||
|
fn glyph(node: AudioNode, on: &'static str, muted: &'static str) -> &'static str {
|
||||||
|
if node.muted {
|
||||||
|
muted
|
||||||
|
} else {
|
||||||
|
on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The quickshell pill: rounded, a shade lighter than the bar behind it.
|
||||||
|
fn pill(theme: &Theme) -> container::Style {
|
||||||
|
container::Style {
|
||||||
|
background: Some(theme.extended_palette().background.weak.color.into()),
|
||||||
|
border: border::rounded(REF_RADIUS),
|
||||||
|
..container::Style::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Bar module registry: constructs every enabled module. Adding a module is
|
//! Bar module registry: constructs every enabled module. Adding a module is
|
||||||
//! a new file under `src/` plus one entry in `all()` — core never changes.
|
//! a new file under `src/` plus one entry in `all()` — core never changes.
|
||||||
|
|
||||||
|
mod audio;
|
||||||
mod clock;
|
mod clock;
|
||||||
mod weather;
|
mod weather;
|
||||||
mod workspaces;
|
mod workspaces;
|
||||||
@@ -13,6 +14,7 @@ pub use weather::*;
|
|||||||
/// One instance of every enabled module, ready to insert by `id()`.
|
/// One instance of every enabled module, ready to insert by `id()`.
|
||||||
pub fn all(module_config: Table) -> Vec<Box<dyn BarModule>> {
|
pub fn all(module_config: Table) -> Vec<Box<dyn BarModule>> {
|
||||||
vec![
|
vec![
|
||||||
|
Box::new(audio::Audio::new()),
|
||||||
Box::new(clock::Clock::new(Some(module_config.clone()))),
|
Box::new(clock::Clock::new(Some(module_config.clone()))),
|
||||||
Box::new(weather::WeatherModule::default()),
|
Box::new(weather::WeatherModule::default()),
|
||||||
Box::new(workspaces::Workspaces::new(Some(module_config))),
|
Box::new(workspaces::Workspaces::new(Some(module_config))),
|
||||||
|
|||||||
@@ -16,4 +16,6 @@ chrono = { workspace = true }
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
reqwest = { version = "0.13.5", features = ["json"] }
|
reqwest = { version = "0.13.5", features = ["json"] }
|
||||||
|
pipewire-native = "0.1.4"
|
||||||
|
pipewire-native-spa = "0.1.4"
|
||||||
hyprland = { git = "https://github.com/hyprland-community/hyprland-rs", branch = "master", default-features = false, features = ["listener", "tokio"] }
|
hyprland = { git = "https://github.com/hyprland-community/hyprland-rs", branch = "master", default-features = false, features = ["listener", "tokio"] }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use common::{Inbox, Service, Wire};
|
|||||||
|
|
||||||
pub mod compositors;
|
pub mod compositors;
|
||||||
pub mod datetime;
|
pub mod datetime;
|
||||||
|
pub mod pipewire;
|
||||||
pub mod weather;
|
pub mod weather;
|
||||||
|
|
||||||
/// Conversion from a route key + its inbox to the subscription backing it.
|
/// Conversion from a route key + its inbox to the subscription backing it.
|
||||||
@@ -28,6 +29,7 @@ impl IntoSubscription for Service {
|
|||||||
compositors::hyprland::HyprlandService::run(inbox)
|
compositors::hyprland::HyprlandService::run(inbox)
|
||||||
}
|
}
|
||||||
k if k.starts_with(weather::NAMESPACE) => weather::WeatherService::run(inbox),
|
k if k.starts_with(weather::NAMESPACE) => weather::WeatherService::run(inbox),
|
||||||
|
k if k.starts_with(pipewire::NAMESPACE) => pipewire::PipewireService::run(inbox),
|
||||||
_ => {
|
_ => {
|
||||||
tracing::warn!(service = key, "no service impl for route key");
|
tracing::warn!(service = key, "no service impl for route key");
|
||||||
Subscription::none()
|
Subscription::none()
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
//! PipeWire service: follows the default sink and source — WirePlumber names
|
||||||
|
//! them in its `default` metadata — and republishes their volume/mute
|
||||||
|
//! whenever PipeWire reports a change.
|
||||||
|
//!
|
||||||
|
//! PipeWire drives its own main loop, so the connection lives on a dedicated
|
||||||
|
//! thread; that thread hands state to the async side over a channel.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use common::{AudioNode, Endpoint, Inbox, PipewireState, Wire};
|
||||||
|
use iced::futures::{channel::mpsc, SinkExt};
|
||||||
|
use iced::Subscription;
|
||||||
|
use pipewire_native as pw;
|
||||||
|
use pipewire_native::context::Context;
|
||||||
|
use pipewire_native::core::Core;
|
||||||
|
use pipewire_native::main_loop::MainLoop;
|
||||||
|
use pipewire_native::properties::Properties;
|
||||||
|
use pipewire_native::proxy::metadata::{Metadata, MetadataEvents};
|
||||||
|
use pipewire_native::proxy::node::{Node, NodeEvents};
|
||||||
|
use pipewire_native::proxy::registry::{Registry, RegistryEvents};
|
||||||
|
use pipewire_native::proxy::{HasProxy, ProxyEvents};
|
||||||
|
use pipewire_native::some_closure;
|
||||||
|
use pipewire_native::types;
|
||||||
|
use pipewire_native_spa as spa;
|
||||||
|
use tokio::sync::mpsc as tokio_mpsc;
|
||||||
|
|
||||||
|
/// Route-key namespace owned by this service; keys are `"pipewire.<kind>"`.
|
||||||
|
pub const NAMESPACE: &str = "pipewire.";
|
||||||
|
|
||||||
|
/// Node classes the service follows.
|
||||||
|
const SINK: &str = "Audio/Sink";
|
||||||
|
const SOURCE: &str = "Audio/Source";
|
||||||
|
|
||||||
|
/// WirePlumber's metadata object holding the default nodes.
|
||||||
|
const DEFAULTS: &str = "default";
|
||||||
|
const DEFAULT_SINK: &str = "default.audio.sink";
|
||||||
|
const DEFAULT_SOURCE: &str = "default.audio.source";
|
||||||
|
|
||||||
|
pub struct PipewireService;
|
||||||
|
|
||||||
|
impl PipewireService {
|
||||||
|
pub fn run(inbox: Inbox) -> Subscription<Wire> {
|
||||||
|
Subscription::run_with(inbox, |inbox| {
|
||||||
|
let key = inbox.key();
|
||||||
|
// We dont need to rx any msgs from modules
|
||||||
|
let mut _rx = inbox.take().expect("inbox receiver is taken once");
|
||||||
|
|
||||||
|
iced::stream::channel(0, move |mut sender: mpsc::Sender<Wire>| async move {
|
||||||
|
// The PipeWire thread owns the connection and drops it once
|
||||||
|
// this channel goes away, which stops its loop.
|
||||||
|
let (tx, mut rx) = tokio_mpsc::unbounded_channel();
|
||||||
|
std::thread::spawn(move || connect(tx));
|
||||||
|
while let Some(state) = rx.recv().await {
|
||||||
|
tracing::debug!(?state, "pipewire state changed");
|
||||||
|
let wire = Wire::topic(Endpoint::service(key), key, state);
|
||||||
|
if sender.send(wire).await.is_err() {
|
||||||
|
tracing::warn!(key, "pipewire send failed; stopping service");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects, follows the default nodes, and runs PipeWire's loop until the
|
||||||
|
/// subscriber (or PipeWire itself) goes away.
|
||||||
|
fn connect(tx: tokio_mpsc::UnboundedSender<PipewireState>) {
|
||||||
|
pw::init();
|
||||||
|
let conn = match Connection::open() {
|
||||||
|
Ok(conn) => conn,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(%err, "pipewire unavailable; audio state stays unknown");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let track = Arc::new(Mutex::new(Track::new(conn, tx)));
|
||||||
|
watch(&track);
|
||||||
|
let main_loop = track.lock().unwrap().conn.main_loop.clone();
|
||||||
|
|
||||||
|
tracing::info!("pipewire connected");
|
||||||
|
main_loop.run();
|
||||||
|
tracing::info!("pipewire disconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The connection chain: PipeWire tears the session down if any link of it
|
||||||
|
/// goes away, so all of it lives for as long as the loop runs.
|
||||||
|
struct Connection {
|
||||||
|
main_loop: MainLoop,
|
||||||
|
_context: Context,
|
||||||
|
_core: Core,
|
||||||
|
registry: Registry,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Connection {
|
||||||
|
fn open() -> std::io::Result<Self> {
|
||||||
|
let main_loop = MainLoop::new(&Properties::new())
|
||||||
|
.ok_or_else(|| std::io::Error::other("pipewire main loop unavailable"))?;
|
||||||
|
let context = Context::new(&main_loop, Properties::new())?;
|
||||||
|
let core = context.connect(None)?;
|
||||||
|
let registry = core.registry()?;
|
||||||
|
Ok(Self {
|
||||||
|
main_loop,
|
||||||
|
_context: context,
|
||||||
|
_core: core,
|
||||||
|
registry,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribes to every sink/source node and to the `default` metadata, so
|
||||||
|
/// PipeWire pushes volume/mute changes instead of us polling for them.
|
||||||
|
fn watch(track: &Arc<Mutex<Track>>) {
|
||||||
|
let registry = track.lock().unwrap().conn.registry.clone();
|
||||||
|
registry.add_listener(RegistryEvents {
|
||||||
|
global: some_closure!([registry ^(track)] id, _perms, type_, version, props, {
|
||||||
|
match type_ {
|
||||||
|
types::interface::NODE => {
|
||||||
|
let class = props.get("media.class").unwrap_or_default();
|
||||||
|
if class != SINK && class != SOURCE {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(name) = props.get("node.name") {
|
||||||
|
bind_node(track, ®istry, id, type_, version, name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
types::interface::METADATA if props.get("metadata.name") == Some(DEFAULTS) => {
|
||||||
|
bind_defaults(track, ®istry, id, type_, version);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
global_remove: some_closure!([^(track)] id, {
|
||||||
|
let mut track = track.lock().unwrap();
|
||||||
|
if let Some(name) = track.ids.remove(&id) {
|
||||||
|
track.nodes.remove(&name);
|
||||||
|
track.publish();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Binds a sink/source node and follows its `Props` param (volume + mute).
|
||||||
|
fn bind_node(
|
||||||
|
track: &Arc<Mutex<Track>>,
|
||||||
|
registry: &Registry,
|
||||||
|
id: pw::Id,
|
||||||
|
type_: &str,
|
||||||
|
version: u32,
|
||||||
|
name: String,
|
||||||
|
) {
|
||||||
|
let Ok(object) = registry.bind(id, type_, version) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(proxy) = object.downcast_proxy::<Node>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(node) = proxy.object() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
node.add_listener(NodeEvents {
|
||||||
|
param: some_closure!([^(track, name)] _seq, id, _index, _next, pod, {
|
||||||
|
if id != spa::param::ParamType::Props {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut track = track.lock().unwrap();
|
||||||
|
merge(track.nodes.entry(name.clone()).or_default(), pod);
|
||||||
|
track.publish();
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
// A local proxy is only usable once the server has bound it, and asking
|
||||||
|
// for params is a method call.
|
||||||
|
proxy.add_listener(ProxyEvents {
|
||||||
|
bound_props: some_closure!([^(node)] _id, _props, {
|
||||||
|
let _ = node.subscribe_params(&[spa::param::ParamType::Props]);
|
||||||
|
let _ = node.enum_params(0, Some(spa::param::ParamType::Props), 0, u32::MAX, None);
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing::debug!(name, "following pipewire node");
|
||||||
|
let mut track = track.lock().unwrap();
|
||||||
|
track.ids.insert(id, name);
|
||||||
|
track.keep.push(object);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Binds WirePlumber's `default` metadata and follows which nodes are the
|
||||||
|
/// current sink and source.
|
||||||
|
fn bind_defaults(
|
||||||
|
track: &Arc<Mutex<Track>>,
|
||||||
|
registry: &Registry,
|
||||||
|
id: pw::Id,
|
||||||
|
type_: &str,
|
||||||
|
version: u32,
|
||||||
|
) {
|
||||||
|
let Ok(object) = registry.bind(id, type_, version) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(proxy) = object.downcast_proxy::<Metadata>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(metadata) = proxy.object() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
metadata.add_listener(MetadataEvents {
|
||||||
|
property: some_closure!([^(track)] _subject, key, _type, value, {
|
||||||
|
let (Some(key), Some(value)) = (key, value) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(name) = default_node(value) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut track = track.lock().unwrap();
|
||||||
|
match key {
|
||||||
|
DEFAULT_SINK => track.sink = Some(name),
|
||||||
|
DEFAULT_SOURCE => track.source = Some(name),
|
||||||
|
_ => return,
|
||||||
|
}
|
||||||
|
track.publish();
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing::debug!("following pipewire default metadata");
|
||||||
|
track.lock().unwrap().keep.push(object);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merges the volume/mute carried by a `SPA_PARAM_Props` pod into `state`;
|
||||||
|
/// anything the pod leaves out keeps its previous value.
|
||||||
|
fn merge(state: &mut AudioNode, pod: &spa::pod::RawPodOwned) {
|
||||||
|
let mut parser = spa::pod::parser::Parser::new(pod.data());
|
||||||
|
let _ = parser.pop_object::<spa::param::props::Prop, spa::param::ParamType, _>(|props, _id| {
|
||||||
|
for (key, _flags, value) in props {
|
||||||
|
match key {
|
||||||
|
spa::param::props::Prop::Volume => {
|
||||||
|
if let Ok(volume) = value.decode::<f32>() {
|
||||||
|
state.volume = volume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spa::param::props::Prop::Mute => {
|
||||||
|
if let Ok(muted) = value.decode::<bool>() {
|
||||||
|
state.muted = muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WirePlumber stores a default as `{"name":"<node.name>"}`.
|
||||||
|
fn default_node(value: &str) -> Option<String> {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Default {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
serde_json::from_str::<Default>(value).ok().map(|d| d.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the PipeWire callbacks share: the connection, the audio state they
|
||||||
|
/// observe, and the channel home.
|
||||||
|
struct Track {
|
||||||
|
conn: Connection,
|
||||||
|
/// Volume/mute of every sink and source, keyed by `node.name`.
|
||||||
|
nodes: HashMap<String, AudioNode>,
|
||||||
|
/// Registry id -> `node.name`, so a removed node can be forgotten.
|
||||||
|
ids: HashMap<pw::Id, String>,
|
||||||
|
/// `node.name` of the default sink/source, per the metadata.
|
||||||
|
sink: Option<String>,
|
||||||
|
source: Option<String>,
|
||||||
|
/// Bound proxies, kept alive so their listeners stay registered.
|
||||||
|
keep: Vec<Box<dyn HasProxy>>,
|
||||||
|
tx: tokio_mpsc::UnboundedSender<PipewireState>,
|
||||||
|
/// Last state published, so redundant updates are dropped.
|
||||||
|
sent: Option<PipewireState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Track {
|
||||||
|
fn new(conn: Connection, tx: tokio_mpsc::UnboundedSender<PipewireState>) -> Self {
|
||||||
|
Self {
|
||||||
|
conn,
|
||||||
|
nodes: HashMap::new(),
|
||||||
|
ids: HashMap::new(),
|
||||||
|
sink: None,
|
||||||
|
source: None,
|
||||||
|
keep: Vec::new(),
|
||||||
|
tx,
|
||||||
|
sent: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publishes the default sink/source, if the state actually changed.
|
||||||
|
fn publish(&mut self) {
|
||||||
|
let state = PipewireState {
|
||||||
|
sink: self.node(&self.sink),
|
||||||
|
source: self.node(&self.source),
|
||||||
|
};
|
||||||
|
if self.sent == Some(state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.sent = Some(state);
|
||||||
|
if self.tx.send(state).is_err() {
|
||||||
|
tracing::info!("pipewire subscriber gone; disconnecting");
|
||||||
|
self.conn.main_loop.quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State of the named node; zeroes while the default is unknown.
|
||||||
|
fn node(&self, name: &Option<String>) -> AudioNode {
|
||||||
|
name.as_ref()
|
||||||
|
.and_then(|name| self.nodes.get(name))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,9 @@
|
|||||||
nativeBuildInputs =
|
nativeBuildInputs =
|
||||||
[
|
[
|
||||||
pkgs.pkg-config
|
pkgs.pkg-config
|
||||||
|
# libspa-0.2.pc: pipewire-native bakes the SPA plugin directory
|
||||||
|
# from it, and loads libspa-support from there at runtime.
|
||||||
|
pkgs.pipewire.dev
|
||||||
]
|
]
|
||||||
++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [
|
++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [
|
||||||
pkgs.mold
|
pkgs.mold
|
||||||
@@ -115,7 +118,9 @@
|
|||||||
nativeBuildInputs = [ pkgs.makeWrapper ];
|
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||||
postBuild = ''
|
postBuild = ''
|
||||||
wrapProgram $out/bin/core \
|
wrapProgram $out/bin/core \
|
||||||
--prefix LD_LIBRARY_PATH : ${pkgs.lib.makeLibraryPath guiLibs}
|
--prefix LD_LIBRARY_PATH : ${pkgs.lib.makeLibraryPath guiLibs} \
|
||||||
|
--set SPA_PLUGIN_DIR ${pkgs.pipewire}/lib/spa-0.2 \
|
||||||
|
--set PIPEWIRE_CONFIG_DIR ${pkgs.pipewire}/share/pipewire
|
||||||
'';
|
'';
|
||||||
meta.mainProgram = "core";
|
meta.mainProgram = "core";
|
||||||
};
|
};
|
||||||
@@ -144,10 +149,13 @@
|
|||||||
# every dependency on each switch.
|
# every dependency on each switch.
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${pkgs.lib.makeLibraryPath guiLibs}"
|
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${pkgs.lib.makeLibraryPath guiLibs}"
|
||||||
|
# pipewire-native dlopens SPA plugins and reads client.conf.
|
||||||
|
export SPA_PLUGIN_DIR="${pkgs.pipewire}/lib/spa-0.2"
|
||||||
|
export PIPEWIRE_CONFIG_DIR="${pkgs.pipewire}/share/pipewire"
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# Extra inputs can be added here; cargo and rustc are provided by default.
|
# Extra inputs can be added here; cargo and rustc are provided by default.
|
||||||
packages = guiLibs ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [ pkgs.mold ];
|
packages = guiLibs ++ [ pkgs.pipewire.dev ] ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [ pkgs.mold ];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# Output to pin the bar to; omit to follow the active output.
|
# Output to pin the bar to; omit to follow the active output.
|
||||||
monitor = "DP-2"
|
monitor = "DP-2"
|
||||||
# Bar height in logical px (default 36); reserved exclusively.
|
# Bar height in logical px (default 36); reserved exclusively.
|
||||||
height = 24
|
height = 20
|
||||||
# MSAA for triangle primitives (does not affect quad borders).
|
# MSAA for triangle primitives (does not affect quad borders).
|
||||||
antialiasing = true
|
antialiasing = true
|
||||||
# Default text size in logical px (default 16).
|
# Default text size in logical px (default 16).
|
||||||
@@ -15,7 +15,7 @@ spacing = 8.0
|
|||||||
[order]
|
[order]
|
||||||
left = ["workspaces", "weather"]
|
left = ["workspaces", "weather"]
|
||||||
middle = []
|
middle = []
|
||||||
right = ["clock"]
|
right = ["audio", "clock"]
|
||||||
|
|
||||||
[modules.clock]
|
[modules.clock]
|
||||||
show_seconds = false
|
show_seconds = false
|
||||||
|
|||||||
Reference in New Issue
Block a user