diff --git a/Cargo.lock b/Cargo.lock index 416e122..811a844 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -365,6 +365,7 @@ dependencies = [ "humantime-serde", "log", "loupedeck_serial", + "once_cell", "regex", "resvg", "rgb", @@ -857,6 +858,7 @@ dependencies = [ "enumset", "flume", "rgb", + "serde", "serialport", "thiserror", ] diff --git a/deckster/Cargo.toml b/deckster/Cargo.toml index 9c03d19..713e51c 100644 --- a/deckster/Cargo.toml +++ b/deckster/Cargo.toml @@ -28,3 +28,4 @@ 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 diff --git a/deckster/examples/full/icons/fad/repeat-one.svg b/deckster/examples/full/icons/fad/repeat-one.svg new file mode 100644 index 0000000..f46d69a --- /dev/null +++ b/deckster/examples/full/icons/fad/repeat-one.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/deckster/examples/full/icons/fad/repeat.svg b/deckster/examples/full/icons/fad/repeat.svg new file mode 100644 index 0000000..35871f2 --- /dev/null +++ b/deckster/examples/full/icons/fad/repeat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/deckster/examples/full/icons/ph/play-pause.svg b/deckster/examples/full/icons/ph/play-pause.svg new file mode 100644 index 0000000..c055180 --- /dev/null +++ b/deckster/examples/full/icons/ph/play-pause.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/deckster/examples/full/icons/ph/skip-back.svg b/deckster/examples/full/icons/ph/skip-back.svg new file mode 100644 index 0000000..67ffd93 --- /dev/null +++ b/deckster/examples/full/icons/ph/skip-back.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/deckster/examples/full/icons/ph/skip-forward.svg b/deckster/examples/full/icons/ph/skip-forward.svg new file mode 100644 index 0000000..466801e --- /dev/null +++ b/deckster/examples/full/icons/ph/skip-forward.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/deckster/examples/full/key-pages/default.toml b/deckster/examples/full/key-pages/default.toml index 6edc056..2f9b22a 100644 --- a/deckster/examples/full/key-pages/default.toml +++ b/deckster/examples/full/key-pages/default.toml @@ -1,17 +1,31 @@ [keys.1x1] -icon = "@ph/play[alpha=0.4]" -mode.vibrate.pattern = "low" -mode.media__play_pause.style.paused.icon = "@ph/play" -mode.media__play_pause.style.playing.icon = "@ph/pause" - -[keys.1x2] -icon = "@fad/shuffle[alpha=0.6]" -mode.vibrate.pattern = "low" -mode.spotify__shuffle.icon.active = "@fad/shuffle[color=#58fc11]" +icon = "@ph/skip-back" +mode.playerctl__button.command = "previous" +mode.playerctl__button.style.inactive.icon = "@ph/skip-back[alpha=0.4]" [keys.2x1] +icon = "@ph/play-pause[alpha=0.4]" +mode.playerctl__button.command = "play-pause" +mode.playerctl__button.style.paused.icon = "@ph/play" +mode.playerctl__button.style.playing.icon = "@ph/pause" + +[keys.3x1] +icon = "@ph/skip-forward" +mode.playerctl__button.command = "next" +mode.playerctl__button.style.inactive.icon = "@ph/skip-forward[alpha=0.4]" + +[keys.1x2] +icon = "@fad/shuffle[alpha=0.4]" +mode.playerctl__shuffle.style.on.icon = "@fad/shuffle[color=#58fc11]" +mode.playerctl__shuffle.style.off.icon = "@fad/shuffle" + +[keys.2x2] +icon = "@fad/repeat[alpha=0.4]" +mode.playerctl__loop.style.single.icon = "@fad/repeat-one[color=#58fc11]" +mode.playerctl__loop.style.all.icon = "@fad/repeat[color=#58fc11]" + +[keys.4x1] icon = "@ph/timer[color=#ff0000]" -mode.vibrate.pattern = "low" mode.timer.durations = ["60s", "5m", "10m", "15m", "30m"] mode.timer.vibrate_when_finished = true mode.timer.needy = true @@ -20,13 +34,11 @@ mode.timer.needy = true icon = "@fad/thunderbolt" label = "Dock" border= "#00ff00" -mode.vibrate.pattern = "low" mode.home_assistant__switch.name = "switch.moritz_thunderbolt_dock" mode.home_assistant__switch.icon.on = "@fad/thunderbolt[color=#58fc11]" [keys.4x3] icon = "@ph/computer-tower" label = "Tower PC unnötig lang" -mode.vibrate.pattern = "low" mode.home_assistant__switch.name = "switch.mwin" mode.home_assistant__switch.icon.on = "@ph/computer-tower[color=#58fc11]" \ No newline at end of file diff --git a/deckster/src/icons/mod.rs b/deckster/src/icons/mod.rs index efe64bd..a867999 100644 --- a/deckster/src/icons/mod.rs +++ b/deckster/src/icons/mod.rs @@ -47,15 +47,15 @@ pub fn get_used_icon_descriptors(config: &Config) -> HashSet { result.insert(d.clone()); } - if let Some(c) = &key.mode.media__next { + if let Some(c) = &key.mode.playerctl__button { insert_all_from_key_style_by_state_map(&mut result, &c.style); } - if let Some(c) = &key.mode.media__play_pause { + if let Some(c) = &key.mode.playerctl__shuffle { insert_all_from_key_style_by_state_map(&mut result, &c.style); } - if let Some(c) = &key.mode.media__previous { + if let Some(c) = &key.mode.playerctl__loop { insert_all_from_key_style_by_state_map(&mut result, &c.style); } @@ -66,14 +66,6 @@ pub fn get_used_icon_descriptors(config: &Config) -> HashSet { if let Some(c) = &key.mode.home_assistant__switch { insert_all_from_key_style_by_state_map(&mut result, &c.style); } - - if let Some(c) = &key.mode.spotify__repeat { - insert_all_from_key_style_by_state_map(&mut result, &c.style); - } - - if let Some(c) = &key.mode.spotify__shuffle { - insert_all_from_key_style_by_state_map(&mut result, &c.style); - } } } diff --git a/deckster/src/model/key_page.rs b/deckster/src/model/key_page.rs index a95558b..ca3cddd 100644 --- a/deckster/src/model/key_page.rs +++ b/deckster/src/model/key_page.rs @@ -76,11 +76,9 @@ pub struct KeyConfig { #[derive(Debug, Default, Deserialize)] pub struct KeyModes { pub vibrate: Option>, - pub media__play_pause: Option>, - pub media__previous: Option>, - pub media__next: Option>, - pub spotify__shuffle: Option>, - pub spotify__repeat: Option>, + pub playerctl__button: Option>, + pub playerctl__shuffle: Option>, + pub playerctl__loop: Option>, pub home_assistant__switch: Option>, pub home_assistant__button: Option>, } diff --git a/deckster/src/modes/key/media.rs b/deckster/src/modes/key/media.rs deleted file mode 100644 index 03ca5bf..0000000 --- a/deckster/src/modes/key/media.rs +++ /dev/null @@ -1,68 +0,0 @@ -use std::sync::Arc; - -use flume::Sender; -use serde::Deserialize; -use tokio::sync::broadcast::Receiver; - -use crate::model::key_page::StyleByStateMap; -use crate::model::position::KeyPath; -use crate::modes::key::KeyEvent; -use crate::runner::state::StateChangeCommand; - -#[derive(Debug, Deserialize)] -pub struct PlayPauseConfig { - #[serde(default)] - pub style: StyleByStateMap, - #[serde(default)] - pub action: PlayPauseAction, -} - -#[derive(Debug, Default, Eq, PartialEq, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum PlayPauseAction { - #[default] - Toggle, - Play, - Pause, -} - -#[derive(Debug, Deserialize)] -pub struct PreviousAndNextConfig { - #[serde(default)] - pub style: StyleByStateMap, -} - -#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum PlayPauseState { - Inactive, - Playing, - Paused, -} - -#[derive(Debug, Eq, PartialEq, Hash, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum PreviousAndNextState { - Inactive, - Active, -} - -pub async fn handle(path: KeyPath, config: Arc, mut events: Receiver, commands: Sender) { - let mut state = PlayPauseState::Inactive; - - while let Ok(event) = events.recv().await { - if event == KeyEvent::Press { - state = match state { - PlayPauseState::Playing => PlayPauseState::Paused, - _ => PlayPauseState::Playing, - }; - - commands - .send(StateChangeCommand::SetKeyStyle { - path: path.clone(), - value: config.style.get(&state).cloned(), - }) - .unwrap(); - } - } -} diff --git a/deckster/src/modes/key/mod.rs b/deckster/src/modes/key/mod.rs index d8ceeae..b469169 100644 --- a/deckster/src/modes/key/mod.rs +++ b/deckster/src/modes/key/mod.rs @@ -5,31 +5,50 @@ use tokio::sync::broadcast; use crate::model; use crate::model::position::KeyPath; -use crate::runner::state::StateChangeCommand; +use crate::runner::command::IoWorkerCommand; pub mod home_assistant; -pub mod media; -pub mod spotify; +pub mod playerctl; pub mod timer; pub mod vibrate; +#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)] +pub enum KeyTouchEventKind { + Start, + Move, + End, +} + #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] pub enum KeyEvent { Press, + Touch { touch_id: u8, x: u16, y: u16, kind: KeyTouchEventKind }, VisibilityChange { is_visible: bool }, } pub fn start_handlers( keys: impl Iterator)>, events: broadcast::Sender<(KeyPath, KeyEvent)>, - commands: flume::Sender, + commands: flume::Sender, ) { for (path, config) in keys { let mut events = events.subscribe(); let own_events = broadcast::Sender::new(5); - if let Some(c) = &config.mode.media__play_pause { - tokio::spawn(media::handle(path.clone(), Arc::clone(c), own_events.subscribe(), commands.clone())); + if let Some(c) = &config.mode.playerctl__button { + tokio::spawn(playerctl::handle_button(path.clone(), Arc::clone(c), own_events.subscribe(), commands.clone())); + } + + if let Some(c) = &config.mode.playerctl__shuffle { + tokio::spawn(playerctl::handle_shuffle(path.clone(), Arc::clone(c), own_events.subscribe(), commands.clone())); + } + + if let Some(c) = &config.mode.playerctl__loop { + tokio::spawn(playerctl::handle_loop(path.clone(), Arc::clone(c), own_events.subscribe(), commands.clone())); + } + + if let Some(c) = &config.mode.vibrate { + tokio::spawn(vibrate::handle(path.clone(), Arc::clone(c), own_events.subscribe(), commands.clone())); } tokio::spawn(async move { diff --git a/deckster/src/modes/key/playerctl.rs b/deckster/src/modes/key/playerctl.rs new file mode 100644 index 0000000..0fe2a3f --- /dev/null +++ b/deckster/src/modes/key/playerctl.rs @@ -0,0 +1,282 @@ +use std::fmt::Debug; +use std::hash::Hash; +use std::io::{BufRead, BufReader}; +use std::process::Stdio; +use std::sync::{Arc, RwLock}; +use std::thread; +use std::thread::sleep; +use std::time::Duration; + +use log::{error, warn}; +use once_cell::sync::Lazy; +use serde::Deserialize; +use tokio::select; +use tokio::sync::broadcast; +use tokio::sync::broadcast::error::RecvError; + +use crate::model::key_page::StyleByStateMap; +use crate::model::position::KeyPath; +use crate::modes::key::KeyEvent; +use crate::runner::command::IoWorkerCommand; + +#[derive(Debug, Deserialize)] +pub struct ButtonConfig { + #[serde(default)] + pub style: StyleByStateMap, + pub command: ButtonCommand, +} + +#[derive(Debug, Eq, PartialEq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ButtonCommand { + PlayPause, + Play, + Pause, + Previous, + Next, +} + +#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ButtonState { + Inactive, + Playing, + Paused, +} + +#[derive(Debug, Deserialize)] +pub struct ShuffleConfig { + #[serde(default)] + pub style: StyleByStateMap, +} + +#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ShuffleState { + Inactive, + Off, + On, +} + +#[derive(Debug, Deserialize)] +pub struct LoopConfig { + #[serde(default)] + pub style: StyleByStateMap, +} + +#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum LoopState { + Inactive, + None, + Single, + All, +} + +struct PlayerctlStateWatcher { + state: Arc>, + new_states: broadcast::Sender, +} + +impl PlayerctlStateWatcher { + pub fn new(initial_state: S, subcommand: &'static str, parse_state: impl 'static + Fn(&String) -> S + Send) -> Self { + let state = Arc::new(RwLock::new(initial_state)); + let cloned_state = Arc::clone(&state); + + let new_states = broadcast::Sender::new(1); + let cloned_new_states = new_states.clone(); + + thread::spawn(move || { + loop { + let players = std::process::Command::new("playerctl") + .args(["-s", "-l"]) + .stderr(Stdio::null()) + .stdin(Stdio::null()) + .output() + .unwrap(); + + if players.stdout.iter().filter(|c| **c == b'\n').count() > 0 { + break; + } + + sleep(Duration::from_secs(1)); + } + + let command = std::process::Command::new("playerctl") + .args(["-s", "-F", subcommand]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .stdin(Stdio::null()) + .spawn() + .unwrap(); + + let stdout = BufReader::new(command.stdout.unwrap()); + + for line in stdout.lines() { + let line = line.unwrap(); + let new_state = parse_state(&line); + + { + let mut g = cloned_state.write().unwrap(); + *g = new_state.clone(); + if cloned_new_states.send(new_state).is_err() { + warn!("No one is listening to player state changes") + } + } + } + }); + + PlayerctlStateWatcher { state, new_states } + } + + fn subscribe_to_state(&self) -> broadcast::Receiver { + let r = self.new_states.subscribe(); + self.new_states.send(self.state.read().unwrap().clone()).unwrap(); + r + } +} + +static STATE_WATCHER_PLAYING: Lazy> = Lazy::new(|| { + PlayerctlStateWatcher::new(ButtonState::Inactive, "status", |s| match s.as_ref() { + "Playing" => ButtonState::Playing, + "Paused" => ButtonState::Paused, + "" | "Stopped" => ButtonState::Inactive, + _ => panic!("Unknown state: {}", s), + }) +}); + +static STATE_WATCHER_SHUFFLE: Lazy> = Lazy::new(|| { + PlayerctlStateWatcher::new(ShuffleState::Inactive, "shuffle", |s| match s.as_ref() { + "Off" => ShuffleState::Off, + "On" => ShuffleState::On, + "" => ShuffleState::Inactive, + _ => panic!("Unknown state: {}", s), + }) +}); + +static STATE_WATCHER_LOOP: Lazy> = Lazy::new(|| { + PlayerctlStateWatcher::new(LoopState::Inactive, "loop", |s| match s.as_ref() { + "Track" => LoopState::Single, + "Playlist" => LoopState::All, + "None" => LoopState::None, + "" => LoopState::Inactive, + _ => panic!("Unknown state: {}", s), + }) +}); + +pub async fn handle_button(path: KeyPath, config: Arc, mut events: broadcast::Receiver, commands: flume::Sender) { + let mut is_active = false; + let mut state = STATE_WATCHER_PLAYING.subscribe_to_state(); + + let command = match config.command { + ButtonCommand::PlayPause => "play-pause", + ButtonCommand::Play => "play", + ButtonCommand::Pause => "pause", + ButtonCommand::Previous => "previous", + ButtonCommand::Next => "next", + }; + + loop { + select! { + result = state.recv() => { + match result { + Err(RecvError::Closed) => { result.unwrap(); }, + Err(RecvError::Lagged(_)) => { /* mission failed, we'll get 'em next time */ }, + Ok(state) => { + is_active = state != ButtonState::Inactive; + + commands.send(IoWorkerCommand::SetKeyStyle { + path: path.clone(), + value: config.style.get(&state).cloned() + }).unwrap(); + } + } + } + + Ok(event) = events.recv() => { + if event == KeyEvent::Press && is_active { + let status = std::process::Command::new("playerctl").arg(command).status().unwrap(); + + if !status.success() { + error!("`playerctl {}` failed with exit code {}", command, status) + } + } + } + } + } +} + +pub async fn handle_shuffle(path: KeyPath, config: Arc, mut events: broadcast::Receiver, commands: flume::Sender) { + let mut state = STATE_WATCHER_SHUFFLE.subscribe_to_state(); + + loop { + select! { + result = state.recv() => { + match result { + Err(RecvError::Closed) => { result.unwrap(); }, + Err(RecvError::Lagged(_)) => { /* mission failed, we'll get 'em next time */ }, + Ok(state) => { + commands.send(IoWorkerCommand::SetKeyStyle { + path: path.clone(), + value: config.style.get(&state).cloned() + }).unwrap(); + } + } + } + + Ok(event) = events.recv() => { + if event == KeyEvent::Press { + let current = *STATE_WATCHER_SHUFFLE.state.read().unwrap(); + let new = match current { + ShuffleState::Inactive | ShuffleState::Off => "On", + ShuffleState::On => "Off", + }; + + let status = std::process::Command::new("playerctl").args(["shuffle", new]).status().unwrap(); + + if !status.success() { + error!("`playerctl shuffle {}` failed with exit code {}", new, status) + } + } + } + } + } +} + +pub async fn handle_loop(path: KeyPath, config: Arc, mut events: broadcast::Receiver, commands: flume::Sender) { + let mut state = STATE_WATCHER_LOOP.subscribe_to_state(); + + loop { + select! { + result = state.recv() => { + match result { + Err(RecvError::Closed) => { result.unwrap(); }, + Err(RecvError::Lagged(_)) => { /* mission failed, we'll get 'em next time */ }, + Ok(state) => { + commands.send(IoWorkerCommand::SetKeyStyle { + path: path.clone(), + value: config.style.get(&state).cloned() + }).unwrap(); + } + } + } + + Ok(event) = events.recv() => { + if event == KeyEvent::Press { + let current = *STATE_WATCHER_LOOP.state.read().unwrap(); + let new = match current { + LoopState::Inactive | LoopState::None => "Playlist", + LoopState::All => "Track", + LoopState::Single => "None", + }; + + let status = std::process::Command::new("playerctl").args(["loop", new]).status().unwrap(); + + if !status.success() { + error!("`playerctl loop {}` failed with exit code {}", new, status) + } + } + } + } + } +} diff --git a/deckster/src/modes/key/spotify.rs b/deckster/src/modes/key/spotify.rs deleted file mode 100644 index b2b14fd..0000000 --- a/deckster/src/modes/key/spotify.rs +++ /dev/null @@ -1,30 +0,0 @@ -use serde::Deserialize; - -use crate::model::key_page::StyleByStateMap; - -#[derive(Debug, Deserialize)] -pub struct ShuffleConfig { - #[serde(default)] - pub style: StyleByStateMap, -} - -#[derive(Debug, Deserialize)] -pub struct RepeatConfig { - #[serde(default)] - pub style: StyleByStateMap, -} - -#[derive(Debug, Eq, PartialEq, Hash, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum ShuffleState { - Inactive, - Active, -} - -#[derive(Debug, Eq, PartialEq, Hash, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum RepeatState { - Inactive, - Single, - All, -} diff --git a/deckster/src/modes/key/vibrate.rs b/deckster/src/modes/key/vibrate.rs index 928ba84..1bff1ac 100644 --- a/deckster/src/modes/key/vibrate.rs +++ b/deckster/src/modes/key/vibrate.rs @@ -1,39 +1,25 @@ +use std::sync::Arc; + use serde::Deserialize; +use tokio::sync::broadcast; + +use loupedeck_serial::commands::VibrationPattern; + +use crate::model::position::KeyPath; +use crate::modes::key::{KeyEvent, KeyTouchEventKind}; +use crate::runner::command::IoWorkerCommand; #[derive(Debug, Deserialize)] pub struct Config { pub pattern: VibrationPattern, } -#[derive(Debug, Eq, PartialEq, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum VibrationPattern { - Short, - Medium, - Long, - Low, - ShortLow, - ShortLower, - Lower, - Lowest, - DescendSlow, - DescendMed, - DescendFast, - AscendSlow, - AscendMed, - AscendFast, - RevSlowest, - RevSlow, - RevMed, - RevFast, - RevFaster, - RevFastest, - RiseFall, - Buzz, - Rumble5, - Rumble4, - Rumble3, - Rumble2, - Rumble1, - VeryLong, +pub async fn handle(_: KeyPath, config: Arc, mut events: broadcast::Receiver, commands: flume::Sender) { + while let Ok(event) = events.recv().await { + if let KeyEvent::Touch { kind, .. } = event { + if kind == KeyTouchEventKind::Start { + commands.send(IoWorkerCommand::Vibrate { pattern: config.pattern }).unwrap(); + } + } + } } diff --git a/deckster/src/runner/command.rs b/deckster/src/runner/command.rs new file mode 100644 index 0000000..31e284b --- /dev/null +++ b/deckster/src/runner/command.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; + +use loupedeck_serial::commands::VibrationPattern; + +use crate::model::key_page::KeyStyle; +use crate::model::position::KeyPath; + +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +pub enum IoWorkerCommand { + Vibrate { pattern: VibrationPattern }, + SetActivePages { key_page_id: String, knob_page_id: String }, + SetKeyStyle { path: KeyPath, value: Option }, +} diff --git a/deckster/src/runner/mod.rs b/deckster/src/runner/mod.rs index 1a73d53..99519bc 100644 --- a/deckster/src/runner/mod.rs +++ b/deckster/src/runner/mod.rs @@ -1,5 +1,5 @@ use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::Path; use std::sync::Arc; use std::thread; @@ -9,11 +9,12 @@ use color_eyre::eyre::{ContextCompat, WrapErr}; use color_eyre::Result; use enum_map::EnumMap; use enum_ordinalize::Ordinalize; -use log::{debug, info, trace}; +use log::{info, trace}; use rgb::RGB8; use tiny_skia::IntSize; use tokio::sync::broadcast; +use command::IoWorkerCommand; use loupedeck_serial::characteristics::{LoupedeckButton, LoupedeckDeviceKeyGridCharacteristics}; use loupedeck_serial::commands::VibrationPattern; use loupedeck_serial::device::LoupedeckDevice; @@ -23,11 +24,12 @@ use crate::icons::{get_used_icon_descriptors, load_icons, LoadedIconsMap}; use crate::model; use crate::model::knob_page::KnobStyle; use crate::model::position::{ButtonPosition, KeyPath, KeyPosition, KnobPath}; -use crate::modes::key::{start_handlers, KeyEvent}; +use crate::modes::key::{start_handlers, KeyEvent, KeyTouchEventKind}; use crate::runner::graphics::labels::LabelRenderer; use crate::runner::graphics::{render_key, GraphicsContext}; -use crate::runner::state::{Key, State, StateChangeCommand}; +use crate::runner::state::{Key, State}; +pub mod command; mod graphics; pub mod state; @@ -53,11 +55,11 @@ pub async fn start(config_directory: &Path, config: model::config::Config) -> Re device.set_brightness(0.5); device.vibrate(VibrationPattern::RiseFall); - let (commands_sender, commands_receiver) = flume::bounded::(20); + let (commands_sender, commands_receiver) = flume::bounded::(20); let key_events_sender: broadcast::Sender<(KeyPath, KeyEvent)> = broadcast::Sender::new(20); commands_sender - .send(StateChangeCommand::SetActivePages { + .send(IoWorkerCommand::SetActivePages { knob_page_id: config.initial.knob_page.clone(), key_page_id: config.initial.key_page.clone(), }) @@ -157,7 +159,7 @@ fn create_state(config: &model::config::Config) -> State { enum IoWork { Event(LoupedeckEvent), - Command(StateChangeCommand), + Command(IoWorkerCommand), } struct IoWorkerContext { @@ -165,6 +167,7 @@ struct IoWorkerContext { device: LoupedeckDevice, state: State, graphics: GraphicsContext, + active_touch_ids: HashSet, } fn do_io_work( @@ -172,8 +175,8 @@ fn do_io_work( icons: LoadedIconsMap, device: LoupedeckDevice, key_events_sender: broadcast::Sender<(KeyPath, KeyEvent)>, - commands_sender: flume::Sender, - commands_receiver: flume::Receiver, + commands_sender: flume::Sender, + commands_receiver: flume::Receiver, ) { let state = create_state(&config); let buffer_endianness = device.characteristics().key_grid.display.endianness; @@ -197,6 +200,7 @@ fn do_io_work( label_renderer, global_icon_filter_by_pack_id, }, + active_touch_ids: HashSet::new(), }; loop { @@ -207,7 +211,7 @@ fn do_io_work( match a { IoWork::Event(event) => { - if !handle_event(&context, &commands_sender, &key_events_sender, event) { + if !handle_event(&mut context, &commands_sender, &key_events_sender, event) { break; } } @@ -217,8 +221,8 @@ fn do_io_work( } fn handle_event( - context: &IoWorkerContext, - commands_sender: &flume::Sender, + context: &mut IoWorkerContext, + commands_sender: &flume::Sender, key_events_sender: &broadcast::Sender<(KeyPath, KeyEvent)>, event: LoupedeckEvent, ) -> bool { @@ -236,32 +240,56 @@ fn handle_event( let button_config = &context.config.buttons[position]; commands_sender - .send(StateChangeCommand::SetActivePages { + .send(IoWorkerCommand::SetActivePages { key_page_id: button_config.key_page.as_ref().unwrap_or(&context.state.active_key_page_id).clone(), knob_page_id: button_config.knob_page.as_ref().unwrap_or(&context.state.active_knob_page_id).clone(), }) .unwrap() } - LoupedeckEvent::Touch { x, y, is_end, .. } => { - if is_end { - let characteristics = context.device.characteristics(); - let display = characteristics.get_display_at_coordinates(x, y); + LoupedeckEvent::Touch { x, y, is_end, touch_id } => { + let characteristics = context.device.characteristics(); + let display = characteristics.get_display_at_coordinates(x, y); - if let Some(display) = display { - if display.name == characteristics.key_grid.display.name { - let key_index = characteristics.key_grid.get_key_at_global_coordinates(x, y); - if let Some(key_index) = key_index { - let position = KeyPosition { - x: (key_index % characteristics.key_grid.columns) as u16 + 1, - y: (key_index / characteristics.key_grid.columns) as u16 + 1, - }; + if let Some(display) = display { + if display.name == characteristics.key_grid.display.name { + let key_index = characteristics.key_grid.get_key_at_global_coordinates(x, y); + if let Some(key_index) = key_index { + let position = KeyPosition { + x: (key_index % characteristics.key_grid.columns) as u16 + 1, + y: (key_index / characteristics.key_grid.columns) as u16 + 1, + }; - let path = KeyPath { - page_id: context.state.active_key_page_id.clone(), - position, - }; + let path = KeyPath { + page_id: context.state.active_key_page_id.clone(), + position, + }; - send_key_event(path, KeyEvent::Press); + let (top_left_x, top_left_y, _, _) = characteristics.key_grid.get_local_key_rect_xywh(key_index).unwrap(); + + let kind = if is_end { + context.active_touch_ids.remove(&touch_id); + KeyTouchEventKind::End + } else { + let is_new = context.active_touch_ids.insert(touch_id); + if is_new { + KeyTouchEventKind::Start + } else { + KeyTouchEventKind::Move + } + }; + + send_key_event( + path.clone(), + KeyEvent::Touch { + touch_id, + x: x - top_left_x, + y: y - top_left_y, + kind, + }, + ); + + if kind == KeyTouchEventKind::Start { + send_key_event(path.clone(), KeyEvent::Press); } } } @@ -273,11 +301,14 @@ fn handle_event( true } -fn handle_command(context: &mut IoWorkerContext, command: StateChangeCommand) { - debug!("Handling command: {:?}", &command); +fn handle_command(context: &mut IoWorkerContext, command: IoWorkerCommand) { + trace!("Handling command: {:?}", &command); match command { - StateChangeCommand::SetActivePages { key_page_id, knob_page_id } => { + IoWorkerCommand::Vibrate { pattern } => { + context.device.vibrate(pattern); + } + IoWorkerCommand::SetActivePages { key_page_id, knob_page_id } => { context.state.active_key_page_id = key_page_id; context.state.active_knob_page_id = knob_page_id; @@ -294,7 +325,7 @@ fn handle_command(context: &mut IoWorkerContext, command: StateChangeCommand) { context.device.refresh_display(&key_grid.display).unwrap(); } - StateChangeCommand::SetKeyStyle { path, value } => { + IoWorkerCommand::SetKeyStyle { path, value } => { context.state.mutate_key_for_command("SetKeyStyle", &path, |k| { k.style = value; }); diff --git a/deckster/src/runner/state.rs b/deckster/src/runner/state.rs index 10b5e64..35febfc 100644 --- a/deckster/src/runner/state.rs +++ b/deckster/src/runner/state.rs @@ -2,7 +2,6 @@ use std::collections::HashMap; use enum_map::EnumMap; use log::error; -use serde::{Deserialize, Serialize}; use crate::model::key_page::KeyStyle; use crate::model::knob_page::KnobStyle; @@ -29,10 +28,6 @@ impl State { None } - pub fn get_key(&self, path: &KeyPath) -> &Key { - &self.key_pages_by_id[&path.page_id].keys_by_position[&path.position] - } - pub fn active_key_page(&self) -> &KeyPage { &self.key_pages_by_id[&self.active_key_page_id] } @@ -68,10 +63,3 @@ pub struct Knob { pub style: KnobStyle, pub value: f32, } - -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] -#[allow(clippy::enum_variant_names)] -pub enum StateChangeCommand { - SetActivePages { key_page_id: String, knob_page_id: String }, - SetKeyStyle { path: KeyPath, value: Option }, -} diff --git a/loupedeck_serial/Cargo.toml b/loupedeck_serial/Cargo.toml index fcbc3a3..5c5a5e6 100644 --- a/loupedeck_serial/Cargo.toml +++ b/loupedeck_serial/Cargo.toml @@ -10,4 +10,5 @@ enumset = "1.1.3" bytes = "1.5.0" thiserror = "1.0.52" rgb = "0.8.37" -flume = "0.11.0" \ No newline at end of file +flume = "0.11.0" +serde = { version = "1.0.195", features = ["derive"] } \ No newline at end of file diff --git a/loupedeck_serial/src/commands.rs b/loupedeck_serial/src/commands.rs index 9d3580f..d8a2b4e 100644 --- a/loupedeck_serial/src/commands.rs +++ b/loupedeck_serial/src/commands.rs @@ -1,11 +1,13 @@ use bytes::Bytes; use enum_ordinalize::Ordinalize; use rgb::RGB8; +use serde::{Deserialize, Serialize}; use crate::characteristics::LoupedeckButton; -#[derive(Debug, Ordinalize)] +#[derive(Debug, Eq, PartialEq, Clone, Copy, Serialize, Deserialize, Ordinalize)] #[repr(u8)] +#[serde(rename_all = "kebab-case")] pub enum VibrationPattern { Short = 0x01, Medium = 0x0a,