commit
This commit is contained in:
parent
ebb0552a13
commit
bf48f12c8c
5 changed files with 370 additions and 2 deletions
14
Cargo.lock
generated
14
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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);
|
||||
|
|
14
handlers/playerctl/Cargo.toml
Normal file
14
handlers/playerctl/Cargo.toml
Normal file
|
@ -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"
|
318
handlers/playerctl/src/handler.rs
Normal file
318
handlers/playerctl/src/handler.rs
Normal file
|
@ -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<ButtonState>,
|
||||
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<ShuffleState>,
|
||||
}
|
||||
|
||||
#[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<LoopState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum LoopState {
|
||||
Inactive,
|
||||
None,
|
||||
Single,
|
||||
All,
|
||||
}
|
||||
|
||||
struct PlayerctlStateWatcher<S: Clone + Debug + Send + Sync + 'static> {
|
||||
state: Arc<RwLock<S>>,
|
||||
new_states: broadcast::Sender<S>,
|
||||
}
|
||||
|
||||
impl<S: Clone + Debug + Send + Sync + 'static> PlayerctlStateWatcher<S> {
|
||||
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<S> {
|
||||
let r = self.new_states.subscribe();
|
||||
self.new_states.send(self.state.read().unwrap().clone()).unwrap();
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
static STATE_WATCHER_PLAYING: Lazy<PlayerctlStateWatcher<ButtonState>> = 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<PlayerctlStateWatcher<ShuffleState>> = 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<PlayerctlStateWatcher<LoopState>> = 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<ButtonConfig>, mut events: broadcast::Receiver<KeyEvent>) {
|
||||
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<ShuffleConfig>, mut events: broadcast::Receiver<KeyEvent>) {
|
||||
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<LoopConfig>, mut events: broadcast::Receiver<KeyEvent>) {
|
||||
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<Self, HandlerInitializationError> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
23
handlers/playerctl/src/main.rs
Normal file
23
handlers/playerctl/src/main.rs
Normal file
|
@ -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(())
|
||||
}
|
Loading…
Add table
Reference in a new issue