Repository: ultrabug/py3status

Issue:
[Proposal] playerctl module
**Is your feature request related to a problem? Please describe.**
There currently is no general media player module. Py3status has `cmus` and `player_control`, but `playerctl` would provide a more general solution.

**Your py3status version**
3.5.0

**Describe the solution you'd like**
A playerctl module, similar to cmus, that allows you to control media players. 

**Describe alternatives you've considered**
Extending a current module could work, but it might be better to create a new one using playerctl.  

**Additional context**
I have created my own minimum working version of a playerctl module (the code is very similar to cmus). I could definitely create a PR for it once I make it more accessible to users. This module would not replace the cmus module because the cmus module provides a lot more information for cmus specifically. However, it could replace the player_control module (or potentially other media modules).

This was mentioned previously in #1099 and #1797, but no further actions were taken.


Discussion:
Make a PR when you're ready. :+1: 
Hey @lasers, I have a few different approaches to this, each with their pros and cons, and I wanted to get your opinion on the best approach for py3status.

1. Get the metadata of the most recently active player. Then, parse it and display the information based on `self.format` -- this would be very close to how the cmus module works. Two commands need to be executed (one for the metadata and one for the status)
- A disadvantage of this is that not all desired metadata is actually output by playerctl (ex: position in the song)
2. Use the `--format` option that is native to playerctl. The user would specify their format for each player (in a dictionary format: {"player1": "format1", "player2": "format2"}. This makes the format more customizable for each player because different data is available for each player
- An advantage of this is users can directly use the formatting functions available from playerctl. Additionally, more data about the player is available (such as position)
- A disadvantage of this is that it needs 3 commands to execute per cycle (one to get the currently active player so we know what format to use, another to get the metadata, and the last to get the status)
3. Use the Playerctl Python bindings that are a part of PyGObject. This would be the most customizable and robust way as no external commands need to be executed
- However, a disadvantage is that the module will need to run a never-ending loop in order to process the event handlers that are necessary for this implementation. This can either be done with `asyncio` or `threading` in order to not block py3status from updating the module. I'm hesitant on this, though, because it is unclear to me if running in another thread can negatively affect py3status.

I'd love to hear if you have any other suggestions. Thanks!
>Use the Playerctl Python bindings that are a part of PyGObject. This would be the most customizable and robust way as no external commands need to be executed

My opinion is to try for 3) first, https://github.com/altdesktop/playerctl/blob/master/examples/player-manager.py, to just handle all possible players including new ones in future.

- Make config `format = '{format_player}'`.
- Make config something like `format_player = '{name} {title} {track} etc'` for all players. Unavailable placeholders in some players can always be hidden in brackets (ie `[{position`) if necessary.
- All found instances would be exposed / connected. 
- I think users wouldn't care about per-player settings and I imagine if you want to do per-player format, then you will have to make default player format too... which could be a little messy / repetitive.

The hard part is also to make play/pause/stop buttons work in all instances too. #Ignoreme. 

