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