diff --git a/hm/desktop/frobar/.dev/new.py b/hm/desktop/frobar/.dev/new.py index 31a1f09..8acecc0 100644 --- a/hm/desktop/frobar/.dev/new.py +++ b/hm/desktop/frobar/.dev/new.py @@ -9,8 +9,10 @@ import logging import os import signal import socket +import time import typing +import gi import i3ipc import i3ipc.aio import psutil @@ -20,6 +22,11 @@ import rich.color import rich.logging import rich.terminal_theme +gi.require_version("Playerctl", "2.0") + +import gi.repository.GLib +import gi.repository.Playerctl + logging.basicConfig( level="DEBUG", format="%(message)s", @@ -58,6 +65,12 @@ def ramp(p: float, states: str = " ▁▂▃▄▅▆▇█") -> str: return states[-1] * int(d) + states[round(m * (len(states) - 1))] +def clip(text: str, length: int = 30) -> str: + if len(text) > length: + text = text[: length - 1] + "…" + return text + + class ComposableText(typing.Generic[P, C]): def __init__( @@ -140,7 +153,7 @@ class Section(ComposableText): # Geometric series, with a cap ANIM_A = 0.025 ANIM_R = 0.9 - ANIM_MIN = 0.005 + ANIM_MIN = 0.001 async def animate(self) -> None: increment = 1 if self.size < self.targetSize else -1 @@ -636,7 +649,7 @@ class I3WorkspacesProvider(MultiSectionsProvider): else: section.color = self.COLOR_DEFAULT if workspace.focused or workspace.visible: - name = f"{name} X" # TODO Custom names + name = f"{name} X" # FIXME Custom names section.setText(name) return update @@ -680,6 +693,166 @@ class I3WorkspacesProvider(MultiSectionsProvider): await self.i3.main() +class MprisProvider(MirrorProvider): + + STATUSES = { + gi.repository.Playerctl.PlaybackStatus.PLAYING: "", + gi.repository.Playerctl.PlaybackStatus.PAUSED: "", + gi.repository.Playerctl.PlaybackStatus.STOPPED: "", + } + + PROVIDERS = { + "mpd": "", + "firefox": "", + "chromium": "", + "mpv": "", + } + + @staticmethod + def get( + something: gi.overrides.GLib.Variant, key: str, default: typing.Any = None + ) -> typing.Any: + if key in something.keys(): + return something[key] + else: + return default + + @staticmethod + def format_us(ms: int) -> str: + if ms < 60 * 60 * 1000000: + return time.strftime("%M:%S", time.gmtime(ms // 1000000)) + else: + return str(datetime.timedelta(microseconds=ms)) + + def show_player(self, player_: gi.repository.Playerctl.Player) -> None: + player = player_.props.player_name + player = self.PROVIDERS.get(player, player) + status = self.STATUSES.get(player_.props.playback_status, "?") + self.status.setText(f"{player} {status}") + + metadata = player_.props.metadata + + album = self.get(metadata, "xesam:album") + if album: + self.album.setText(f" {clip(album)}") + else: + self.album.setText(None) + + artists = self.get(metadata, "xesam:artist") + if artists: + artist = ", ".join(artists) + self.artist.setText(f" {clip(artist)}") + else: + self.artist.setText(None) + + pos = player_.props.position # In µs + # FIXME Doesn't increment during play + text = f" {self.format_us(pos)}" + dur = self.get(metadata, "mpris:length") + if dur: + text += f"/{self.format_us(dur)}" + title = self.get(metadata, "xesam:title") + if title: + text += f" {clip(title)}" + self.title.setText(text) + + def show_players(self, manager: gi.repository.Playerctl.PlayerManager, exclude: str | None = None) -> None: + # FIXME Opening and closing a chromium player, it will stay there (as default), + # which is not the behaviour of playerctl somehow + for name in manager.props.player_names: + if name == exclude: # DEBUG + continue + player = gi.repository.Playerctl.Player.new_from_name(name) + self.show_player(player) + break + else: + self.status.setText(None) + self.album.setText(None) + self.artist.setText(None) + self.title.setText(None) + + def on_player_appear( + self, + manager: gi.repository.Playerctl.PlayerManager, + player: gi.repository.Playerctl.Player, + ) -> None: + log.debug(f"Player appeared: {player.props.player_name}") + self.show_player(player) + + def on_player_vanished( + self, + manager: gi.repository.Playerctl.PlayerManager, + player: gi.repository.Playerctl.Player, + ) -> None: + log.debug(f"Player vanished: {player.props.player_name}") + self.show_player(player) + + def on_event( + self, + player: gi.repository.Playerctl.Player, + _: typing.Any, + manager: gi.repository.Playerctl.PlayerManager, + ) -> None: + log.debug(f"Player evented: {player.props.player_name}") + self.show_player(player) + + def init_player( + self, manager: gi.repository.Playerctl.PlayerManager, name: str + ) -> None: + player = gi.repository.Playerctl.Player.new_from_name(name) + # All events will cause the active player to change, + # so we listen on all events, even if the display won't change + player.connect("playback-status", self.on_event, manager) + player.connect("loop-status", self.on_event, manager) + player.connect("shuffle", self.on_event, manager) + player.connect("metadata", self.on_event, manager) + player.connect("volume", self.on_event, manager) + player.connect("seeked", self.on_event, manager) + manager.manage_player(player) + + def on_name_appeared( + self, manager: gi.repository.Playerctl.PlayerManager, name: str + ) -> None: + log.debug(f"Player name appeared: {name}") + self.init_player(manager, name) + + def on_name_vanished( + self, + manager: gi.repository.Playerctl.PlayerManager, + name: str, + ) -> None: + log.debug(f"Player name vanished: {name}") + self.show_players(manager, exclude=name) + + def blocking(self) -> None: + manager = gi.repository.Playerctl.PlayerManager() + manager.connect("name-appeared", self.on_name_appeared) + manager.connect("player-appeared", self.on_player_appear) + manager.connect("name-vanished", self.on_name_vanished) + manager.connect("player-vanished", self.on_player_vanished) + + for name in manager.props.player_names: + self.init_player(manager, name) + + self.show_players(manager) + + # FIXME Prevents graceful shutdown + main = gi.repository.GLib.MainLoop() + main.run() + + async def run(self) -> None: + await super().run() + self.status = self.sectionType(parent=self.module, color=self.color) + self.album = self.sectionType(parent=self.module, color=self.color) + self.artist = self.sectionType(parent=self.module, color=self.color) + self.title = self.sectionType(parent=self.module, color=self.color) + + # PyGObject can work with async code, but is experimental, + # also it running in its own event loop makes things tricky + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, self.blocking) + + class AlertingProvider(Provider): COLOR_NORMAL = rich.color.Color.parse("green") COLOR_WARNING = rich.color.Color.parse("yellow") @@ -1089,7 +1262,7 @@ async def main() -> None: alignment=Alignment.CENTER, ) bar.addProvider( - StaticProvider(text="mpris", color=color("bright_white")), + MprisProvider(color=color("bright_white")), screenNum=rightPreferred, alignment=Alignment.CENTER, ) diff --git a/hm/desktop/frobar/default.nix b/hm/desktop/frobar/default.nix index 2bb804f..b966486 100644 --- a/hm/desktop/frobar/default.nix +++ b/hm/desktop/frobar/default.nix @@ -30,8 +30,9 @@ pkgs.python3Packages.buildPythonApplication rec { mpd2 notmuch psutil - pulsectl # old only pulsectl-asyncio + pulsectl # old only + pygobject3 pyinotify rich ]; @@ -43,5 +44,7 @@ pkgs.python3Packages.buildPythonApplication rec { ]); makeWrapperArgs = [ "--prefix PATH : ${pkgs.lib.makeBinPath nativeBuildInputs}" ]; + GI_TYPELIB_PATH = pkgs.lib.makeSearchPath "lib/girepository-1.0" [ pkgs.glib.out pkgs.playerctl ]; + src = ./.; }