From 00bb5d778e66c0481dad6f0d57948b71a0652f49 Mon Sep 17 00:00:00 2001 From: Toby Vincent Date: Thu, 25 Jul 2024 21:25:25 -0500 Subject: feat: refactor into wrapper around i3blocks --- Cargo.lock | 136 +++++++++++++++++++++++++++ Cargo.toml | 7 +- contrib/i3blocks.config | 8 ++ contrib/mpris_click.json | 1 + src/component.rs | 232 ----------------------------------------------- src/component/icon.rs | 54 ----------- src/component/next.rs | 77 ---------------- src/component/play.rs | 100 -------------------- src/component/prev.rs | 77 ---------------- src/component/title.rs | 93 ------------------- src/component/volume.rs | 82 ----------------- src/dbus/player.rs | 159 +++++++++++++++++++++++++++++++- src/error.rs | 18 ---- src/i3bar.rs | 24 ++--- src/lib.rs | 24 ++++- src/listener.rs | 184 +++++++++++++++++++++++++++++++++++++ src/main.rs | 82 ++++++++++++++--- 17 files changed, 591 insertions(+), 767 deletions(-) create mode 100644 contrib/i3blocks.config create mode 100644 contrib/mpris_click.json delete mode 100644 src/component.rs delete mode 100644 src/component/icon.rs delete mode 100644 src/component/next.rs delete mode 100644 src/component/play.rs delete mode 100644 src/component/prev.rs delete mode 100644 src/component/title.rs delete mode 100644 src/component/volume.rs create mode 100644 src/listener.rs diff --git a/Cargo.lock b/Cargo.lock index f51a9ae..56647a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -250,6 +250,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + [[package]] name = "digest" version = "0.10.7" @@ -330,6 +340,15 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-core" version = "0.3.30" @@ -453,10 +472,23 @@ dependencies = [ "serde", "serde_json", "thiserror", + "time", "tokio", + "tokio-stream", + "url", "zbus", ] +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "indexmap" version = "2.2.6" @@ -539,6 +571,12 @@ dependencies = [ "memoffset", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num_cpus" version = "1.16.0" @@ -580,6 +618,12 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + [[package]] name = "pin-project-lite" version = "0.2.14" @@ -618,6 +662,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -836,6 +886,52 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.38.0" @@ -866,6 +962,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-stream" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml_datetime" version = "0.6.6" @@ -931,12 +1038,39 @@ dependencies = [ "winapi", ] +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + [[package]] name = "unicode-ident" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unicode-normalization" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "url" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "version_check" version = "0.9.4" @@ -1196,6 +1330,8 @@ dependencies = [ "enumflags2", "serde", "static_assertions", + "time", + "url", "zvariant_derive", ] diff --git a/Cargo.toml b/Cargo.toml index fe34a4b..6c3ebf3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,5 +9,8 @@ main_error = "0.1.2" serde = { version = "1.0.203", features = ["derive"] } serde_json = "1.0.120" thiserror = "1.0.61" -tokio = { version = "1.38.0", features = ["macros", "rt-multi-thread"] } -zbus = { version = "4.3.0", default-features = false, features = ["tokio"] } +time = { version = "0.3.36", features = ["serde-human-readable"] } +tokio = { version = "1.38.0", features = ["io-std", "io-util", "macros", "process", "rt-multi-thread"] } +tokio-stream = "0.1.15" +url = "2.5.2" +zbus = { version = "4.3.0", default-features = false, features = ["time", "tokio", "url"] } diff --git a/contrib/i3blocks.config b/contrib/i3blocks.config new file mode 100644 index 0000000..87f7a5e --- /dev/null +++ b/contrib/i3blocks.config @@ -0,0 +1,8 @@ +# vim: set ft=confini: + +[mpris] +full_text= + +[time] +command=date +" %a %m/%d %T " +interval=1 diff --git a/contrib/mpris_click.json b/contrib/mpris_click.json new file mode 100644 index 0000000..821f6f2 --- /dev/null +++ b/contrib/mpris_click.json @@ -0,0 +1 @@ +{"":"","interval":"persist","name":"clickme","full_text":"Click me!","command":"tee /tmp/mpris_click.json","format":"json","button":1,"event":272,"x":1866,"y":13,"relative_x":50,"relative_y":13,"width":90,"height":25,"scale":1} diff --git a/src/component.rs b/src/component.rs deleted file mode 100644 index e06f2cb..0000000 --- a/src/component.rs +++ /dev/null @@ -1,232 +0,0 @@ -use std::{ - future::Future, - io::{BufReader, Read, Write}, - marker::Send, - sync::Arc, -}; - -use tokio::{ - sync::{mpsc::Sender, Mutex}, - task::JoinSet, -}; -use zbus::Connection; - -use crate::{ - dbus::{ - player::{PlaybackStatus, PlayerProxy}, - playerctld::PlayerctldProxy, - }, - i3bar::{Block, Click}, - Error, IGNORED, -}; - -pub use icon::Icon; -pub use next::Next; -pub use play::Play; -pub use prev::Prev; -pub use title::Title; -pub use volume::Volume; - -mod icon; -mod next; -mod play; -mod prev; -mod title; -mod volume; - -pub trait Component: Send + 'static { - type Updater: Update; - type Colorer: Update; - type Handler: Button; - - fn initialize() -> Block; -} - -pub trait Runner: Component + private::Sealed { - fn run(reader: R) -> impl Future> + Send { - async move { - use std::io::BufRead; - - let conn = Connection::session().await?; - - let writer = std::io::stdout(); - - let listeners = tokio::spawn(Self::listeners(conn.clone(), writer)); - let buf_reader = BufReader::new(reader); - - for click in buf_reader - .lines() - .map_while(Result::ok) - .flat_map(|s| serde_json::from_str::(&s)) - { - let _ = ::handle(conn.clone(), click).await; - } - - listeners.await? - } - } - - fn listeners( - conn: Connection, - mut writer: W, - ) -> impl Future> + Send { - async move { - let mut join_set = JoinSet::new(); - - let (tx_player, mut rx_player) = tokio::sync::mpsc::channel(128); - let (tx_status, mut rx_status) = tokio::sync::mpsc::channel(128); - let (tx_value, mut rx_value) = tokio::sync::mpsc::channel(128); - - let block = Arc::new(Mutex::new(Self::initialize())); - - tokio::spawn(Self::active_listener(conn.clone(), tx_player)); - - loop { - let output = tokio::select! { - Some(name) = rx_player.recv() => { - join_set.shutdown().await; - - let mut block = block.lock().await; - *block = Self::initialize(); - if !name.is_empty() { - block.instance.clone_from(&Some(name.clone())); - let proxy = PlayerProxy::builder(&conn) - .destination(name.clone())? - .build() - .await?; - join_set.spawn(::listen(tx_status.clone(), proxy.clone())); - join_set.spawn(::listen(tx_value.clone(), proxy)); - } - Output::Clear - } - Some(color) = rx_status.recv() => ::update(color, block.clone()).await?, - Some(value) = rx_value.recv() => ::update(value, block.clone()).await? - }; - - match output { - Output::Print => (block.lock().await).write_to(&mut writer)?, - Output::Clear => writer.write_all(&[b'\n'])?, - Output::Skip => continue, - } - - writer.flush()?; - } - } - } - - fn active_listener( - conn: Connection, - tx: Sender, - ) -> impl std::future::Future> + Send { - use futures_util::StreamExt; - - async move { - let proxy = PlayerctldProxy::builder(&conn).build().await?; - let mut last = String::new(); - let mut stream = proxy.receive_player_names_changed().await; - while let Some(signal) = stream.next().await { - let name = signal - .get() - .await? - .into_iter() - .find(|s| s.split('.').nth(3).is_some_and(|s| !IGNORED.contains(&s))) - .unwrap_or_default(); - if name != last { - last.clone_from(&name); - tx.send(name).await?; - } - } - Ok(()) - } - } -} - -impl Runner for T {} - -mod private { - pub trait Sealed {} - - impl Sealed for T {} -} - -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub enum Output { - Print, - Clear, - #[default] - Skip, -} - -pub trait Update: Send + 'static { - type Value: Send; - - fn listen( - tx: Sender, - proxy: PlayerProxy<'_>, - ) -> impl Future> + Send; - - fn update( - value: Self::Value, - block: Arc>, - ) -> impl Future> + Send; -} - -impl Update for () { - type Value = (); - - async fn listen(_: Sender, _: PlayerProxy<'_>) -> Result<(), Error> { - Ok(()) - } - - async fn update(_: Self::Value, _: Arc>) -> Result { - Ok(Output::Skip) - } -} - -impl Update for PlaybackStatus { - type Value = (Option, Option); - - async fn listen(tx: Sender, proxy: PlayerProxy<'_>) -> Result<(), Error> { - use futures_util::StreamExt; - - let black = std::env::var("BASE16_COLOR_00_HEX").ok(); - let cyan = std::env::var("BASE16_COLOR_0C_HEX").ok(); - let yellow = std::env::var("BASE16_COLOR_0A_HEX").ok(); - - let mut stream = proxy.receive_playback_status_changed().await; - while let Some(signal) = stream.next().await { - if let Ok(value) = signal.get().await { - let val = match value { - PlaybackStatus::Playing => (black.clone(), cyan.clone()), - PlaybackStatus::Paused => (black.clone(), yellow.clone()), - PlaybackStatus::Stopped => (None, None), - }; - tx.send(val).await?; - } - } - Ok(()) - } - - async fn update( - (color, background): Self::Value, - block: Arc>, - ) -> Result { - let mut block = block.lock().await; - block.color = color; - block.background = background; - Ok(Output::Print) - } -} - -pub trait Button { - fn handle( - conn: zbus::Connection, - click: crate::i3bar::Click, - ) -> impl Future> + Send; -} - -impl Button for () { - async fn handle(_: zbus::Connection, _: crate::i3bar::Click) -> Result<(), Error> { - Ok(()) - } -} diff --git a/src/component/icon.rs b/src/component/icon.rs deleted file mode 100644 index 287c655..0000000 --- a/src/component/icon.rs +++ /dev/null @@ -1,54 +0,0 @@ -use zbus::Connection; - -use crate::{ - dbus::{media_player2::MediaPlayer2Proxy, player::PlaybackStatus, playerctld::PlayerctldProxy}, - i3bar::{Block, Click}, - Error, -}; - -use super::{Button, Component}; - -pub struct Icon; - -impl Component for Icon { - type Updater = (); - type Colorer = PlaybackStatus; - type Handler = Self; - - fn initialize() -> Block { - Block { - name: Some("mpris-icon".into()), - full_text: " 󰝚 ".into(), - separator: Some(false), - separator_block_width: Some(0), - ..Default::default() - } - } -} - -impl Button for Icon { - async fn handle(conn: Connection, click: Click) -> Result<(), Error> { - let Some(name) = click.instance else { - return Ok(()); - }; - - let proxy = MediaPlayer2Proxy::builder(&conn) - .destination(name)? - .build() - .await?; - - match click.button { - 3 => { - PlayerctldProxy::builder(&conn) - .build() - .await? - .shift() - .await?; - } - 1 if proxy.can_raise().await? => proxy.raise().await?, - _ => {} - } - - Ok(()) - } -} diff --git a/src/component/next.rs b/src/component/next.rs deleted file mode 100644 index 52e72ff..0000000 --- a/src/component/next.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::sync::Arc; - -use tokio::sync::{mpsc::Sender, Mutex}; -use zbus::Connection; - -use crate::{ - dbus::player::{PlaybackStatus, PlayerProxy}, - i3bar::{Align, Block, Click, MinWidth}, - Error, -}; - -use super::{Button, Component, Output, Update}; - -pub struct Next; - -impl Component for Next { - type Updater = Self; - type Colorer = PlaybackStatus; - type Handler = Self; - - fn initialize() -> Block { - Block { - name: Some("mpris-next".into()), - full_text: '󰒭'.into(), - min_width: Some(MinWidth::Text("xx".to_string())), - align: Align::Center, - separator: Some(false), - separator_block_width: Some(0), - ..Default::default() - } - } -} - -impl Update for Next { - type Value = bool; - - async fn listen(tx: Sender, proxy: PlayerProxy<'_>) -> Result<(), Error> { - use futures_util::StreamExt; - - let mut stream = proxy.receive_can_go_next_changed().await; - while let Some(signal) = stream.next().await { - if let Ok(value) = signal.get().await { - tx.send(value).await?; - } - } - Ok(()) - } - - async fn update(value: Self::Value, _: Arc>) -> Result { - if value { - Ok(Output::Print) - } else { - Ok(Output::Clear) - } - } -} - -impl Button for Next { - async fn handle(conn: Connection, click: Click) -> Result<(), Error> { - let Some(name) = click.instance else { - return Ok(()); - }; - - let proxy = PlayerProxy::builder(&conn) - .destination(name)? - .build() - .await?; - - match click.button { - 1 if proxy.can_go_next().await? => proxy.next().await?, - 3 if proxy.can_seek().await? => proxy.seek(5000).await?, - _ => {} - } - - Ok(()) - } -} diff --git a/src/component/play.rs b/src/component/play.rs deleted file mode 100644 index 512bc77..0000000 --- a/src/component/play.rs +++ /dev/null @@ -1,100 +0,0 @@ -use std::sync::Arc; - -use tokio::sync::{mpsc::Sender, Mutex}; -use zbus::Connection; - -use crate::{ - dbus::player::{PlaybackStatus, PlayerProxy}, - i3bar::{Align, Block, Click, MinWidth}, - Error, -}; - -use super::{Button, Component, Output, Update}; - -pub struct Play; - -impl Component for Play { - type Updater = Self; - type Colorer = (); - type Handler = Self; - - fn initialize() -> Block { - Block { - name: Some("mpris-play".into()), - min_width: Some(MinWidth::Text("xx".to_string())), - align: Align::Center, - separator: Some(false), - separator_block_width: Some(0), - ..Default::default() - } - } -} - -impl Update for Play { - type Value = (char, Option, Option); - - async fn listen(tx: Sender, proxy: PlayerProxy<'_>) -> Result<(), Error> { - use futures_util::StreamExt; - let black = std::env::var("BASE16_COLOR_00_HEX").ok(); - let cyan = std::env::var("BASE16_COLOR_0C_HEX").ok(); - let yellow = std::env::var("BASE16_COLOR_0A_HEX").ok(); - - let mut stream = proxy.receive_playback_status_changed().await; - while let Some(signal) = stream.next().await { - if let Ok(value) = signal.get().await { - tx.send(match value { - PlaybackStatus::Playing => ('󰏤', black.clone(), cyan.clone()), - PlaybackStatus::Paused => ('󰐊', black.clone(), yellow.clone()), - PlaybackStatus::Stopped => ('󰐊', None, None), - }) - .await?; - } - } - Ok(()) - } - - async fn update(value: Self::Value, block: Arc>) -> Result { - let (full_text, color, background) = value; - - let mut block = block.lock().await; - block.full_text = full_text.into(); - block.color = color; - block.background = background; - Ok(Output::Print) - } -} - -impl Button for Play { - async fn handle(conn: Connection, click: Click) -> Result<(), Error> { - let Some(name) = click.instance else { - return Ok(()); - }; - - let proxy = PlayerProxy::builder(&conn) - .destination(name)? - .build() - .await?; - - let valid = match proxy.playback_status().await { - Ok(PlaybackStatus::Playing) => proxy.can_pause().await.unwrap_or_default(), - Ok(_) => proxy.can_play().await.unwrap_or_default(), - _ => false, - }; - - if !valid { - return Ok(()); - } - - match (click.button, proxy.playback_status().await) { - (1, Ok(PlaybackStatus::Playing)) if proxy.can_pause().await.unwrap_or_default() => { - proxy.play_pause().await? - } - (1, Ok(PlaybackStatus::Paused)) if proxy.can_play().await.unwrap_or_default() => { - proxy.play_pause().await? - } - _ => (), - } - - Ok(()) - } -} diff --git a/src/component/prev.rs b/src/component/prev.rs deleted file mode 100644 index 73372ab..0000000 --- a/src/component/prev.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::sync::Arc; - -use tokio::sync::{mpsc::Sender, Mutex}; -use zbus::Connection; - -use crate::{ - dbus::player::{PlaybackStatus, PlayerProxy}, - i3bar::{Align, Block, Click, MinWidth}, - Error, -}; - -use super::{Button, Component, Output, Update}; - -pub struct Prev; - -impl Component for Prev { - type Updater = Self; - type Colorer = PlaybackStatus; - type Handler = Self; - - fn initialize() -> Block { - Block { - name: Some("mpris-prev".into()), - full_text: '󰒮'.into(), - min_width: Some(MinWidth::Text("xx".to_string())), - align: Align::Center, - separator: Some(false), - separator_block_width: Some(0), - ..Default::default() - } - } -} - -impl Update for Prev { - type Value = bool; - - async fn listen(tx: Sender, proxy: PlayerProxy<'_>) -> Result<(), Error> { - use futures_util::StreamExt; - - let mut stream = proxy.receive_can_go_previous_changed().await; - while let Some(signal) = stream.next().await { - if let Ok(value) = signal.get().await { - tx.send(value).await?; - } - } - Ok(()) - } - - async fn update(value: Self::Value, _: Arc>) -> Result { - if value { - Ok(Output::Print) - } else { - Ok(Output::Clear) - } - } -} - -impl Button for Prev { - async fn handle(conn: Connection, click: Click) -> Result<(), Error> { - let Some(name) = click.instance else { - return Ok(()); - }; - - let proxy = PlayerProxy::builder(&conn) - .destination(name)? - .build() - .await?; - - match click.button { - 1 if proxy.can_go_previous().await? => proxy.previous().await?, - 3 if proxy.can_seek().await? => proxy.seek(-5000).await?, - _ => {} - } - - Ok(()) - } -} diff --git a/src/component/title.rs b/src/component/title.rs deleted file mode 100644 index 870fe1e..0000000 --- a/src/component/title.rs +++ /dev/null @@ -1,93 +0,0 @@ -use std::{sync::Arc, time::Duration}; - -use tokio::{ - sync::{mpsc::Sender, Mutex}, - task::{AbortHandle, JoinSet}, -}; - -use crate::{ - dbus::player::{PlaybackStatus, PlayerProxy}, - i3bar::{Align, Block, MinWidth}, - Error, -}; - -use super::{Component, Output, Update}; - -const TICK_RATE: Duration = Duration::from_millis(500); - -pub struct Title; - -impl Component for Title { - type Updater = Self; - type Colorer = PlaybackStatus; - type Handler = (); - - fn initialize() -> Block { - Block { - name: Some("mpris-title".into()), - separator: Some(false), - separator_block_width: Some(0), - ..Default::default() - } - } -} - -impl Update for Title { - type Value = String; - - async fn listen(tx: Sender, proxy: PlayerProxy<'_>) -> Result<(), Error> { - use futures_util::StreamExt; - - let mut join_set = JoinSet::new(); - let mut rotator: Option = None; - let mut old_title = String::new(); - let mut stream = proxy.receive_metadata_changed().await; - while let Some(signal) = stream.next().await { - if let Ok(metadata) = signal.get().await { - let Some(owned_value) = metadata.get("xesam:title") else { - continue; - }; - - let title: String = owned_value.try_to_owned()?.try_into()?; - - if old_title == title { - continue; - } - - if let Some(h) = rotator.take() { - h.abort(); - }; - - old_title.clone_from(&title); - - if title.len() >= 10 { - let tx = tx.clone(); - let mut chars = title.chars().collect::>(); - chars.push(' '); - rotator = Some(join_set.spawn(async move { - let mut interval = tokio::time::interval(TICK_RATE); - loop { - interval.tick().await; - tx.send(String::from_iter(chars[0..10].iter())) - .await - .unwrap(); - chars.rotate_left(1); - } - })); - } else { - tx.send(title).await?; - } - } - } - Ok(()) - } - - async fn update(value: Self::Value, block: Arc>) -> Result { - let mut block = block.lock().await; - block.full_text = value; - block.min_width = Some(MinWidth::Text(format!("x{}", block.full_text))); - block.align = Align::Center; - - Ok(Output::Print) - } -} diff --git a/src/component/volume.rs b/src/component/volume.rs deleted file mode 100644 index 83341f0..0000000 --- a/src/component/volume.rs +++ /dev/null @@ -1,82 +0,0 @@ -use std::sync::Arc; - -use tokio::sync::{mpsc::Sender, Mutex}; -use zbus::Connection; - -use crate::{ - dbus::player::{PlaybackStatus, PlayerProxy}, - i3bar::{Align, Block, Click, MinWidth}, - Error, -}; - -use super::{Button, Component, Output, Update}; - -pub struct Volume; - -impl Component for Volume { - type Updater = Self; - type Colorer = PlaybackStatus; - type Handler = Self; - - fn initialize() -> Block { - Block { - name: Some("mpris-volume".into()), - full_text: " ".to_string(), - align: Align::Center, - ..Default::default() - } - } -} - -impl Update for Volume { - type Value = Option; - - async fn listen(tx: Sender, proxy: PlayerProxy<'_>) -> Result<(), Error> { - use futures_util::StreamExt; - let mut stream = proxy.receive_volume_changed().await; - while let Some(signal) = stream.next().await { - if let Ok(value) = signal.get().await { - tx.send(Some(value)).await?; - } - } - Ok(()) - } - - async fn update(value: Self::Value, block: Arc>) -> Result { - let volume = match value { - Some(v) => (v * 100_f64) as u32, - None => return Ok(Output::Clear), - }; - - let mut block = block.lock().await; - block.full_text = match volume { - v @ 66.. => format!("󰕾 {v}%"), - v @ 33.. => format!("󰖀 {v}%"), - v @ 0.. => format!("󰕿 {v}%"), - }; - block.min_width = Some(MinWidth::Text(format!("x{}", block.full_text))); - - Ok(Output::Print) - } -} - -impl Button for Volume { - async fn handle(conn: Connection, click: Click) -> Result<(), Error> { - let Some(name) = click.instance else { - return Ok(()); - }; - - let proxy = PlayerProxy::builder(&conn) - .destination(name)? - .build() - .await?; - - match (click.button, proxy.volume().await) { - (4, Ok(v)) if proxy.can_control().await? => proxy.set_volume(v + 0.05).await?, - (5, Ok(v)) if proxy.can_control().await? => proxy.set_volume(v - 0.05).await?, - _ => (), - } - - Ok(()) - } -} diff --git a/src/dbus/player.rs b/src/dbus/player.rs index 6457373..a78d7ed 100644 --- a/src/dbus/player.rs +++ b/src/dbus/player.rs @@ -20,6 +20,11 @@ //! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html //! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, +use std::collections::HashMap; + +use serde::{ser::Error, Deserialize, Serialize}; +use time::{format_description::well_known::Iso8601, PrimitiveDateTime}; +use url::Url; use zbus::{ proxy, zvariant::{OwnedValue, Type}, @@ -102,9 +107,7 @@ trait Player { /// Metadata property #[zbus(property)] - fn metadata( - &self, - ) -> zbus::Result>; + fn metadata(&self) -> zbus::Result; /// MinimumRate property #[zbus(property)] @@ -137,6 +140,156 @@ trait Player { fn set_volume(&self, value: f64) -> zbus::Result<()>; } +/// MPRIS metadata recomended fields +/// +/// Found here: [https://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata] +#[derive(Debug, Default, PartialEq, Type, Serialize, Deserialize)] +#[zvariant(signature = "dict")] +pub struct Metadata { + /// D-Bus path: A unique identity for this track within the context of an MPRIS object (eg: tracklist). + #[zvariant(rename = "mpris:trackid")] + pub trackid: Option, + + /// 64-bit integer: The duration of the track in microseconds. + #[zvariant(rename = "mpris:length")] + pub length: Option, + + /// URI: The location of an image representing the track or album. Clients should not assume this will continue to exist when the media player stops giving out the URL. + #[zvariant(rename = "mpris:artUrl")] + pub art_url: Option, + + /// String: The album name. + #[zvariant(rename = "xesam:album")] + pub album: Option, + + /// List of Strings: The album artist(s). + #[zvariant(rename = "xesam:albumArtist")] + pub album_artist: Option>, + + /// List of Strings: The track artist(s). + #[zvariant(rename = "xesam:artist")] + pub artist: Option>, + + /// String: The track lyrics. + #[zvariant(rename = "xesam:asText")] + pub as_text: Option, + + /// Integer: The speed of the music, in beats per minute. + #[zvariant(rename = "xesam:audioBPM")] + pub audio_bpm: Option, + + /// Float: An automatically-generated rating, based on things such as how often it has been played. This should be in the range 0.0 to 1.0. + #[zvariant(rename = "xesam:autoRating")] + pub auto_rating: Option, + + /// List of Strings: A (list of) freeform comment(s). + #[zvariant(rename = "xesam:comment")] + pub comment: Option>, + + /// List of Strings: The composer(s) of the track. + #[zvariant(rename = "xesam:composer")] + pub composer: Option>, + + /// Date/Time: When the track was created. Usually only the year component will be useful. + #[zvariant(rename = "xesam:contentCreated")] + pub content_created: Option, + + /// Integer: The disc number on the album that this track is from. + #[zvariant(rename = "xesam:discNumber")] + pub disc_number: Option, + + /// Date/Time: When the track was first played. + #[zvariant(rename = "xesam:firstUsed")] + pub first_used: Option, + + /// List of Strings: The genre(s) of the track. + #[zvariant(rename = "xesam:genre")] + pub genre: Option>, + + /// Date/Time: When the track was last played. + #[zvariant(rename = "xesam:lastUsed")] + pub last_used: Option, + + /// List of Strings: The lyricist(s) of the track. + #[zvariant(rename = "xesam:lyricist")] + pub lyricist: Option>, + + /// String: The track title. + #[zvariant(rename = "xesam:title")] + pub title: Option, + + /// Integer: The track number on the album disc. + #[zvariant(rename = "xesam:trackNumber")] + pub track_number: Option, + + /// URI: The location of the media file. + #[zvariant(rename = "xesam:url")] + pub url: Option, + + /// Integer: The number of times the track has been played. + #[zvariant(rename = "xesam:useCount")] + pub use_count: Option, + + /// Float: A user-specified rating. This should be in the range 0.0 to 1.0. + #[zvariant(rename = "xesam:userRating")] + pub user_rating: Option, +} + +impl TryFrom for Metadata { + type Error = zbus::zvariant::Error; + + fn try_from(value: OwnedValue) -> Result { + let map: HashMap = value.try_into()?; + + macro_rules! get_field { + ($n:literal, PrimitiveDateTime) => { + get_field!($n) + .map(|s: String| PrimitiveDateTime::parse(&s, &Iso8601::DEFAULT)) + .transpose() + .map_err(Self::Error::custom)? + }; + ($n:literal, Url) => { + get_field!($n) + .map(|s: String| Url::parse(&s)) + .transpose() + .map_err(Self::Error::custom)? + }; + ($n:literal) => { + map.get($n) + .map(|o| o.try_to_owned()) + .transpose()? + .map(|o| o.try_into()) + .transpose()? + }; + } + + Ok(Metadata { + trackid: get_field!("mpris:trackid"), + length: get_field!("mpris:length"), + art_url: get_field!("mpris:artUrl", Url), + album: get_field!("xesam:album"), + album_artist: get_field!("xesam:albumArtist"), + artist: get_field!("xesam:artist"), + as_text: get_field!("xesam:asText"), + audio_bpm: get_field!("xesam:audioBPM"), + auto_rating: get_field!("xesam:autoRating"), + comment: get_field!("xesam:comment"), + composer: get_field!("xesam:composer"), + content_created: get_field!("xesam:contentCreated", PrimitiveDateTime), + disc_number: get_field!("xesam:discNumber"), + first_used: get_field!("xesam:firstUsed", PrimitiveDateTime), + genre: get_field!("xesam:genre"), + last_used: get_field!("xesam:lastUsed", PrimitiveDateTime), + lyricist: get_field!("xesam:lyricist"), + title: get_field!("xesam:title"), + track_number: get_field!("xesam:trackNumber"), + url: get_field!("xesam:url", Url), + use_count: get_field!("xesam:useCount"), + user_rating: get_field!("xesam:userRating"), + }) + } +} + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Type)] pub enum PlaybackStatus { Playing, diff --git a/src/error.rs b/src/error.rs index 77d832c..ef596d2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,9 +5,6 @@ pub enum Error { #[error("IO error: {0}")] IO(#[from] std::io::Error), - #[error("Poison error: {0}")] - Poison(String), - #[error("Join error: {0}")] Join(#[from] tokio::task::JoinError), @@ -22,19 +19,4 @@ pub enum Error { #[error("Json serde error: {0}")] Json(#[from] serde_json::Error), - - #[error("Send error: {0}")] - Send(String), -} - -impl From> for Error { - fn from(err: tokio::sync::mpsc::error::SendError) -> Self { - Self::Send(err.to_string()) - } -} - -impl From> for Error { - fn from(err: std::sync::PoisonError) -> Self { - Self::Poison(err.to_string()) - } } diff --git a/src/i3bar.rs b/src/i3bar.rs index bdc92fa..28306e0 100644 --- a/src/i3bar.rs +++ b/src/i3bar.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::Error; /// Represent block as described in -#[derive(Debug, Clone, Default, Serialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Block { pub full_text: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -49,7 +49,7 @@ impl Block { } } -#[derive(Debug, Copy, Clone, Default, Serialize)] +#[derive(Debug, Copy, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Align { Center, @@ -64,7 +64,7 @@ impl Align { } } -#[derive(Serialize, Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum MinWidth { Pixels(usize), @@ -72,24 +72,20 @@ pub enum MinWidth { } #[derive(Debug, Default, Clone, Serialize, Deserialize)] -#[serde(default)] pub struct Click { + #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub instance: Option, - pub full_text: String, - pub short_text: Option, - pub color: Option, - pub background: Option, - pub button: u8, - pub event: usize, - pub modifiers: Option>, pub x: usize, pub y: usize, + pub button: u8, + pub event: usize, pub relative_x: usize, pub relative_y: usize, - pub output_x: Option, - pub output_y: Option, pub width: usize, pub height: usize, - pub scale: usize, + /// The modifiers property is not currently added by swaybar + #[serde(skip_serializing_if = "Option::is_none")] + pub modifiers: Option>, } diff --git a/src/lib.rs b/src/lib.rs index 01c25aa..6306546 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,28 @@ +use std::sync::Arc; + +use i3bar::Block; +use tokio::sync::{Notify, RwLock}; + pub use crate::error::{Error, Result}; -pub mod component; pub mod dbus; pub mod error; pub mod i3bar; +pub mod listener; + +pub type Blocks = Arc; 6]>>; + +#[derive(Default)] +pub struct Watcher { + pub value: RwLock, + pub notify: Notify, +} -const IGNORED: [&str; 3] = ["playerctld", "kdeconnect", "spotifyd"]; +impl Watcher { + pub fn new(value: T) -> Self { + Self { + value: RwLock::new(value), + notify: Notify::new(), + } + } +} diff --git a/src/listener.rs b/src/listener.rs new file mode 100644 index 0000000..208c161 --- /dev/null +++ b/src/listener.rs @@ -0,0 +1,184 @@ +use std::{ops::ControlFlow, time::Duration}; + +use tokio::task::JoinHandle; +use tokio_stream::StreamExt; +use zbus::{proxy::PropertyChanged, Connection}; + +use crate::{ + dbus::player::{Metadata, PlaybackStatus, PlayerProxy}, + i3bar::{Align, Block, MinWidth}, + Blocks, Error, +}; + +const TICK_RATE: Duration = Duration::from_millis(500); + +pub async fn listeners(blocks: Blocks) -> Result<(), Error> { + let conn = Connection::session().await?; + + let proxy = PlayerProxy::builder(&conn) + .destination("org.mpris.MediaPlayer2.playerctld")? + .build() + .await?; + + let black = std::env::var("BASE16_COLOR_00_HEX").ok(); + let cyan = std::env::var("BASE16_COLOR_0C_HEX").ok(); + let yellow = std::env::var("BASE16_COLOR_0A_HEX").ok(); + + let mut rotator: Option>> = None; + let mut old_title = String::new(); + + let mut metadata = proxy.receive_metadata_changed().await; + let mut prev = proxy.receive_can_go_previous_changed().await; + let mut play = proxy.receive_playback_status_changed().await; + let mut next = proxy.receive_can_go_next_changed().await; + let mut volume = proxy.receive_volume_changed().await; + + loop { + tokio::select! { + Some(prop) = metadata.next() => handle_metadata(prop, blocks.clone(), &mut old_title, &mut rotator).await, + Some(prop) = prev.next() => handle_prev_next(prop, blocks.clone(), '󰒮', "mpris-prev").await, + Some(prop) = play.next() => handle_play(prop, blocks.clone(), black.clone(), cyan.clone(), yellow.clone()).await, + Some(prop) = next.next() => handle_prev_next(prop, blocks.clone(), '󰒭', "mpris-next").await, + Some(prop) = volume.next() => handle_volume(prop, blocks.clone()).await, + else => { + eprintln!("Failed to get next property"); + break + } + + }; + + blocks.notify.notify_one(); + } + + Ok(()) +} + +async fn title_rotator(blocks: Blocks, title: String) -> Result<(), Error> { + let mut interval = tokio::time::interval(TICK_RATE); + let mut chars = title.chars().collect::>(); + chars.push(' '); + let full_text = String::from_iter(chars[0..10].iter()); + + blocks.value.write().await[1] = Some(Block { + name: Some("mpris-title".into()), + min_width: Some(MinWidth::Text(format!("x{}", full_text))), + full_text, + separator: Some(false), + separator_block_width: Some(0), + ..Default::default() + }); + + loop { + interval.tick().await; + let mut value = blocks.value.write().await; + if let Some(block) = &mut value[1] { + block.full_text = String::from_iter(chars[0..10].iter()); + }; + chars.rotate_left(1); + } +} + +async fn handle_metadata( + prop: PropertyChanged<'_, Metadata>, + blocks: Blocks, + old_title: &mut String, + rotator: &mut Option>>, +) -> ControlFlow> { + let title = match prop.get().await.map(|m| m.title) { + Ok(Some(s)) if *old_title != s => s, + _ => return ControlFlow::Continue(()), + }; + + old_title.clone_from(&title); + rotator.take().into_iter().for_each(|h| h.abort()); + + if title.len() >= 10 { + *rotator = Some(tokio::spawn(title_rotator(blocks.clone(), title))); + } else { + blocks.value.write().await[1] = Some(Block { + name: Some("mpris-title".into()), + min_width: Some(MinWidth::Text(format!("x{}", title))), + full_text: title, + separator: Some(false), + separator_block_width: Some(0), + ..Default::default() + }); + } + + ControlFlow::Continue(()) +} + +async fn handle_prev_next( + prop: PropertyChanged<'_, bool>, + blocks: Blocks, + icon: char, + name: &'static str, +) -> ControlFlow> { + if let Ok(value) = prop.get().await { + blocks.value.write().await[2] = value.then_some(Block { + name: Some(name.into()), + full_text: icon.into(), + min_width: Some(MinWidth::Text("xx".to_string())), + align: Align::Center, + separator: Some(false), + separator_block_width: Some(0), + ..Default::default() + }); + }; + ControlFlow::Continue(()) +} + +async fn handle_play( + prop: PropertyChanged<'_, PlaybackStatus>, + blocks: Blocks, + black: Option, + cyan: Option, + yellow: Option, +) -> ControlFlow> { + let (icon, color, bg) = match prop.get().await { + Ok(PlaybackStatus::Playing) => ('󰏤', black.clone(), cyan.clone()), + Ok(PlaybackStatus::Paused) => ('󰐊', black.clone(), yellow.clone()), + Ok(PlaybackStatus::Stopped) => ('󰐊', None, None), + Err(_) => return ControlFlow::Continue(()), + }; + + let mut blocks = blocks.value.write().await; + blocks[3] = Some(Block { + name: Some("mpris-next".into()), + full_text: icon.into(), + min_width: Some(MinWidth::Text("xx".to_string())), + align: Align::Center, + separator: Some(false), + separator_block_width: Some(0), + ..Default::default() + }); + + for block in blocks.iter_mut().flatten() { + block.color.clone_from(&color); + block.background.clone_from(&bg); + } + + ControlFlow::Continue(()) +} + +async fn handle_volume( + prop: PropertyChanged<'_, f64>, + blocks: Blocks, +) -> ControlFlow> { + let full_text = match prop.get().await.map(|v| (v * 100_f64) as u32) { + Ok(v @ 66..) => format!("󰕾 {v}%"), + Ok(v @ 33..) => format!("󰖀 {v}%"), + Ok(v @ 0..) => format!("󰕿 {v}%"), + Err(_) => return ControlFlow::Continue(()), + }; + + blocks.value.write().await[5] = Some(Block { + name: Some("mpris-volume".into()), + min_width: Some(MinWidth::Text(format!("x{}", full_text))), + full_text, + align: Align::Center, + ..Default::default() + }); + + ControlFlow::Continue(()) +} diff --git a/src/main.rs b/src/main.rs index e17742e..26b5784 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,75 @@ -use i3blocks_mpris::component::{Icon, Next, Play, Prev, Runner, Title, Volume}; +use std::process::Stdio; + +use i3blocks_mpris::{ + i3bar::{Block, Click}, + listener::listeners, + Blocks, +}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; #[tokio::main] async fn main() -> Result<(), main_error::MainError> { - let stdin = std::io::stdin(); - match std::env::args().nth(1).as_deref().unwrap_or("icon") { - "icon" => Icon::run(stdin).await, - "next" => Next::run(stdin).await, - "play" => Play::run(stdin).await, - "prev" => Prev::run(stdin).await, - "title" => Title::run(stdin).await, - "volume" => Volume::run(stdin).await, - s => { - eprintln!("Invalid component name: {s}"); - std::process::exit(1) + let args = std::env::args().skip(1).collect::>(); + let mut child = tokio::process::Command::new("i3blocks") + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn()?; + + let mut stdout = tokio::io::stdout(); + let mut stdin = BufReader::new(tokio::io::stdin()).lines(); + + let mut child_stdin = child.stdin.take().unwrap(); + let mut child_stdout = BufReader::new(child.stdout.take().unwrap()).lines(); + + let blocks: Blocks = Blocks::default(); + + let blocks2 = blocks.clone(); + tokio::spawn(async move { + blocks2.value.write().await[0] = Some(Block { + name: Some("mpris-icon".into()), + full_text: " 󰝚 ".into(), + separator: Some(false), + separator_block_width: Some(0), + ..Default::default() + }); + blocks2.notify.notify_one(); + }); + + tokio::spawn(listeners(blocks.clone())); + + let mut mpris_blocks = String::new(); + let mut other_blocks = String::new(); + + loop { + tokio::select! { + _ = blocks.notify.notified() => { + mpris_blocks = blocks + .value + .read() + .await + .iter() + .map(serde_json::to_string) + .collect::, _>>()? + .join(","); + } + Ok(Some(line)) = child_stdout.next_line() => other_blocks = line, + Ok(Some(line)) = stdin.next_line() => match serde_json::from_str::(&line) { + Err(_) => continue, + Ok(Click { name: Some(name), button, .. }) if name.starts_with("mpris-") => println!("{name}, {button}"), + Ok(click) => { + let mut s = serde_json::to_vec(&click)?; + s.push(b'\n'); + child_stdin.write_all(&s).await?; + } + }, + else => break, } + + let mut output = other_blocks.replace(r#"{"name":"mpris","full_text":""}"#, &mpris_blocks); + output.push('\n'); + stdout.write_all(output.as_bytes()).await?; } - .map_err(Into::into) + + Ok(()) } -- cgit v1.2.3-70-g09d2