diff --git a/Cargo.lock b/Cargo.lock index 2f9fc41..198c150 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1161,6 +1161,20 @@ version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69d3587f8a9e599cc7ec2c00e331f71c4e69a5f9a4b8a6efd5b07466b9736f9a" +[[package]] +name = "playerctl" +version = "0.1.0" +dependencies = [ + "clap", + "color-eyre", + "deckster_mode", + "env_logger", + "log", + "once_cell", + "serde", + "tokio", +] + [[package]] name = "png" version = "0.17.10" diff --git a/handlers/pa_volume/src/handler.rs b/handlers/pa_volume/src/handler.rs index 7f57c00..0505092 100644 --- a/handlers/pa_volume/src/handler.rs +++ b/handlers/pa_volume/src/handler.rs @@ -4,7 +4,6 @@ use log::warn; use parse_display::Display; use regex::Regex; use serde::Deserialize; -use tokio::select; use tokio::sync::broadcast; use deckster_mode::shared::handler_communication::{ @@ -246,7 +245,7 @@ async fn manage_knob(path: KnobPath, config: KnobConfig, mut events: broadcast:: } loop { - select! { + tokio::select! { Ok(volume_state) = volume_states.recv() => { entity_state = volume_state.entities_by_id().values().find(|entity| state_matches(&config.target, entity)).map(Arc::clone); update_knob_style(&entity_state); diff --git a/handlers/playerctl/Cargo.toml b/handlers/playerctl/Cargo.toml new file mode 100644 index 0000000..e1ccfb6 --- /dev/null +++ b/handlers/playerctl/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "playerctl" +version = "0.1.0" +edition = "2021" + +[dependencies] +deckster_mode = { path = "../../crates/deckster_mode" } +clap = { version = "4.4.18", features = ["derive"] } +color-eyre = "0.6.2" +serde = { version = "1.0.196", features = ["derive"] } +env_logger = "0.11.1" +log = "0.4.20" +tokio = { version = "1.35.1", features = ["macros", "parking_lot", "rt-multi-thread", "sync"] } +once_cell = "1.19.0" \ No newline at end of file diff --git a/handlers/playerctl/src/handler.rs b/handlers/playerctl/src/handler.rs new file mode 100644 index 0000000..7729716 --- /dev/null +++ b/handlers/playerctl/src/handler.rs @@ -0,0 +1,318 @@ +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::sync::broadcast; +use tokio::sync::broadcast::error::RecvError; + +use deckster_mode::shared::handler_communication::{HandlerCommand, HandlerEvent, InitialHandlerMessage, KeyEvent}; +use deckster_mode::shared::path::KeyPath; +use deckster_mode::shared::state::KeyStyleByStateMap; +use deckster_mode::{send_command, DecksterHandler}; + +#[derive(Debug, Deserialize)] +pub struct ButtonConfig { + #[serde(default)] + pub style: KeyStyleByStateMap, + 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: KeyStyleByStateMap, +} + +#[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: KeyStyleByStateMap, +} + +#[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) { + 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 { + tokio::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; + + send_command(HandlerCommand::SetKeyStyle { + path: path.clone(), + value: config.style.get(&state).cloned() + }); + } + } + } + + 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) { + let mut state = STATE_WATCHER_SHUFFLE.subscribe_to_state(); + + loop { + tokio::select! { + result = state.recv() => { + match result { + Err(RecvError::Closed) => { result.unwrap(); }, + Err(RecvError::Lagged(_)) => { /* mission failed, we'll get 'em next time */ }, + Ok(state) => { + send_command(HandlerCommand::SetKeyStyle { + path: path.clone(), + value: config.style.get(&state).cloned() + }) + } + } + } + + 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) { + let mut state = STATE_WATCHER_LOOP.subscribe_to_state(); + + loop { + tokio::select! { + result = state.recv() => { + match result { + Err(RecvError::Closed) => { result.unwrap(); }, + Err(RecvError::Lagged(_)) => { /* mission failed, we'll get 'em next time */ }, + Ok(state) => { + send_command(HandlerCommand::SetKeyStyle { + path: path.clone(), + value: config.style.get(&state).cloned() + }) + } + } + } + + 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) + } + } + } + } + } +} + +pub struct Handler { + events_sender: broadcast::Sender<(KeyPath, KeyEvent)>, + #[allow(unused)] + runtime: tokio::runtime::Runtime, +} + +impl Handler { + pub fn new(data: InitialHandlerMessage<()>) -> Result { + let (events_sender, _) = broadcast::channel::<(KnobPath, KnobEvent)>(5); + let pa_volume_interface = Arc::new(PaVolumeInterface::spawn_thread("deckster handler".to_owned())); + + let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(1).build().unwrap(); + + for (path, (mode, config)) in data.knob_configs { + if !mode.is_empty() { + return Err(HandlerInitializationError::InvalidModeString { + message: "No mode string allowed.".into(), + }); + } + + let events_receiver = events_sender.subscribe(); + let a = Arc::clone(&pa_volume_interface); + runtime.spawn(manage_knob(path, config, events_receiver, a)); + } + + Ok(Handler { events_sender, runtime }) + } +} + +impl DecksterHandler for Handler { + fn handle(&mut self, event: HandlerEvent) { + if let HandlerEvent::Knob { path, event } = event { + self.events_sender.send((path, event)).unwrap(); + } + } +} diff --git a/handlers/playerctl/src/main.rs b/handlers/playerctl/src/main.rs new file mode 100644 index 0000000..ffc9844 --- /dev/null +++ b/handlers/playerctl/src/main.rs @@ -0,0 +1,23 @@ +use clap::Parser; +use color_eyre::Result; + +mod handler; + +#[derive(Debug, Parser)] +#[command(name = "playerctl")] +enum CliCommand { + #[command(name = "deckster-run", hide = true)] + Run, +} + +fn main() -> Result<()> { + let command = CliCommand::parse(); + + match command { + CliCommand::Run => { + deckster_mode::run(Handler::new)?; + } + } + + Ok(()) +}