i3status_rs/blocks/
music.rs

1//! The current song title and artist
2//!
3//! Also provides buttons for play/pause, previous and next.
4//!
5//! Supports all music players that implement the [MediaPlayer2 Interface]. This includes:
6//!
7//! - Spotify
8//! - VLC
9//! - mpd (via [mpDris2](https://github.com/eonpatapon/mpDris2))
10//!
11//! and many others.
12//!
13//! By default the block tracks all players available on the MPRIS bus. Right clicking on the block
14//! will cycle it to the next player. You can pin the widget to a given player via the "player"
15//! setting.
16//!
17//! # Configuration
18//!
19//! Key | Values | Default
20//! ----|--------|--------
21//! `format` | A string to customise the output of this block. See below for available placeholders. | <code>\" $icon {$combo.str(max_w:25,rot_interval:0.5) $play \|}\"</code>
22//! `format_alt` | If set, block will switch between `format` and `format_alt` on every click | `None`
23//! `player` | Name(s) of the music player(s) MPRIS interface. This can be either a music player name or an array of music player names. Run <code>busctl \--user list \| grep \"org.mpris.MediaPlayer2.\" \| cut -d\' \' -f1</code> and the name is the part after "org.mpris.MediaPlayer2.". | `None`
24//! `interface_name_exclude` | A list of regex patterns for player MPRIS interface names to ignore. | `["playerctld"]`
25//! `separator` | String to insert between artist and title. | `" - "`
26//! `seek_step_secs` | Positive number of seconds to seek forward/backward when scrolling on the bar. Does not need to be an integer. | `1`
27//! `seek_forward_step_secs` | Positive number of seconds to seek forward when scrolling on the bar. Does not need to be an integer. | `seek_step_secs`
28//! `seek_backward_step_secs` | Positive number of seconds to seek backward when scrolling on the bar. Does not need to be an integer. | `seek_step_secs`
29//! `volume_step` | The percent volume level is increased/decreased for the selected audio device when scrolling. Capped automatically at 50. | `5`
30//!
31//! Note: All placeholders except `icon` can be absent. See the examples below to learn how to handle this.
32//!
33//! Placeholder   | Value          | Type
34//! --------------|----------------|------
35//! `icon`        | A static icon  | Icon
36//! `artist`      | Current artist | Text
37//! `title`       | Current title  | Text
38//! `url`         | Current song url | Text
39//! `combo`       | Resolves to "`$artist[sep]$title"`, `"$artist"`, `"$title"`, or `"$url"` depending on what information is available. `[sep]` is set by `separator` option. | Text
40//! `player`      | Name of the current player (taken from the last part of its MPRIS bus name) | Text
41//! `avail`       | Total number of players available to switch between | Number
42//! `cur`         | The current player index of the available players | Number
43//! `play`        | Play/Pause button | Clickable icon
44//! `next`        | Next button | Clickable icon
45//! `prev`        | Previous button | Clickable icon
46//! `volume_icon` | Icon based on volume. Missing if unsupported.    | Icon
47//! `volume`      | Current volume. Missing if muted or unsupported. | Number
48//!
49//! Widget           | Placeholder
50//! -----------------|-------------
51//! `play_pause_btn` | `$play`
52//! `next_btn`       | `$next`
53//! `prev_btn`       | `$prev`
54//!
55//! Action          | Default button
56//! ----------------|------------------
57//! `play_pause`    | Left on `play_pause_btn`
58//! `next`          | Left on `next_btn`
59//! `prev`          | Left on `prev_btn`
60//! `next_player`   | Right
61//! `seek_forward`  | Wheel Up
62//! `seek_backward` | Wheel Down
63//! `volume_up`     | -
64//! `volume_down`   | -
65//! `toggle_format` | Left
66//!
67//! # Examples
68//!
69//! Show the currently playing song on Spotify only, with play & next buttons and limit the width
70//! to 20 characters:
71//!
72//! ```toml
73//! [[block]]
74//! block = "music"
75//! format = " $icon {$combo.str(max_w:20) $play $next |}"
76//! player = "spotify"
77//! ```
78//!
79//! Same thing for any compatible player, takes the first active on the bus, but ignores "mpd" or anything with "kdeconnect" in the name:
80//!
81//! ```toml
82//! [[block]]
83//! block = "music"
84//! format = " $icon {$combo.str(max_w:20) $play $next |}"
85//! interface_name_exclude = [".*kdeconnect.*", "mpd"]
86//! ```
87//!
88//! Same as above, but displays with rotating text
89//!
90//! ```toml
91//! [[block]]
92//! block = "music"
93//! format = " $icon {$combo.str(max_w:20,rot_interval:0.5) $play $next |}"
94//! interface_name_exclude = [".*kdeconnect.*", "mpd"]
95//! ```
96//!
97//! Click anywhere to play/pause, middle click to toggle format:
98//!
99//! ```toml
100//! [[block]]
101//! block = "music"
102//! format = " format 1 "
103//! format_alt = " format 2 "
104//! [[block.click]]
105//! button = "left"
106//! action = "play_pause"
107//! [[block.click]]
108//! button = "middle"
109//! widget = "."
110//! action = "toggle_format"
111//! ```
112//!
113//! Scroll to change the player volume, use the forward and back buttons to seek:
114//!
115//! ```toml
116//! [[block]]
117//! block = "music"
118//! format = " $icon $volume_icon $combo $play $next| "
119//! seek_step_secs = 10
120//! [[block.click]]
121//! button = "up"
122//! action = "volume_up"
123//! [[block.click]]
124//! button = "down"
125//! action = "volume_down"
126//! [[block.click]]
127//! button = "forward"
128//! action = "seek_forward"
129//! [[block.click]]
130//! button = "back"
131//! action = "seek_backward"
132//! ```
133//!
134//! # Icons Used
135//! - `music`
136//! - `music_next`
137//! - `music_play`
138//! - `music_prev`
139//! - `volume_muted`
140//! - `volume` (as a progression)
141//!
142//! [MediaPlayer2 Interface]: https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html
143
144use super::prelude::*;
145use crate::wrappers::DisplaySlice;
146
147use regex::Regex;
148use std::fmt;
149use zbus::fdo::{DBusProxy, NameOwnerChanged, PropertiesChanged};
150use zbus::names::{OwnedBusName, OwnedUniqueName};
151use zbus::{MatchRule, MessageStream};
152
153mod zbus_mpris;
154mod zbus_playerctld;
155
156make_log_macro!(debug, "music");
157
158const PLAY_PAUSE_BTN: &str = "play_pause_btn";
159const NEXT_BTN: &str = "next_btn";
160const PREV_BTN: &str = "prev_btn";
161
162#[derive(Deserialize, Debug, SmartDefault)]
163#[serde(deny_unknown_fields, default)]
164pub struct Config {
165    pub format: FormatConfig,
166    pub format_alt: Option<FormatConfig>,
167    pub player: PlayerName,
168    #[default(vec!["playerctld".into()])]
169    pub interface_name_exclude: Vec<String>,
170    #[default(" - ".into())]
171    pub separator: String,
172    #[default(1.into())]
173    pub seek_step_secs: Seconds<false>,
174    pub seek_forward_step_secs: Option<Seconds<false>>,
175    pub seek_backward_step_secs: Option<Seconds<false>>,
176    #[default(5.0)]
177    pub volume_step: f64,
178}
179
180#[derive(Deserialize, Debug, Clone, SmartDefault)]
181#[serde(untagged)]
182pub enum PlayerName {
183    Single(String),
184    #[default]
185    Multiple(Vec<String>),
186}
187
188pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
189    let mut actions = api.get_actions()?;
190    api.set_default_actions(&[
191        (MouseButton::Left, Some(PLAY_PAUSE_BTN), "play_pause"),
192        (MouseButton::Left, Some(NEXT_BTN), "next"),
193        (MouseButton::Left, Some(PREV_BTN), "prev"),
194        (MouseButton::Right, None, "next_player"),
195        (MouseButton::WheelUp, None, "seek_forward"),
196        (MouseButton::WheelDown, None, "seek_backward"),
197        (MouseButton::Left, None, "toggle_format"),
198    ])?;
199
200    let dbus_conn = new_dbus_connection().await?;
201
202    let mut format = config
203        .format
204        .with_default(" $icon {$combo.str(max_w:25,rot_interval:0.5) $play |}")?;
205    let mut format_alt = match &config.format_alt {
206        Some(f) => Some(f.with_default("")?),
207        None => None,
208    };
209
210    let volume_step = config.volume_step.clamp(0.0, 50.0) / 100.0;
211
212    let seek_forward_step = config
213        .seek_forward_step_secs
214        .unwrap_or(config.seek_step_secs)
215        .0
216        .as_micros() as i64;
217    let seek_backward_step = -(config
218        .seek_backward_step_secs
219        .unwrap_or(config.seek_step_secs)
220        .0
221        .as_micros() as i64);
222
223    let new_btn = |icon: &str, instance: &'static str| -> Result<Value> {
224        Ok(Value::icon(icon.to_string()).with_instance(instance))
225    };
226
227    let values = map! {
228        "icon" => Value::icon("music"),
229        "next" => new_btn("music_next", NEXT_BTN)?,
230        "prev" => new_btn("music_prev", PREV_BTN)?,
231    };
232
233    let preferred_players = match config.player.clone() {
234        PlayerName::Single(name) => vec![name],
235        PlayerName::Multiple(names) => names,
236    };
237    let exclude_regex = config
238        .interface_name_exclude
239        .iter()
240        .map(|r| Regex::new(r))
241        .collect::<Result<Vec<_>, _>>()
242        .error("Invalid regex")?;
243
244    let playerctld_proxy = zbus_playerctld::PlayerctldProxy::new(&dbus_conn)
245        .await
246        .error("Failed to create PlayerctldProxy")?;
247
248    let mut players = get_players(&dbus_conn, &preferred_players, &exclude_regex).await?;
249    let mut cur_player = None;
250    if let Ok(playerctld_players) = playerctld_proxy.player_names().await {
251        // If we can get the list of players from playerctld then we should
252        // take the first matching player (this is the most recently active player)
253        for playerctld_player in playerctld_players {
254            if let Some(pos) = players
255                .iter()
256                .position(|p| p.bus_name.as_str() == playerctld_player)
257            {
258                cur_player = Some(pos);
259                break;
260            }
261        }
262    } else {
263        // If we couldn't get the players from playerctld then fall back to walking over
264        // the players and select the first one found playing something, or the last one
265        // in the list (the most recently opened)
266        for (i, player) in players.iter().enumerate() {
267            cur_player = Some(i);
268            if player.status == Some(PlaybackStatus::Playing) {
269                break;
270            }
271        }
272    }
273
274    let mut properties_stream = MessageStream::for_match_rule(
275        MatchRule::builder()
276            .msg_type(zbus::message::Type::Signal)
277            .interface("org.freedesktop.DBus.Properties")
278            .and_then(|x| x.member("PropertiesChanged"))
279            .and_then(|x| x.path("/org/mpris/MediaPlayer2"))
280            .unwrap()
281            .build(),
282        &dbus_conn,
283        None,
284    )
285    .await
286    .error("Failed to add match rule")?;
287
288    let mut name_owner_changed_stream = MessageStream::for_match_rule(
289        MatchRule::builder()
290            .msg_type(zbus::message::Type::Signal)
291            .interface("org.freedesktop.DBus")
292            .and_then(|x| x.member("NameOwnerChanged"))
293            .and_then(|x| x.arg0ns("org.mpris.MediaPlayer2"))
294            .unwrap()
295            .build(),
296        &dbus_conn,
297        None,
298    )
299    .await
300    .error("Failed to add match rule")?;
301
302    let mut active_player_change_end_stream = playerctld_proxy
303        .receive_active_player_change_end()
304        .await
305        .error("Failed to create ActivePlayerChangeEndStream")?;
306
307    loop {
308        debug!("available players: {}", DisplaySlice(&players));
309
310        let avail = players.len();
311        let player = cur_player.map(|c| players.get_mut(c).unwrap());
312        match player {
313            Some(player) => {
314                let mut values = values.clone();
315                values.insert("avail".into(), Value::number(avail));
316                values.insert("cur".into(), Value::number(cur_player.unwrap() + 1));
317                values.insert(
318                    "player".into(),
319                    Value::text(
320                        extract_player_name(player.bus_name.as_str())
321                            .unwrap()
322                            .into(),
323                    ),
324                );
325                let (state, play_icon) = match player.status {
326                    Some(PlaybackStatus::Playing) => (State::Info, "music_pause"),
327                    _ => (State::Idle, "music_play"),
328                };
329                values.insert("play".into(), new_btn(play_icon, PLAY_PAUSE_BTN)?);
330                if let Some(url) = &player.metadata.url {
331                    values.insert("url".into(), Value::text(url.clone()));
332                }
333                match (
334                    &player.metadata.title,
335                    &player.metadata.artist,
336                    &player.metadata.url,
337                ) {
338                    (Some(t), None, _) => {
339                        values.insert("combo".into(), Value::text(t.clone()));
340                        values.insert("title".into(), Value::text(t.clone()));
341                    }
342                    (None, Some(a), _) => {
343                        values.insert("combo".into(), Value::text(a.clone()));
344                        values.insert("artist".into(), Value::text(a.clone()));
345                    }
346                    (Some(t), Some(a), _) => {
347                        values.insert(
348                            "combo".into(),
349                            Value::text(format!("{t}{}{a}", config.separator)),
350                        );
351                        values.insert("title".into(), Value::text(t.clone()));
352                        values.insert("artist".into(), Value::text(a.clone()));
353                    }
354                    (None, None, Some(url)) => {
355                        values.insert("combo".into(), Value::text(url.clone()));
356                    }
357                    _ => (),
358                }
359                if let Some(volume) = player.volume {
360                    values.insert(
361                        "volume_icon".into(),
362                        Value::icon_progression("volume", volume),
363                    );
364                    values.insert("volume".into(), Value::percents(volume * 100.0));
365                }
366                let mut widget = Widget::new().with_format(format.clone());
367                widget.set_values(values);
368                widget.state = state;
369                api.set_widget(widget)?;
370            }
371            None => {
372                let mut widget = Widget::new().with_format(format.clone());
373                widget.set_values(map!("icon" => Value::icon("music")));
374                api.set_widget(widget)?;
375            }
376        }
377
378        loop {
379            select! {
380                Some(msg) = properties_stream.next() => {
381                    let msg = msg.unwrap();
382                    let msg = PropertiesChanged::from_message(msg).unwrap();
383                    let args = msg.args().unwrap();
384                    let header = msg.message().header();
385                    let sender = header.sender().unwrap();
386                    if let Some((pos, player)) = players.iter_mut().enumerate().find(|p| &*p.1.owner == sender) {
387                        let props = args.changed_properties;
388                        if let Some(status) = props.get("PlaybackStatus") {
389                            let status: &str = status.downcast_ref().unwrap();
390                            player.status = PlaybackStatus::from_str(status);
391                        }
392                        if let Some(metadata) = props.get("Metadata") {
393                            player.metadata =
394                                zbus_mpris::PlayerMetadata::try_from(metadata.try_to_owned().unwrap()).unwrap();
395                        }
396                        if let Some(volume) = props.get("Volume") {
397                            player.volume = Some(*volume.downcast_ref::<&f64>().unwrap());
398                        }
399                        if player.status == Some(PlaybackStatus::Playing)
400                        && (
401                            player.metadata.title.is_some()
402                            || player.metadata.artist.is_some()
403                            || player.metadata.url.is_some()
404                        ) {
405                            cur_player = Some(pos);
406                        }
407                        break;
408                    }
409                }
410                Some(msg) = name_owner_changed_stream.next() => {
411                    let msg = msg.unwrap();
412                    let msg = NameOwnerChanged::from_message(msg).unwrap();
413                    let args = msg.args().unwrap();
414                    match (args.old_owner.as_ref(), args.new_owner.as_ref()) {
415                        (None, Some(new)) => {
416                            debug!("new player {} owned by {new}", args.name);
417                            if player_matches(args.name.as_str(), &preferred_players, &exclude_regex) {
418                                match Player::new(&dbus_conn, args.name.to_owned().into(), new.to_owned().into()).await {
419                                    Ok(player) => players.push(player),
420                                    Err(e) => {
421                                        debug!("{e}");
422                                    },
423                                }
424                            }
425                        }
426                        (Some(old), None) => {
427                            if let Some(pos) = players.iter().position(|p| &*p.owner == old) {
428                                debug!("removed player {} owned by {old}", args.name);
429                                players.remove(pos);
430                                if let Some(cur) = cur_player {
431                                    if players.is_empty() {
432                                        cur_player = None;
433                                    } else if pos == cur {
434                                        cur_player = Some(0);
435                                    } else if pos < cur {
436                                        cur_player = Some(cur - 1);
437                                    }
438                                }
439                            }
440                        }
441                        _ => (),
442                    }
443                    break;
444                }
445                Some(msg) = active_player_change_end_stream.next() => {
446                    let args = msg.args().unwrap();
447                    if let Some(pos) = players.iter().position(|p| p.bus_name == args.name){
448                        cur_player = Some(pos);
449                    }
450                    else{
451                        // We must have shifted to a player we wanted to skip (on the interface_name_exclude list).
452                        // Let's shift again
453                        if let Err(e) = playerctld_proxy.shift().await{
454                            debug!("{e}");
455                        }
456                    }
457                    break;
458                }
459                Some(action) = actions.recv() => {
460                    if let Some(i) = cur_player {
461                        let player = &players[i];
462                        match action.as_ref() {
463                            "play_pause" => {
464                                player.play_pause().await?;
465                            }
466                            "next" => {
467                                player.next().await?;
468                            }
469                            "prev" => {
470                                player.prev().await?;
471                            }
472                            "next_player" => {
473                                cur_player = Some((i + 1) % players.len());
474                                if let Err(e) = playerctld_proxy.shift().await{
475                                    debug!("{e}");
476                                }
477                                break;
478                            }
479                            "seek_forward" => {
480                                player.seek(seek_forward_step).await?;
481                            }
482                            "seek_backward" => {
483                                player.seek(seek_backward_step).await?;
484                            }
485                            "volume_up" => {
486                                player.set_volume(volume_step).await?;
487                            }
488                            "volume_down" => {
489                                player.set_volume(-volume_step).await?;
490                            }
491                            "toggle_format" => {
492                                if let Some(format_alt) = &mut format_alt {
493                                    std::mem::swap(format_alt, &mut format);
494                                    break;
495                                }
496                            }
497                            _ => (),
498                        }
499                    }
500                }
501            }
502        }
503    }
504}
505
506async fn get_players(
507    dbus_conn: &zbus::Connection,
508    preferred_players: &[String],
509    exclude_regex: &[Regex],
510) -> Result<Vec<Player>> {
511    let proxy = DBusProxy::new(dbus_conn)
512        .await
513        .error("failed to create DBusProxy")?;
514    let names = proxy
515        .list_names()
516        .await
517        .error("failed to list dbus names")?;
518    let mut players = Vec::new();
519    for name in names {
520        if player_matches(name.as_str(), preferred_players, exclude_regex) {
521            let owner = proxy.get_name_owner(name.as_ref()).await.unwrap();
522            match Player::new(dbus_conn, name, owner).await {
523                Ok(player) => players.push(player),
524                Err(e) => {
525                    debug!("{e}");
526                }
527            }
528        }
529    }
530    Ok(players)
531}
532
533#[derive(Debug)]
534struct Player {
535    status: Option<PlaybackStatus>,
536    owner: OwnedUniqueName,
537    bus_name: OwnedBusName,
538    player_proxy: zbus_mpris::PlayerProxy<'static>,
539    metadata: zbus_mpris::PlayerMetadata,
540    volume: Option<f64>,
541}
542
543impl Player {
544    async fn new(
545        dbus_conn: &zbus::Connection,
546        bus_name: OwnedBusName,
547        owner: OwnedUniqueName,
548    ) -> Result<Player> {
549        debug!("creating Player for {bus_name}");
550
551        let proxy = zbus_mpris::PlayerProxy::builder(dbus_conn)
552            .destination(bus_name.clone())
553            .error("failed to set proxy destination")?
554            .build()
555            .await
556            .error("failed to open player proxy")?;
557
558        // debug!("querying player info");
559        // let (metadata, status, volume) =
560        //     tokio::join!(proxy.metadata(), proxy.playback_status(), proxy.volume());
561        debug!("querying player metadata");
562        let metadata = proxy.metadata().await;
563        debug!("querying player status");
564        let status = proxy.playback_status().await;
565        debug!("querying player volume");
566        let volume = proxy.volume().await;
567
568        let metadata = metadata.error("failed to obtain player metadata")?;
569        let status = status.error("failed to obtain player status")?;
570
571        debug!("Player created");
572
573        Ok(Self {
574            status: PlaybackStatus::from_str(&status),
575            owner,
576            bus_name,
577            player_proxy: proxy,
578            metadata,
579            volume: volume.ok(),
580        })
581    }
582
583    async fn play_pause(&self) -> Result<()> {
584        self.player_proxy
585            .play_pause()
586            .await
587            .error("play_pause() failed")
588    }
589
590    async fn prev(&self) -> Result<()> {
591        self.player_proxy.previous().await.error("prev() failed")
592    }
593
594    async fn next(&self) -> Result<()> {
595        self.player_proxy.next().await.error("next() failed")
596    }
597
598    async fn seek(&self, offset: i64) -> Result<()> {
599        match self.player_proxy.seek(offset).await {
600            Err(zbus::Error::MethodError(e, _, _))
601                if e == "org.freedesktop.DBus.Error.NotSupported" =>
602            {
603                // TODO show this error somehow
604                Ok(())
605            }
606            other => dbg!(other).error("seek() failed"),
607        }
608    }
609
610    async fn set_volume(&self, step_size: f64) -> Result<()> {
611        if let Some(volume) = self.volume {
612            self.player_proxy
613                .set_volume(volume + step_size)
614                .await
615                .error("set_volume() failed")?;
616        }
617        Ok(())
618    }
619}
620
621impl fmt::Display for Player {
622    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
623        extract_player_name(&self.bus_name).unwrap().fmt(f)
624    }
625}
626
627#[derive(Debug, Clone, Copy, PartialEq, Eq)]
628enum PlaybackStatus {
629    Playing,
630    Paused,
631    Stopped,
632}
633
634impl PlaybackStatus {
635    fn from_str(s: &str) -> Option<Self> {
636        match s {
637            "Paused" => Some(Self::Paused),
638            "Playing" => Some(Self::Playing),
639            "Stopped" => Some(Self::Stopped),
640            _ => None,
641        }
642    }
643}
644
645fn extract_player_name(full_name: &str) -> Option<&str> {
646    const NAME_PREFIX: &str = "org.mpris.MediaPlayer2.";
647    full_name
648        .starts_with(NAME_PREFIX)
649        .then(|| &full_name[NAME_PREFIX.len()..])
650}
651
652fn player_matches(full_name: &str, preferred_players: &[String], exclude_regex: &[Regex]) -> bool {
653    let name = match extract_player_name(full_name) {
654        Some(name) => name,
655        None => return false,
656    };
657
658    exclude_regex.iter().all(|r| !r.is_match(name))
659        && (preferred_players.is_empty()
660            || preferred_players.iter().any(|p| name.starts_with(&**p)))
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666
667    #[test]
668    fn extract_player_name_test() {
669        assert_eq!(
670            extract_player_name("org.mpris.MediaPlayer2.firefox.instance852"),
671            Some("firefox.instance852")
672        );
673        assert_eq!(
674            extract_player_name("not.org.mpris.MediaPlayer2.firefox.instance852"),
675            None,
676        );
677        assert_eq!(
678            extract_player_name("org.mpris.MediaPlayer3.firefox.instance852"),
679            None,
680        );
681    }
682
683    #[test]
684    fn player_matches_test() {
685        let exclude = vec![Regex::new("mpd").unwrap(), Regex::new("firefox.*").unwrap()];
686        assert!(player_matches(
687            "org.mpris.MediaPlayer2.playerctld",
688            &[],
689            &exclude
690        ));
691        assert!(!player_matches(
692            "org.mpris.MediaPlayer2.playerctld",
693            &["spotify".into()],
694            &exclude
695        ));
696        assert!(!player_matches(
697            "org.mpris.MediaPlayer2.firefox.instance852",
698            &[],
699            &exclude
700        ));
701    }
702}