feat: mpris player widget

This commit is contained in:
2026-05-05 23:15:09 +02:00
parent d14a0c9678
commit 81e1a1fc1f
5 changed files with 251 additions and 129 deletions

View File

@@ -4,7 +4,7 @@ from fabric.widgets.image import Image
from fabric.widgets.overlay import Overlay from fabric.widgets.overlay import Overlay
from fabric.widgets.datetime import DateTime from fabric.widgets.datetime import DateTime
from fabric.widgets.centerbox import CenterBox 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.battery import Battery
from sims.modules.control_center import ControlCenter from sims.modules.control_center import ControlCenter
from sims.modules.notmuch import NotmuchWidget from sims.modules.notmuch import NotmuchWidget
@@ -82,6 +82,7 @@ class StatusBar(Window):
overlays=[self.cpu_progress_bar, self.progress_label], overlays=[self.cpu_progress_bar, self.progress_label],
) )
self.player = Player() self.player = Player()
self.player_small = PlayerSmall()
self.battery = None self.battery = None
if BATTERY["enable"]: if BATTERY["enable"]:
@@ -132,6 +133,7 @@ class StatusBar(Window):
children=[ children=[
Image(name="nixos-label", icon_name="nix-snowflake-white", icon_size=20), Image(name="nixos-label", icon_name="nix-snowflake-white", icon_size=20),
self.workspaces, self.workspaces,
self.player_small,
], ],
), ),
center_children=Box( center_children=Box(

View File

@@ -7,6 +7,7 @@ from fabric.widgets.box import Box
from fabric.widgets.centerbox import CenterBox from fabric.widgets.centerbox import CenterBox
from fabric.widgets.label import Label from fabric.widgets.label import Label
from fabric.widgets.button import Button from fabric.widgets.button import Button
from fabric.widgets.image import Image
from fabric.widgets.circularprogressbar import CircularProgressBar from fabric.widgets.circularprogressbar import CircularProgressBar
from fabric.widgets.overlay import Overlay from fabric.widgets.overlay import Overlay
from fabric.widgets.stack import Stack from fabric.widgets.stack import Stack
@@ -522,30 +523,52 @@ class Player(Box):
return False 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): def __init__(self):
super().__init__( 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 = ["title", "artist"]
self._display_options = ["cavalcade", "title", "artist"]
self._display_index = 0 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( self.mpris_icon = Button(
name="compact-mpris-icon", name="compact-mpris-icon",
h_align="center", h_align="center",
v_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.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
self.mpris_icon.connect("button-press-event", self._on_icon_button_press) 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) add_hover_cursor(self.mpris_icon)
self.mpris_label = Label( self.mpris_label = Label(
@@ -555,36 +578,33 @@ class PlayerSmall(CenterBox):
max_chars_width=26, max_chars_width=26,
h_align="center", h_align="center",
) )
self.play_image = Image(
name="compact-mpris-button-icon",
icon_name=self.PLAY_ICON,
icon_size=16,
)
self.mpris_button = Button( self.mpris_button = Button(
name="compact-mpris-button", name="compact-mpris-button",
h_align="center", h_align="center",
v_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.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
self.mpris_button.connect( self.mpris_button.connect(
"button-press-event", self._on_play_pause_button_press "button-press-event", self._on_play_pause_button_press
) )
# Add hover effect
add_hover_cursor(self.mpris_button) add_hover_cursor(self.mpris_button)
# self.cavalcade = SpectrumRender()
# self.cavalcade_box = self.cavalcade.get_spectrum_box()
self.center_stack = Stack( self.center_stack = Stack(
name="compact-mpris", name="compact-mpris",
transition_type="crossfade", transition_type="crossfade",
transition_duration=100, transition_duration=100,
v_align="center", v_align="center",
v_expand=False, v_expand=False,
children=[ children=[self.mpris_label],
# self.cavalcade_box,
self.mpris_label,
],
) )
# self.center_stack.set_visible_child(self.cavalcade_box) # default to cavalcade
# Create additional compact view.
self.mpris_small = CenterBox( self.mpris_small = CenterBox(
name="compact-mpris", name="compact-mpris",
orientation="h", orientation="h",
@@ -593,7 +613,7 @@ class PlayerSmall(CenterBox):
v_align="center", v_align="center",
v_expand=False, v_expand=False,
start_children=self.mpris_icon, 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, end_children=self.mpris_button,
) )
@@ -601,157 +621,177 @@ class PlayerSmall(CenterBox):
self.mpris_manager = MprisPlayerManager() self.mpris_manager = MprisPlayerManager()
self.mpris_player = None self.mpris_player = None
# Almacenar el índice del reproductor actual
self.current_index = 0 self.current_index = 0
players = self.mpris_manager.players players = self.mpris_manager.players
if players: if players:
mp = MprisPlayer(players[self.current_index]) mp = MprisPlayer(players[self.current_index])
self.mpris_player = mp self.mpris_player = mp
self._apply_mpris_properties()
self.mpris_player.connect("changed", self._on_mpris_changed) 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-appeared", self.on_player_appeared)
self.mpris_manager.connect("player-vanished", self.on_player_vanished) 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): def _apply_mpris_properties(self):
if not self.mpris_player: if not self.mpris_player:
self.mpris_label.set_text("Nothing Playing") self.mpris_label.set_text("Nothing Playing")
self.mpris_button.get_child().set_markup(icons.stop) self.play_image.set_property("icon-name", self.PLAY_ICON)
self.mpris_icon.get_child().set_markup(icons.disc) self.cover_stack.set_visible_child(self.fallback_icon)
if self._current_display != "cavalcade": self.center_stack.set_visible_child(self.mpris_label)
self.center_stack.set_visible_child( self._update_spin()
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
return return
mp = self.mpris_player mp = self.mpris_player
self._update_cover(mp)
# 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_play_pause_icon() self.update_play_pause_icon()
if self._current_display == "title": if self._current_display == "title":
text = mp.title if mp.title and mp.title.strip() else "Nothing Playing" text = mp.title if mp.title and mp.title.strip() else "Nothing Playing"
self.mpris_label.set_text(text) else: # "artist"
self.center_stack.set_visible_child(self.mpris_label) text = mp.artist if mp.artist and mp.artist.strip() else "Nothing Playing"
elif self._current_display == "artist": self.mpris_label.set_text(text)
text = mp.artist if mp.artist else "Nothing Playing" self.center_stack.set_visible_child(self.mpris_label)
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)
def _on_icon_button_press(self, widget, event): 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: if event.button == 2:
players = self.mpris_manager.players self._display_index = (self._display_index + 1) % len(self._display_options)
if not players: self._current_display = self._display_options[self._display_index]
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)
self._apply_mpris_properties() 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 return True
def _on_play_pause_button_press(self, widget, event): def _on_play_pause_button_press(self, widget, event):
if event.type == Gdk.EventType.BUTTON_PRESS: if event.type != Gdk.EventType.BUTTON_PRESS or not self.mpris_player:
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()
return True 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 return True
def _restore_play_pause_icon(self): def _restore_play_pause_icon(self):
self.update_play_pause_icon() self.update_play_pause_icon()
return False 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): def update_play_pause_icon(self):
if self.mpris_player and self.mpris_player.playback_status == "playing": playing = (
self.mpris_button.get_child().set_markup(icons.pause) self.mpris_player
else: and self.mpris_player.playback_status == "playing"
self.mpris_button.get_child().set_markup(icons.play) )
self.play_image.set_property(
def _on_play_pause_clicked(self, button): "icon-name", self.PAUSE_ICON if playing else self.PLAY_ICON
if self.mpris_player: )
self.mpris_player.play_pause() self._update_spin()
self.update_play_pause_icon()
def _on_mpris_changed(self, *args): def _on_mpris_changed(self, *args):
# Update properties when the player's state changes.
self._apply_mpris_properties() self._apply_mpris_properties()
def on_player_appeared(self, manager, player): def on_player_appeared(self, manager, player):
# When a new player appears, use it if no player is active.
if not self.mpris_player: if not self.mpris_player:
mp = MprisPlayer(player) self.mpris_player = MprisPlayer(player)
self.mpris_player = mp
self._apply_mpris_properties()
self.mpris_player.connect("changed", self._on_mpris_changed) self.mpris_player.connect("changed", self._on_mpris_changed)
self._apply_mpris_properties()
def on_player_vanished(self, manager, player_name): def on_player_vanished(self, manager, player_name):
players = self.mpris_manager.players players = self.mpris_manager.players
if ( if self.mpris_player and self.mpris_player.player_name == player_name:
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
self.current_index = self.current_index % len(players) self.current_index = self.current_index % len(players)
new_player = MprisPlayer(players[self.current_index]) self.mpris_player = MprisPlayer(players[self.current_index])
self.mpris_player = new_player
self.mpris_player.connect("changed", self._on_mpris_changed) self.mpris_player.connect("changed", self._on_mpris_changed)
else: else:
self.mpris_player = None # No players left self.mpris_player = None
elif not players: elif not players:
self.mpris_player = None self.mpris_player = None
self._apply_mpris_properties() self._apply_mpris_properties()

View File

@@ -300,6 +300,43 @@ tooltip>* {{
font-size: 0px; 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 styling */
#quick-menu-container {{ #quick-menu-container {{
background-color: #{colors["base00"]}; background-color: #{colors["base00"]};

View File

@@ -265,7 +265,11 @@ class MprisPlayerManager(Service):
def on_name_appeard(self, manager, player_name: Playerctl.PlayerName): def on_name_appeard(self, manager, player_name: Playerctl.PlayerName):
logger.info(f"[MprisPlayer] {player_name.name} appeared") 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) manager.manage_player(new_player)
self.emit("player-appeared", new_player) # type: ignore self.emit("player-appeared", new_player) # type: ignore
@@ -275,7 +279,10 @@ class MprisPlayerManager(Service):
def add_players(self): def add_players(self):
for player in self._manager.get_property("player-names"): # type: ignore 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") @Property(object, "readable")
def players(self): def players(self):

View File

@@ -62,6 +62,42 @@
padding: 2px; 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 { #nixos-label {
color: var(--blue); color: var(--blue);
} }