From 81e1a1fc1fbc52e10718e9d599032666abfd867c Mon Sep 17 00:00:00 2001 From: Makesesama Date: Tue, 5 May 2026 23:15:09 +0200 Subject: [PATCH] feat: mpris player widget --- sims/modules/bar.py | 4 +- sims/modules/player.py | 292 +++++++++++++++++++++++------------------ sims/modules/stylix.py | 37 ++++++ sims/services/mpris.py | 11 +- sims/styles/bar.css | 36 +++++ 5 files changed, 251 insertions(+), 129 deletions(-) diff --git a/sims/modules/bar.py b/sims/modules/bar.py index 1200609..8e8e321 100644 --- a/sims/modules/bar.py +++ b/sims/modules/bar.py @@ -4,7 +4,7 @@ from fabric.widgets.image import Image from fabric.widgets.overlay import Overlay from fabric.widgets.datetime import DateTime from fabric.widgets.centerbox import CenterBox -from sims.modules.player import Player +from sims.modules.player import Player, PlayerSmall from sims.modules.battery import Battery from sims.modules.control_center import ControlCenter from sims.modules.notmuch import NotmuchWidget @@ -82,6 +82,7 @@ class StatusBar(Window): overlays=[self.cpu_progress_bar, self.progress_label], ) self.player = Player() + self.player_small = PlayerSmall() self.battery = None if BATTERY["enable"]: @@ -132,6 +133,7 @@ class StatusBar(Window): children=[ Image(name="nixos-label", icon_name="nix-snowflake-white", icon_size=20), self.workspaces, + self.player_small, ], ), center_children=Box( diff --git a/sims/modules/player.py b/sims/modules/player.py index f55da95..9a083b2 100644 --- a/sims/modules/player.py +++ b/sims/modules/player.py @@ -7,6 +7,7 @@ from fabric.widgets.box import Box from fabric.widgets.centerbox import CenterBox from fabric.widgets.label import Label from fabric.widgets.button import Button +from fabric.widgets.image import Image from fabric.widgets.circularprogressbar import CircularProgressBar from fabric.widgets.overlay import Overlay from fabric.widgets.stack import Stack @@ -522,30 +523,52 @@ class Player(Box): return False -class PlayerSmall(CenterBox): +class PlayerSmall(Box): + PLAY_ICON = "media-playback-start-symbolic" + PAUSE_ICON = "media-playback-pause-symbolic" + PREV_ICON = "media-skip-backward-symbolic" + NEXT_ICON = "media-skip-forward-symbolic" + FALLBACK_ICON = "audio-x-generic-symbolic" + COVER_SIZE = 22 + def __init__(self): super().__init__( - name="player-small", orientation="h", h_align="fill", v_align="center" + name="player-small", orientation="h", v_align="center" ) - self._show_artist = False # toggle flag - self._display_options = ["cavalcade", "title", "artist"] + self._display_options = ["title", "artist"] self._display_index = 0 - self._current_display = "cavalcade" + self._current_display = "title" + self._spin_timer_id = None + self._spin_step = 2 # deg per tick → ~9s per rotation at 50ms + self._spin_interval_ms = 50 + + self.cover = CircleImage( + name="compact-mpris-cover", + size=self.COVER_SIZE, + h_align="center", + v_align="center", + ) + self.fallback_icon = Image( + name="compact-mpris-fallback", + icon_name=self.FALLBACK_ICON, + icon_size=16, + ) + self.cover_stack = Stack( + name="compact-mpris-cover-stack", + transition_type="crossfade", + transition_duration=200, + children=[self.fallback_icon, self.cover], + ) + self.cover_stack.set_visible_child(self.fallback_icon) self.mpris_icon = Button( name="compact-mpris-icon", h_align="center", v_align="center", - child=Label(name="compact-mpris-icon-label", markup=icons.disc), + child=self.cover_stack, ) - # Remove scroll events; instead, add button press events. self.mpris_icon.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) self.mpris_icon.connect("button-press-event", self._on_icon_button_press) - # Prevent the child from propagating events. - child = self.mpris_icon.get_child() - child.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) - child.connect("button-press-event", lambda widget, event: True) - # Add hover effect add_hover_cursor(self.mpris_icon) self.mpris_label = Label( @@ -555,36 +578,33 @@ class PlayerSmall(CenterBox): max_chars_width=26, h_align="center", ) + + self.play_image = Image( + name="compact-mpris-button-icon", + icon_name=self.PLAY_ICON, + icon_size=16, + ) self.mpris_button = Button( name="compact-mpris-button", h_align="center", v_align="center", - child=Label(name="compact-mpris-button-label", markup=icons.play), + child=self.play_image, ) self.mpris_button.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) self.mpris_button.connect( "button-press-event", self._on_play_pause_button_press ) - # Add hover effect add_hover_cursor(self.mpris_button) - # self.cavalcade = SpectrumRender() - # self.cavalcade_box = self.cavalcade.get_spectrum_box() - self.center_stack = Stack( name="compact-mpris", transition_type="crossfade", transition_duration=100, v_align="center", v_expand=False, - children=[ - # self.cavalcade_box, - self.mpris_label, - ], + children=[self.mpris_label], ) - # self.center_stack.set_visible_child(self.cavalcade_box) # default to cavalcade - # Create additional compact view. self.mpris_small = CenterBox( name="compact-mpris", orientation="h", @@ -593,7 +613,7 @@ class PlayerSmall(CenterBox): v_align="center", v_expand=False, start_children=self.mpris_icon, - center_children=self.center_stack, # Changed to center_stack to handle stack switching + center_children=self.center_stack, end_children=self.mpris_button, ) @@ -601,157 +621,177 @@ class PlayerSmall(CenterBox): self.mpris_manager = MprisPlayerManager() self.mpris_player = None - # Almacenar el índice del reproductor actual self.current_index = 0 players = self.mpris_manager.players if players: mp = MprisPlayer(players[self.current_index]) self.mpris_player = mp - self._apply_mpris_properties() self.mpris_player.connect("changed", self._on_mpris_changed) - else: - self._apply_mpris_properties() + self._apply_mpris_properties() self.mpris_manager.connect("player-appeared", self.on_player_appeared) self.mpris_manager.connect("player-vanished", self.on_player_vanished) - self.mpris_button.connect("clicked", self._on_play_pause_clicked) + + def _set_cover_from_path(self, image_path): + if image_path and os.path.isfile(image_path): + try: + self.cover.set_image_from_file(image_path) + self.cover_stack.set_visible_child(self.cover) + self._update_spin() + return False + except Exception: + pass + self.cover_stack.set_visible_child(self.fallback_icon) + self._update_spin() + return False + + def _start_spin(self): + if self._spin_timer_id is None: + self._spin_timer_id = GLib.timeout_add( + self._spin_interval_ms, self._advance_spin + ) + + def _stop_spin(self): + if self._spin_timer_id is not None: + GLib.source_remove(self._spin_timer_id) + self._spin_timer_id = None + + def _advance_spin(self): + self.cover.angle = (self.cover.angle + self._spin_step) % 360 + return True + + def _update_spin(self): + showing_cover = self.cover_stack.get_visible_child() is self.cover + is_playing = ( + self.mpris_player is not None + and self.mpris_player.playback_status == "playing" + ) + if showing_cover and is_playing: + self._start_spin() + else: + self._stop_spin() + + def _download_artwork(self, arturl): + try: + parsed = urllib.parse.urlparse(arturl) + suffix = os.path.splitext(parsed.path)[1] or ".png" + with urllib.request.urlopen(arturl) as response: + data = response.read() + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) + tmp.write(data) + tmp.close() + local_path = tmp.name + except Exception: + local_path = None + GLib.idle_add(self._set_cover_from_path, local_path) + return None + + def _update_cover(self, mp): + if not mp or not mp.arturl: + self.cover_stack.set_visible_child(self.fallback_icon) + return + parsed = urllib.parse.urlparse(mp.arturl) + if parsed.scheme == "file": + self._set_cover_from_path(urllib.parse.unquote(parsed.path)) + elif parsed.scheme in ("http", "https"): + GLib.Thread.new("compact-artwork", self._download_artwork, mp.arturl) + else: + self._set_cover_from_path(mp.arturl) def _apply_mpris_properties(self): if not self.mpris_player: self.mpris_label.set_text("Nothing Playing") - self.mpris_button.get_child().set_markup(icons.stop) - self.mpris_icon.get_child().set_markup(icons.disc) - if self._current_display != "cavalcade": - self.center_stack.set_visible_child( - self.mpris_label - ) # if was title or artist, keep showing label - # else: - # self.center_stack.set_visible_child( - # self.cavalcade_box - # ) # default to cavalcade if no player + self.play_image.set_property("icon-name", self.PLAY_ICON) + self.cover_stack.set_visible_child(self.fallback_icon) + self.center_stack.set_visible_child(self.mpris_label) + self._update_spin() return mp = self.mpris_player - - # Choose icon based on player name. - player_name = ( - mp.player_name.lower() - if hasattr(mp, "player_name") and mp.player_name - else "" - ) - icon_markup = get_player_icon_markup_by_name(player_name) - self.mpris_icon.get_child().set_markup(icon_markup) + self._update_cover(mp) self.update_play_pause_icon() if self._current_display == "title": text = mp.title if mp.title and mp.title.strip() else "Nothing Playing" - self.mpris_label.set_text(text) - self.center_stack.set_visible_child(self.mpris_label) - elif self._current_display == "artist": - text = mp.artist if mp.artist else "Nothing Playing" - self.mpris_label.set_text(text) - self.center_stack.set_visible_child(self.mpris_label) - # else: # default cavalcade - # self.center_stack.set_visible_child(self.cavalcade_box) + else: # "artist" + text = mp.artist if mp.artist and mp.artist.strip() else "Nothing Playing" + self.mpris_label.set_text(text) + self.center_stack.set_visible_child(self.mpris_label) def _on_icon_button_press(self, widget, event): - from gi.repository import Gdk + if event.type != Gdk.EventType.BUTTON_PRESS: + return True + players = self.mpris_manager.players + if not players: + return True - if event.type == Gdk.EventType.BUTTON_PRESS: - players = self.mpris_manager.players - if not players: - return True - - if event.button == 2: # Middle-click: cycle display - self._display_index = (self._display_index + 1) % len( - self._display_options - ) - self._current_display = self._display_options[self._display_index] - self._apply_mpris_properties() # Re-apply to update label/cavalcade - return True - - # Cambiar de reproductor según el botón presionado. - if event.button == 1: # Left-click: next player - self.current_index = (self.current_index + 1) % len(players) - elif event.button == 3: # Right-click: previous player - self.current_index = (self.current_index - 1) % len(players) - if self.current_index < 0: - self.current_index = len(players) - 1 - - mp_new = MprisPlayer(players[self.current_index]) - self.mpris_player = mp_new - # Conectar el evento "changed" para que se actualice - self.mpris_player.connect("changed", self._on_mpris_changed) + if event.button == 2: + self._display_index = (self._display_index + 1) % len(self._display_options) + self._current_display = self._display_options[self._display_index] self._apply_mpris_properties() - return True # Se consume el evento + return True + + if event.button == 1: + self.current_index = (self.current_index + 1) % len(players) + elif event.button == 3: + self.current_index = (self.current_index - 1) % len(players) + else: + return True + + self.mpris_player = MprisPlayer(players[self.current_index]) + self.mpris_player.connect("changed", self._on_mpris_changed) + self._apply_mpris_properties() return True def _on_play_pause_button_press(self, widget, event): - if event.type == Gdk.EventType.BUTTON_PRESS: - if event.button == 1: # Click izquierdo -> track anterior - if self.mpris_player: - self.mpris_player.previous() - self.mpris_button.get_child().set_markup(icons.prev) - GLib.timeout_add(500, self._restore_play_pause_icon) - elif event.button == 3: # Click derecho -> siguiente track - if self.mpris_player: - self.mpris_player.next() - self.mpris_button.get_child().set_markup(icons.next) - GLib.timeout_add(500, self._restore_play_pause_icon) - elif event.button == 2: # Click medio -> play/pausa - if self.mpris_player: - self.mpris_player.play_pause() - self.update_play_pause_icon() + if event.type != Gdk.EventType.BUTTON_PRESS or not self.mpris_player: return True + if event.button == 1: + self.mpris_player.play_pause() + self.update_play_pause_icon() + elif event.button == 3: + self.mpris_player.next() + self.play_image.set_property("icon-name", self.NEXT_ICON) + GLib.timeout_add(500, self._restore_play_pause_icon) + elif event.button == 2: + self.mpris_player.previous() + self.play_image.set_property("icon-name", self.PREV_ICON) + GLib.timeout_add(500, self._restore_play_pause_icon) return True def _restore_play_pause_icon(self): self.update_play_pause_icon() return False - def _on_icon_clicked( - self, widget - ): # No longer used, logic moved to _on_icon_button_press - pass - def update_play_pause_icon(self): - if self.mpris_player and self.mpris_player.playback_status == "playing": - self.mpris_button.get_child().set_markup(icons.pause) - else: - self.mpris_button.get_child().set_markup(icons.play) - - def _on_play_pause_clicked(self, button): - if self.mpris_player: - self.mpris_player.play_pause() - self.update_play_pause_icon() + playing = ( + self.mpris_player + and self.mpris_player.playback_status == "playing" + ) + self.play_image.set_property( + "icon-name", self.PAUSE_ICON if playing else self.PLAY_ICON + ) + self._update_spin() def _on_mpris_changed(self, *args): - # Update properties when the player's state changes. self._apply_mpris_properties() def on_player_appeared(self, manager, player): - # When a new player appears, use it if no player is active. if not self.mpris_player: - mp = MprisPlayer(player) - self.mpris_player = mp - self._apply_mpris_properties() + self.mpris_player = MprisPlayer(player) self.mpris_player.connect("changed", self._on_mpris_changed) + self._apply_mpris_properties() def on_player_vanished(self, manager, player_name): players = self.mpris_manager.players - if ( - players - and self.mpris_player - and self.mpris_player.player_name == player_name - ): - if players: # Check if players is not empty after vanishing + if self.mpris_player and self.mpris_player.player_name == player_name: + if players: self.current_index = self.current_index % len(players) - new_player = MprisPlayer(players[self.current_index]) - self.mpris_player = new_player + self.mpris_player = MprisPlayer(players[self.current_index]) self.mpris_player.connect("changed", self._on_mpris_changed) else: - self.mpris_player = None # No players left + self.mpris_player = None elif not players: self.mpris_player = None self._apply_mpris_properties() diff --git a/sims/modules/stylix.py b/sims/modules/stylix.py index 6dd489d..65ab905 100644 --- a/sims/modules/stylix.py +++ b/sims/modules/stylix.py @@ -300,6 +300,43 @@ tooltip>* {{ font-size: 0px; }} +/* Compact MPRIS player */ +#player-small {{ + background-color: #{colors["base01"]}; + padding: 6px; + border-radius: 100px; +}} + +#compact-mpris-icon, +#compact-mpris-button {{ + background: transparent; + border: none; + padding: 0 4px; + margin: 0; + box-shadow: none; + min-height: 0; + min-width: 0; +}} + +#compact-mpris-button-icon, +#compact-mpris-fallback {{ + color: #{colors["base05"]}; +}} + +#compact-mpris-button:hover #compact-mpris-button-icon {{ + color: #{colors["base0D"]}; +}} + +#compact-mpris-fallback {{ + opacity: 0.7; +}} + +#compact-mpris-label {{ + color: #{colors["base05"]}; + font-size: {font_size}px; + margin: 0 6px; +}} + /* Quick Menu styling */ #quick-menu-container {{ background-color: #{colors["base00"]}; diff --git a/sims/services/mpris.py b/sims/services/mpris.py index 7de1719..2a6af57 100644 --- a/sims/services/mpris.py +++ b/sims/services/mpris.py @@ -265,7 +265,11 @@ class MprisPlayerManager(Service): def on_name_appeard(self, manager, player_name: Playerctl.PlayerName): logger.info(f"[MprisPlayer] {player_name.name} appeared") - new_player = Playerctl.Player.new_from_name(player_name) + try: + new_player = Playerctl.Player.new_from_name(player_name) + except GLib.Error as e: + logger.warning(f"[MprisPlayer] could not attach to {player_name.name}: {e}") + return manager.manage_player(new_player) self.emit("player-appeared", new_player) # type: ignore @@ -275,7 +279,10 @@ class MprisPlayerManager(Service): def add_players(self): for player in self._manager.get_property("player-names"): # type: ignore - self._manager.manage_player(Playerctl.Player.new_from_name(player)) # type: ignore + try: + self._manager.manage_player(Playerctl.Player.new_from_name(player)) # type: ignore + except GLib.Error as e: + logger.warning(f"[MprisPlayer] could not attach to {getattr(player, 'name', player)}: {e}") @Property(object, "readable") def players(self): diff --git a/sims/styles/bar.css b/sims/styles/bar.css index 4203b6a..4a7d0b5 100644 --- a/sims/styles/bar.css +++ b/sims/styles/bar.css @@ -62,6 +62,42 @@ padding: 2px; } +#player-small { + background-color: var(--module-bg); + padding: 6px; + border-radius: 100px; +} + +#compact-mpris-icon, +#compact-mpris-button { + background: transparent; + border: none; + padding: 0 4px; + margin: 0; + box-shadow: none; + min-height: 0; + min-width: 0; +} + +#compact-mpris-button-icon { + color: var(--foreground); +} + +#compact-mpris-button:hover #compact-mpris-button-icon { + color: var(--blue); +} + +#compact-mpris-fallback { + color: var(--foreground); + opacity: 0.7; +} + +#compact-mpris-label { + color: var(--foreground); + font-size: 13px; + margin: 0 6px; +} + #nixos-label { color: var(--blue); }