Skip to main content

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