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.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(

View File

@@ -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()

View File

@@ -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"]};

View File

@@ -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):

View File

@@ -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);
}