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),
};
}
}