Resolving patch:
diff --git a/py3status/modules/playerctl.py b/py3status/modules/playerctl.py
new file mode 100644
--- /dev/null
+++ b/py3status/modules/playerctl.py
@@ -0,0 +1,354 @@
+r"""
+Display song/video and control players supported by playerctl
+
+Playerctl is a command-line utility for controlling media players 
+that implement the MPRIS D-Bus Interface Specification. With Playerctl
+you can bind player actions to keys and get metadata about the currently
+playing song or video.
+
+Configuration parameters:
+    button_loop: mouse button to cycle the loop status of the player (default None)
+    button_next: mouse button to skip to the next track (default None)
+    button_pause: mouse button to pause the playback (default None)
+    button_play: mouse button to play the playback (default None)
+    button_play_pause: mouse button to play/pause the playback (default 1)
+    button_previous: mouse button to skip to the previous track (default None)
+    button_seek_forward: mouse button to playback's position forward (default None)
+    button_seek_backward: mouse button to playback's position backward (default None)
+    button_stop: mouse button to stop the playback (default 3)
+    button_shuffle: mouse button to toggle the shuffle mode of the player (default None)
+    button_volume_up: mouse button to increase the volume of the player
+    button_volume_down: mouse button to decrease the volume of the player
+    format: display format for this module (default '{format_player}')
+    format_player: display format for players
+        *(default '[\?color=status [\?if=status=Playing > ][\?if=status=Paused \|\| ]'
+        '[\?if=status=Stopped .. ][[{artist}][\?soft  - ][{title}|{player}]]]')*
+    format_player_separator: show separator if more than one player (default ' ')
+    players: list of players to track. An empty list tracks all players (default [])
+    seek_delta: time (in seconds) to change the playback's position by (default 5)
+    thresholds: specify color thresholds to use for different placeholders
+        (default {"status": [("Playing", "good"), ("Paused", "degraded"), ("Stopped", "bad")]})
+    volume_delta: percentage (from 0 to 100) to change the player's volume by (default 10)
+
+    Not all players support every button action
+
+Format placeholders:
+    {format_player} format for players
+
+Format player placeholders:
+    {album} album name
+    {artist} artist name
+    {duration} length of track/video in [HH:]MM:SS, e.g. 03:22
+    {loop} loop status of the player, e.g. None, playlist, Track
+    {player} name of the player
+    {position} elapsed time in [HH:]MM:SS, e.g. 00:17
+    {shuffle} boolean indicating if the player's shuffle mode is on
+    {status} playback status, e.g. Playing, Paused, Stopped
+    {title} track/video title
+    {trackNumber} position of the track in the album or playlist
+    {volume} volume level of the player from 0 to 100
+
+    Not all media players support every placeholder
+
+Requires:
+    playerctl: mpris media player controller and lib for spotify, vlc, audacious,
+        bmp, xmms2, and others.
+
+@author jdholtz
+
+SAMPLE OUTPUT
+{'color': '#00FF00', 'full_text': '> Don’t Eat the Yellow Snow - Frank Zappa'}
+
+Paused
+{'color': '#FFFF00', 'full_text': '|| Too Much Skunk Tonight - Birdy Nam Nam'}
+
+Stopped
+{'color': '#FF0000', 'full_text': '.. This Song Has No Title - Elton John'}
+"""
+import time
+from fnmatch import fnmatch
+from threading import Thread
+
+import gi
+
+gi.require_version("Playerctl", "2.0")
+from gi.repository import GLib, Playerctl
+
+
+class Py3status:
+    """ """
+
+    # available configuration parameters
+    button_loop = None
+    button_next = None
+    button_pause = None
+    button_play = None
+    button_play_pause = 1
+    button_previous = None
+    button_seek_forward = None
+    button_seek_backward = None
+    button_stop = 3
+    button_shuffle = None
+    button_volume_up = None
+    button_volume_down = None
+    format = "{format_player}"
+    format_player = (
+        r"[\?color=status [\?if=status=Playing > ][\?if=status=Paused \|\| ]"
+        r"[\?if=status=Stopped .. ][[{artist}][\?soft  - ][{title}|{player}]]]"
+    )
+    format_player_separator = " "
+    players = []
+    seek_delta = 5
+    thresholds = {
+        "status": [("Playing", "good"), ("Paused", "degraded"), ("Stopped", "bad")]
+    }
+    volume_delta = 10
+
+    class Meta:
+        update_config = {
+            "update_placeholder_format": [
+                {
+                    # Escape the title by default because many contain special characters
+                    "placeholder_formats": {"title": ":escape"},
+                    "format_strings": ["format_player"],
+                }
+            ]
+        }
+
+    def post_config_hook(self):
+        self.thresholds_init = self.py3.get_color_names_list(self.format_player)
+        self.position = self.py3.format_contains(self.format_player, "position")
+        self.cache_timeout = getattr(self, "cache_timeout", 1)
+
+        # Initialize the manager + event listeners
+        self.manager = Playerctl.PlayerManager()
+        self.manager.connect("name-appeared", self._on_name_appeared)
+        self.manager.connect("name-vanished", self._on_name_vanished)
+
+        # Initialize all currently active players
+        for player_name in self.manager.props.player_names:
+            self._init_player(player_name)
+
+        self._start_loop()
+
+    def _player_should_be_tracked(self, player_name):
+        for _filter in self.players:
+            if fnmatch(player_name.name, _filter):
+                return True
+
+        return len(self.players) == 0
+
+    def _init_player(self, player_name):
+        if not self._player_should_be_tracked(player_name):
+            return
+
+        player = Playerctl.Player.new_from_name(player_name)
+
+        # Initialize player event listeners
+        player.connect("loop-status", self._loop_status_changed, self.manager)
+        player.connect("metadata", self._on_metadata, self.manager)
+        player.connect("playback-status", self._status_changed, self.manager)
+        player.connect("seeked", self._on_seeked, self.manager)
+        player.connect("shuffle", self._shuffle_changed, self.manager)
+        player.connect("volume", self._volume_changed, self.manager)
+
+        self.manager.manage_player(player)
+
+    def _start_loop(self):
+        loop = GLib.MainLoop()
+
+        # The loop is blocking so it needs to be run a separate thread
+        thread = Thread(target=loop.run)
+        thread.daemon = True
+        thread.start()
+
+    def _on_name_appeared(self, manager, player_name):
+        self._init_player(player_name)
+
+    def _on_name_vanished(self, manager, name):
+        # Add a very small delay to give Playerctl time to remove the player
+        time.sleep(0.01)
+        self.py3.update()
+
+    def _loop_status_changed(self, player, loop_status, manager):
+        self.py3.update()
+
+    def _on_metadata(self, player, metadata, manager):
+        self.py3.update()
+
+    def _status_changed(self, player, status, manager):
+        self.py3.update()
+
+    def _on_seeked(self, player, position, manager):
+        self.py3.update()
+
+    def _shuffle_changed(self, player, shuffle, manager):
+        self.py3.update()
+
+    def _volume_changed(self, player, volume, manager):
+        self.py3.update()
+
+    @staticmethod
+    def _microseconds_to_time(microseconds):
+        seconds = microseconds // 1_000_000
+        m, s = divmod(seconds, 60)
+        h, m = divmod(m, 60)
+        time = f"{h}:{m:02d}:{s:02d}"
+        return time.lstrip("0").lstrip(":")
+
+    def _get_player_position(self, player):
+        try:
+            # Playerctl doesn't support getting positions for all players
+            microseconds = player.get_position()
+            position = self._microseconds_to_time(microseconds)
+        except GLib.GError:
+            position = None
+
+        return position
+
+    def _set_data_from_metadata(self, player, data):
+        """Set any data retrieved directly from the metadata for a player"""
+        metadata = dict(player.props.metadata)
+
+        data["trackNumber"] = metadata.get("xesam:trackNumber")
+
+        duration_ms = metadata.get("mpris:length")
+        if duration_ms:
+            data["duration"] = self._microseconds_to_time(duration_ms)
+        else:
+            data["duration"] = None
+
+    def _get_player_data(self, player):
+        data = {}
+
+        # Song attributes
+        data["album"] = player.get_album()
+        data["artist"] = player.get_artist()
+        data["title"] = player.get_title()
+        data["position"] = self._get_player_position(player)
+        self._set_data_from_metadata(player, data)
+
+        # Player attributes
+        data["player"] = player.props.player_name
+        data["loop"] = player.props.loop_status.value_nick
+        data["shuffle"] = player.props.shuffle
+        data["status"] = player.props.status
+        data["volume"] = int(player.props.volume * 100)
+
+        return data
+
+    def _get_player_from_index(self, index):
+        for player in self.manager.props.players:
+            if player.props.player_name == index:
+                return player
+
+        return None
+
+    def _change_player_volume(self, player, volume_factor):
+        volume_change = volume_factor * self.volume_delta / 100
+        new_volume = player.props.volume + volume_change
+        try:
+            # Playerctl can't set the volume for every player
+            player.set_volume(new_volume)
+        except GLib.GError:
+            pass
+
+    def _cycle_player_loop_status(self, player):
+        new_loop_status = (player.props.loop_status + 1) % 3
+        try:
+            # Not all players support setting the loop status
+            player.set_loop_status(new_loop_status)
+        except GLib.GError:
+            pass
+
+    def _toggle_player_shuffle(self, player):
+        try:
+            # Not all players support setting the shuffle mode
+            player.set_shuffle(not player.props.shuffle)
+        except GLib.GError:
+            pass
+
+    def playerctl(self):
+        tracked_players = self.manager.props.players
+
+        players = []
+        cached_until = self.py3.CACHE_FOREVER
+        for player in tracked_players:
+            player_data = self._get_player_data(player)
+
+            # Check if the player should cause the module to continuously update
+            if (
+                self.position
+                and player_data["status"] == "Playing"
+                and player_data["position"]
+            ):
+                cached_until = self.cache_timeout
+
+            # Set the color of a player
+            for key in self.thresholds_init:
+                if key in player_data:
+                    self.py3.threshold_get_color(player_data[key], key)
+
+            format_player = self.py3.safe_format(self.format_player, player_data)
+            self.py3.composite_update(format_player, {"index": player_data["player"]})
+
+            players.append(format_player)
+
+        format_player_separator = self.py3.safe_format(self.format_player_separator)
+        format_players = self.py3.composite_join(format_player_separator, players)
+
+        return {
+            "cached_until": self.py3.time_in(cached_until),
+            "full_text": self.py3.safe_format(
+                self.format, {"format_player": format_players}
+            ),
+        }
+
+    def on_click(self, event):
+        """
+        Control playerctl with mouse clicks.
+        """
+        button = event["button"]
+        index = event["index"]
+
+        # Always prevent a refresh because this module updates whenever
+        # a player's status or metadata changes
+        self.py3.prevent_refresh()
+
+        player = self._get_player_from_index(index)
+        if not player or not player.props.can_control:
+            return
+
+        if button == self.button_play and player.props.can_play:
+            player.play()
+        elif button == self.button_pause and player.props.can_pause:
+            player.pause()
+        elif button == self.button_play_pause and player.props.can_play:
+            player.play_pause()
+        elif button == self.button_stop:
+            player.stop()
+        elif button == self.button_next and player.props.can_go_next:
+            player.next()
+        elif button == self.button_previous and player.props.can_go_previous:
+            player.previous()
+        elif button == self.button_seek_forward and player.props.can_seek:
+            player.seek(self.seek_delta * 10**6)
+        elif button == self.button_seek_backward and player.props.can_seek:
+            player.seek(self.seek_delta * -1 * 10**6)
+        elif button == self.button_volume_up:
+            self._change_player_volume(player, 1)
+        elif button == self.button_volume_down:
+            self._change_player_volume(player, -1)
+        elif button == self.button_loop:
+            self._cycle_player_loop_status(player)
+        elif button == self.button_shuffle:
+            self._toggle_player_shuffle(player)
+
+
+if __name__ == "__main__":
+    """
+    Run module in test mode.
+    """
+    from py3status.module_test import module_test
+
+    module_test(Py3status)


Test patch: