diff --git a/Cargo.lock b/Cargo.lock index 96238c9..1887d24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -376,6 +376,7 @@ dependencies = [ "loupedeck_serial", "once_cell", "pa-volume-interface", + "parse-display", "regex", "resvg", "rgb", @@ -1061,6 +1062,32 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "parse-display" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6509d08722b53e8dafe97f2027b22ccbe3a5db83cb352931e9716b0aa44bc5c" +dependencies = [ + "once_cell", + "parse-display-derive", + "regex", +] + +[[package]] +name = "parse-display-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68517892c8daf78da08c0db777fcc17e07f2f63ef70041718f8a7630ad84f341" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "regex", + "regex-syntax 0.7.5", + "structmeta", + "syn 2.0.48", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -1161,7 +1188,7 @@ dependencies = [ "aho-corasick", "memchr", "regex-automata", - "regex-syntax", + "regex-syntax 0.8.2", ] [[package]] @@ -1172,9 +1199,15 @@ checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.2", ] +[[package]] +name = "regex-syntax" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" + [[package]] name = "regex-syntax" version = "0.8.2" @@ -1499,6 +1532,29 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "structmeta" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ad9e09554f0456d67a69c1584c9798ba733a5b50349a6c0d0948710523922d" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn 2.0.48", +] + +[[package]] +name = "structmeta-derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a60bcaff7397072dca0017d1db428e30d5002e00b6847703e2e42005c95fbe00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + [[package]] name = "svgtypes" version = "0.13.0" diff --git a/deckster/Cargo.toml b/deckster/Cargo.toml index ded928a..09fce1b 100644 --- a/deckster/Cargo.toml +++ b/deckster/Cargo.toml @@ -29,4 +29,5 @@ tiny-skia = "0.11.3" tokio = { version = "1.35.1", features = ["macros", "parking_lot", "rt", "rt-multi-thread", "sync"]} toml = "0.8.8" walkdir = "2.4.0" -once_cell = "1.19.0" \ No newline at end of file +once_cell = "1.19.0" +parse-display = "0.8.2" \ No newline at end of file diff --git a/deckster/examples/full/icons/apps/youtube.svg b/deckster/examples/full/icons/apps/youtube.svg new file mode 100644 index 0000000..01652bc --- /dev/null +++ b/deckster/examples/full/icons/apps/youtube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/deckster/examples/full/knob-pages/default.toml b/deckster/examples/full/knob-pages/default.toml index f6e21ca..38c33a2 100644 --- a/deckster/examples/full/knob-pages/default.toml +++ b/deckster/examples/full/knob-pages/default.toml @@ -1,30 +1,49 @@ -#[knobs.right-top] -#icon = "@ph/microphone-light[scale=0.9]" -#indicators.bar.color = "#ffffff50" -# -#mode.audio_volume.direction = "input" -#mode.audio_volume.regex = "^(SC425 USB Microphone Analog Stereo)$" -#mode.audio_volume.disable_press_to_unmute = true -#mode.audio_volume.muted_turn_action = "unmute-at-zero" -#mode.audio_volume.style.muted.label = "Muted" -#mode.audio_volume.style.inactive.icon = "@ph/microphone-slash-light[alpha=0.9|color=#fc4646]" -# -#[knobs.right-middle] -#icon = "@apps/discord[scale=0.25]" -#indicators.bar.color = "#ffffff50" -# -#mode.audio_volume.regex = "Discord" -#mode.audio_volume.style.active.label = "{percentage}%" -#mode.audio_volume.style.muted.label = "Muted" -#mode.audio_volume.style.inactive.label = "" -#mode.audio_volume.style.inactive.icon = "@apps/discord[grayscale|alpha=0.9]" - -[knobs.right-bottom] -icon = "@apps/spotify[scale=1.1]" +[knobs.right-top] +icon = "@ph/microphone-light[scale=0.9]" indicators.bar.color = "#ffffff50" -mode.audio_volume.regex = "Spotify" +mode.audio_volume.delta = 0.05 +mode.audio_volume.target.type = "input" +mode.audio_volume.target.predicates = [{ property = "description", value = "SC425 USB Microphone Analog Stereo" }] +mode.audio_volume.disable_press_to_unmute = true +mode.audio_volume.muted_turn_action = "unmute-at-zero" + mode.audio_volume.style.active.label = "{percentage}%" mode.audio_volume.style.muted.label = "Muted" -mode.audio_volume.style.inactive.label = "" -mode.audio_volume.style.inactive.icon = "@apps/spotify[grayscale|alpha=0.9]" \ No newline at end of file +mode.audio_volume.style.muted.icon = "@ph/microphone-slash-light[scale=0.9|color=#fc4646]" +mode.audio_volume.style.muted.indicators.bar.color = "#fc464690" +mode.audio_volume.style.inactive.label = "N/A" +mode.audio_volume.style.inactive.icon = "@ph/microphone-slash-light[scale=0.9|alpha=0.8|color=#fc4646]" + +[knobs.left-top] +icon = "@apps/discord[scale=0.25]" +indicators.bar.color = "#ffffff50" + +mode.audio_volume.delta = 0.05 +mode.audio_volume.target.type = "application" +mode.audio_volume.target.predicates = [{ property = "binary-name", value = "Discord" }, { property = "description", value = "playStream" }] + +mode.audio_volume.style.muted.indicators.bar.color = "#fc464690" +mode.audio_volume.style.inactive.icon = "@apps/discord[scale=0.25|grayscale|alpha=0.8]" + +[knobs.left-middle] +icon = "@apps/youtube[scale=1.3|color=#ff0000]" +indicators.bar.color = "#ffffff50" + +mode.audio_volume.delta = 0.05 +mode.audio_volume.target.type = "application" +mode.audio_volume.target.predicates = [{ property = "binary-name", value = "librewolf" }, { property = "description", regex = "\\- Piped$" }] + +mode.audio_volume.style.muted.indicators.bar.color = "#fc464690" +mode.audio_volume.style.inactive.icon = "@apps/youtube[scale=1.3|grayscale|alpha=0.8]" + +[knobs.left-bottom] +icon = "@apps/spotify[scale=1.2]" +indicators.bar.color = "#ffffff50" + +mode.audio_volume.delta = 0.05 +mode.audio_volume.target.type = "application" +mode.audio_volume.target.predicates = [{ property = "application-name", value = "spotify" }] + +mode.audio_volume.style.muted.indicators.bar.color = "#fc464690" +mode.audio_volume.style.inactive.icon = "@apps/spotify[scale=1.2|grayscale|alpha=0.8]" \ No newline at end of file diff --git a/deckster/src/model/position.rs b/deckster/src/model/position.rs index 9d776df..58fb08b 100644 --- a/deckster/src/model/position.rs +++ b/deckster/src/model/position.rs @@ -67,6 +67,12 @@ pub enum KnobPosition { RightBottom, } +impl KnobPosition { + pub fn is_left(&self) -> bool { + matches!(self, KnobPosition::LeftBottom | KnobPosition::LeftMiddle | KnobPosition::LeftTop) + } +} + impl From for KnobPosition { fn from(value: LoupedeckKnob) -> Self { match value { diff --git a/deckster/src/modes/knob/audio_volume.rs b/deckster/src/modes/knob/audio_volume.rs index 069b024..f41f831 100644 --- a/deckster/src/modes/knob/audio_volume.rs +++ b/deckster/src/modes/knob/audio_volume.rs @@ -1,14 +1,15 @@ use std::borrow::ToOwned; -use std::collections::HashMap; use std::sync::Arc; +use log::warn; use once_cell::sync::Lazy; +use parse_display::Display; use regex::Regex; use serde::Deserialize; use tokio::select; use tokio::sync::broadcast; -use pa_volume_interface::{PaEntityState, PaVolumeInterface}; +use pa_volume_interface::{PaEntityKind, PaEntityMetadata, PaEntityState, PaVolumeInterface}; use crate::model::knob_page::StyleByStateMap; use crate::model::position::KnobPath; @@ -17,12 +18,8 @@ use crate::runner::command::IoWorkerCommand; #[derive(Debug, Deserialize)] pub struct Config { - #[serde(default = "default_delta")] - pub delta: f32, - #[serde(with = "serde_regex")] - pub regex: Regex, - #[serde(default)] - pub direction: Direction, + pub target: Target, + pub delta: Option, #[serde(default)] pub disable_press_to_mute: bool, #[serde(default)] @@ -30,13 +27,52 @@ pub struct Config { #[serde(default)] pub muted_turn_action: MutedTurnAction, #[serde(default)] - pub label: HashMap, - #[serde(default)] pub style: StyleByStateMap, } -fn default_delta() -> f32 { - 0.05 +#[derive(Debug, Eq, PartialEq, Deserialize, Display)] +#[serde(rename_all = "kebab-case")] +#[display(style = "kebab-case")] +pub enum TargetKind { + Input, + Output, + Application, +} + +#[derive(Debug, Deserialize)] +pub struct Target { + #[serde(rename = "type")] + kind: TargetKind, + predicates: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct TargetPredicate { + property: TargetPredicateProperty, + #[serde(flatten)] + pattern: TargetPredicatePattern, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum TargetPredicatePattern { + Static { + value: String, + }, + Regex { + #[serde(with = "serde_regex")] + regex: Regex, + }, +} + +#[derive(Debug, Eq, PartialEq, Deserialize, Display)] +#[serde(rename_all = "kebab-case")] +#[display(style = "kebab-case")] +pub enum TargetPredicateProperty { + Description, + InternalName, + ApplicationName, + BinaryName, } #[derive(Debug, Default, Eq, PartialEq, Deserialize)] @@ -66,12 +102,54 @@ pub enum State { static PA_VOLUME_INTERFACE: Lazy = Lazy::new(|| PaVolumeInterface::spawn_thread("deckster".to_owned())); -fn get_volume_cv(channel_volumes: &Vec) -> f32 { - *channel_volumes.first().unwrap() +fn get_volume_cv(channel_volumes: &[f32]) -> f32 { + *channel_volumes.iter().max_by(|a, b| a.partial_cmp(b).unwrap()).unwrap() } fn get_volume_es(entity_state: &Option>) -> f32 { - entity_state.as_ref().map(|s| *s.channel_volumes().first().unwrap()).unwrap_or(0.0) + entity_state.as_ref().map(|s| get_volume_cv(&s.channel_volumes())).unwrap_or(0.0) +} + +fn state_matches(target: &Target, state: &PaEntityState) -> bool { + if !match target.kind { + TargetKind::Input => state.kind() == PaEntityKind::Source, + TargetKind::Output => state.kind() == PaEntityKind::Sink, + TargetKind::Application => state.kind() == PaEntityKind::SinkInput, + } { + return false; + } + + static EMPTY_STRING: String = String::new(); + + return target.predicates.iter().all(|p| { + let v = match (&p.property, state.metadata()) { + (TargetPredicateProperty::InternalName, PaEntityMetadata::Sink { name, .. }) => Some(name), + (TargetPredicateProperty::InternalName, PaEntityMetadata::Source { name, .. }) => Some(name), + (TargetPredicateProperty::InternalName, PaEntityMetadata::SinkInput { .. }) => None, + (TargetPredicateProperty::Description, PaEntityMetadata::Sink { description, .. }) => Some(description), + (TargetPredicateProperty::Description, PaEntityMetadata::Source { description, .. }) => Some(description), + (TargetPredicateProperty::Description, PaEntityMetadata::SinkInput { description, .. }) => Some(description), + (TargetPredicateProperty::ApplicationName, PaEntityMetadata::Sink { .. }) => None, + (TargetPredicateProperty::ApplicationName, PaEntityMetadata::Source { .. }) => None, + (TargetPredicateProperty::ApplicationName, PaEntityMetadata::SinkInput { application_name, .. }) => { + Some(application_name.as_ref().unwrap_or(&EMPTY_STRING)) + } + (TargetPredicateProperty::BinaryName, PaEntityMetadata::Sink { .. }) => None, + (TargetPredicateProperty::BinaryName, PaEntityMetadata::Source { .. }) => None, + (TargetPredicateProperty::BinaryName, PaEntityMetadata::SinkInput { binary_name, .. }) => Some(binary_name.as_ref().unwrap_or(&EMPTY_STRING)), + }; + + if let Some(v) = v { + match &p.pattern { + TargetPredicatePattern::Static { value } => value == v, + TargetPredicatePattern::Regex { regex } => regex.is_match(v), + } + } else { + warn!("Property \"{}\" is not available for targets of type \"{}\"", &p.property, &target.kind); + + false + } + }); } pub async fn handle(path: KnobPath, config: Arc, mut events: broadcast::Receiver<(KnobPath, KnobEvent)>, commands: flume::Sender) { @@ -84,8 +162,9 @@ pub async fn handle(path: KnobPath, config: Arc, mut events: broadcast:: entity_state = state .entities_by_id() .values() - .find(|entity| config.regex.is_match(entity.display_name())) + .find(|entity| state_matches(&config.target, &entity)) .map(Arc::clone); + commands .send(IoWorkerCommand::SetKnobValue { path: path.clone(), @@ -127,7 +206,7 @@ pub async fn handle(path: KnobPath, config: Arc, mut events: broadcast:: loop { select! { Ok(volume_state) = volume_states.recv() => { - entity_state = volume_state.entities_by_id().values().find(|entity| config.regex.is_match(entity.display_name())).map(Arc::clone); + entity_state = volume_state.entities_by_id().values().find(|entity| state_matches(&config.target, &entity)).map(Arc::clone); update_style(&entity_state); commands.send(IoWorkerCommand::SetKnobValue { path: path.clone(), value: get_volume_es(&entity_state) }).unwrap() @@ -147,7 +226,7 @@ pub async fn handle(path: KnobPath, config: Arc, mut events: broadcast:: }; let current_v = get_volume_cv(&entity_state.channel_volumes()); - let v = (current_v + (factor * config.delta)).clamp(0.0, 1.0); + let v = (current_v + (factor * config.delta.unwrap_or(0.01))).clamp(0.0, 1.0); pa_volume_interface.set_channel_volumes(*entity_state.id(), vec![v; entity_state.channel_volumes().len()]); } KnobEvent::Press => { diff --git a/deckster/src/runner/graphics.rs b/deckster/src/runner/graphics.rs index 9fb3a5c..49594e1 100644 --- a/deckster/src/runner/graphics.rs +++ b/deckster/src/runner/graphics.rs @@ -81,13 +81,23 @@ pub fn render_knob(context: &GraphicsContext, screen_size: IntSize, state: Optio if let Some(indicators) = &style.indicators { if let Some(bar) = indicators.bar.as_ref() { let color = bar.color.unwrap_or(RGBA::new(0xff, 0xff, 0xff, 0x50).into()); - const PADDING: f32 = 20.0; - let max_height: f32 = pixmap.height() as f32 - PADDING * 2.0; + const WIDTH: f32 = 5.0; + const PADDING_X: f32 = 5.0; + const PADDING_Y: f32 = 20.0; + + let max_height: f32 = pixmap.height() as f32 - PADDING_Y * 2.0; let height = state.value * max_height; - let y = pixmap.height() as f32 - PADDING - height; + + let x = if state.path.position.is_left() { + PADDING_X + } else { + pixmap.width() as f32 - PADDING_X - WIDTH + }; + + let y = pixmap.height() as f32 - PADDING_Y - height; pixmap.fill_rect( - Rect::from_xywh(pixmap.width() as f32 - 10.0, y, 5.0, height).unwrap(), + Rect::from_xywh(x, y, WIDTH, height).unwrap(), &Paint { shader: Shader::SolidColor(Color::from_rgba8(color.r, color.g, color.b, color.a)), ..Paint::default() diff --git a/pa-volume-interface/src/lib.rs b/pa-volume-interface/src/lib.rs index d1688d9..3f83646 100644 --- a/pa-volume-interface/src/lib.rs +++ b/pa-volume-interface/src/lib.rs @@ -10,7 +10,6 @@ use libpulse_binding::context::introspect::{Introspector, SinkInfo, SinkInputInf use libpulse_binding::context::subscribe::{Facility, InterestMaskSet}; use libpulse_binding::context::{subscribe, Context, FlagSet}; use libpulse_binding::def::Retval; -use libpulse_binding::mainloop::api::Mainloop as MainloopTrait; use libpulse_binding::mainloop::standard::{IterateResult, Mainloop}; use libpulse_binding::operation::Operation; use libpulse_binding::volume::{ChannelVolumes, Volume}; @@ -23,17 +22,32 @@ pub type PaEntityId = u32; pub enum PaEntityKind { Source, Sink, - Application, + SinkInput, +} + +#[derive(Debug, Eq, PartialEq, Clone)] +pub enum PaEntityMetadata { + Source { + name: String, + description: String, + }, + Sink { + name: String, + description: String, + }, + SinkInput { + description: String, + binary_name: Option, + application_name: Option, + }, } #[derive(Debug, Clone)] pub struct PaEntityState { id: PaEntityId, - kind: PaEntityKind, - name: String, - display_name: String, channel_volumes: ChannelVolumes, is_muted: bool, + metadata: PaEntityMetadata, } impl PaEntityState { @@ -41,16 +55,16 @@ impl PaEntityState { &self.id } - pub fn kind(&self) -> &PaEntityKind { - &self.kind + pub fn kind(&self) -> PaEntityKind { + match &self.metadata { + PaEntityMetadata::Source { .. } => PaEntityKind::Source, + PaEntityMetadata::Sink { .. } => PaEntityKind::Sink, + PaEntityMetadata::SinkInput { .. } => PaEntityKind::SinkInput, + } } - pub fn name(&self) -> &String { - &self.name - } - - pub fn display_name(&self) -> &String { - &self.display_name + pub fn metadata(&self) -> &PaEntityMetadata { + &self.metadata } pub fn channel_volumes(&self) -> Vec { @@ -67,10 +81,11 @@ impl From<&SourceInfo<'_>> for PaEntityState { PaEntityState { id: value.index as PaEntityId, is_muted: value.mute, - name: value.name.clone().unwrap_or_default().into_owned(), - display_name: value.description.clone().unwrap_or_default().into_owned(), - kind: PaEntityKind::Source, channel_volumes: value.volume, + metadata: PaEntityMetadata::Source { + name: value.name.clone().unwrap_or_default().into_owned(), + description: value.description.clone().unwrap_or_default().into_owned(), + }, } } } @@ -80,10 +95,11 @@ impl From<&SinkInfo<'_>> for PaEntityState { PaEntityState { id: value.index as PaEntityId, is_muted: value.mute, - name: value.name.clone().unwrap_or_default().into_owned(), - display_name: value.description.clone().unwrap_or_default().into_owned(), - kind: PaEntityKind::Sink, channel_volumes: value.volume, + metadata: PaEntityMetadata::Sink { + name: value.name.clone().unwrap_or_default().into_owned(), + description: value.description.clone().unwrap_or_default().into_owned(), + }, } } } @@ -93,10 +109,18 @@ impl From<&SinkInputInfo<'_>> for PaEntityState { PaEntityState { id: value.index as PaEntityId, is_muted: value.mute, - name: value.name.clone().unwrap_or_default().into_owned(), - display_name: value.name.clone().unwrap_or_default().into_owned(), - kind: PaEntityKind::Application, channel_volumes: value.volume, + metadata: PaEntityMetadata::SinkInput { + description: value.name.clone().unwrap_or_default().into_owned(), + application_name: value + .proplist + .get("application.name") + .map(|v| String::from_utf8_lossy(v).trim_end_matches(char::from(0)).to_owned()), + binary_name: value + .proplist + .get("application.process.binary") + .map(|v| String::from_utf8_lossy(v).trim_end_matches(char::from(0)).to_owned()), + }, } } } @@ -353,14 +377,14 @@ impl PaThread { loop { self.run_single_mainloop_iteration(false); - if let Ok(command) = self.commands_rx.try_recv() { + while let Ok(command) = self.commands_rx.try_recv() { match command { PaCommand::SetIsMuted { id, value } => { if let Some(state) = PaThread::unwrap_state(¤t_state).entities_by_id.get(&id) { - match state.kind { + match state.kind() { PaEntityKind::Sink => self.introspector.borrow_mut().set_sink_mute_by_index(id, value, None), PaEntityKind::Source => self.introspector.borrow_mut().set_source_mute_by_index(id, value, None), - PaEntityKind::Application => self.introspector.borrow_mut().set_sink_input_mute(id, value, None), + PaEntityKind::SinkInput => self.introspector.borrow_mut().set_sink_input_mute(id, value, None), }; } } @@ -371,10 +395,10 @@ impl PaThread { value.set(i as u8, Volume((Volume::NORMAL.0 as f32 * v).floor() as u32)); } - match state.kind { + match state.kind() { PaEntityKind::Sink => self.introspector.borrow_mut().set_sink_volume_by_index(id, &value, None), PaEntityKind::Source => self.introspector.borrow_mut().set_source_volume_by_index(id, &value, None), - PaEntityKind::Application => self.introspector.borrow_mut().set_sink_input_volume(id, &value, None), + PaEntityKind::SinkInput => self.introspector.borrow_mut().set_sink_input_volume(id, &value, None), }; } }