Compare commits

...

2 Commits

Author SHA1 Message Date
4adace6c4c feat: subscribe to all players 2026-05-05 23:58:15 +02:00
cc3bac5ce7 feat: player animations 2026-05-05 23:47:47 +02:00
2 changed files with 167 additions and 34 deletions

View File

@@ -1,8 +1,9 @@
import contextlib
import os import os
import urllib.parse import urllib.parse
import urllib.request import urllib.request
import tempfile import tempfile
from gi.repository import Gtk, GLib, Gio, Gdk from gi.repository import Gtk, GLib, Gio, Gdk, Pango
from fabric.widgets.box import Box 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
@@ -578,6 +579,9 @@ class PlayerSmall(Box):
max_chars_width=26, max_chars_width=26,
h_align="center", h_align="center",
) )
self._width_tween_id = None
self._current_label_width_px = None
self._width_tween_duration_ms = 220
self.play_image = Image( self.play_image = Image(
name="compact-mpris-button-icon", name="compact-mpris-button-icon",
@@ -621,18 +625,98 @@ class PlayerSmall(Box):
self.mpris_manager = MprisPlayerManager() self.mpris_manager = MprisPlayerManager()
self.mpris_player = None self.mpris_player = None
self.current_index = 0 self._players = {}
self._player_handlers = {}
self._last_status = {}
players = self.mpris_manager.players for p in self.mpris_manager.players:
if players: self._track_player(p)
mp = MprisPlayer(players[self.current_index]) self._select_initial_player()
self.mpris_player = mp
self.mpris_player.connect("changed", self._on_mpris_changed)
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)
def _char_width_px(self):
metrics = self.mpris_label.get_pango_context().get_metrics(None, None)
return max(1.0, metrics.get_approximate_char_width() / Pango.SCALE)
def _measure_text_width(self, text):
if not text:
return 0
layout = self.mpris_label.create_pango_layout(text)
text_width_px, _ = layout.get_pixel_size()
max_px = int(self._char_width_px() * 26)
return min(text_width_px, max_px)
def _apply_label_width(self, width_px):
if width_px <= 0:
self.mpris_label.set_size_request(0, -1)
if self.mpris_label.get_margin_start() != 0:
self.mpris_label.set_margin_start(0)
self.mpris_label.set_margin_end(0)
if self.mpris_label.get_visible():
self.mpris_label.set_visible(False)
self._current_label_width_px = 0
return
if not self.mpris_label.get_visible():
self.mpris_label.set_visible(True)
chars = max(1, int(round(width_px / self._char_width_px())))
if chars != self.mpris_label.get_max_width_chars():
self.mpris_label.set_max_width_chars(chars)
self.mpris_label.set_size_request(width_px, -1)
margin = min(6, width_px // 2)
if self.mpris_label.get_margin_start() != margin:
self.mpris_label.set_margin_start(margin)
self.mpris_label.set_margin_end(margin)
self._current_label_width_px = width_px
def _set_label_text(self, text):
target_text = text or ""
target_px = self._measure_text_width(target_text)
if self._width_tween_id is not None:
GLib.source_remove(self._width_tween_id)
self._width_tween_id = None
# First call — snap to target without animation.
if self._current_label_width_px is None:
self.mpris_label.set_text(target_text)
self._apply_label_width(target_px)
return
start_px = self._current_label_width_px
# Growing from collapsed: set new text first so it's ready to reveal.
# Cross-fading between two non-empty texts: also swap text immediately.
# Shrinking to empty: keep old text visible while it shrinks, clear at end.
if target_px > 0:
self.mpris_label.set_text(target_text)
if start_px == target_px:
self._apply_label_width(target_px)
return
duration_ms = self._width_tween_duration_ms
start_time = GLib.get_monotonic_time()
def tick():
elapsed_ms = (GLib.get_monotonic_time() - start_time) / 1000.0
progress = min(1.0, elapsed_ms / duration_ms)
t = 1 - (1 - progress) ** 3 # ease-out cubic
cur_px = int(start_px + (target_px - start_px) * t)
self._apply_label_width(cur_px)
if progress >= 1.0:
self._apply_label_width(target_px)
if target_px == 0:
self.mpris_label.set_text("")
self._width_tween_id = None
return False
return True
self._width_tween_id = GLib.timeout_add(16, tick)
def _set_cover_from_path(self, image_path): def _set_cover_from_path(self, image_path):
if image_path and os.path.isfile(image_path): if image_path and os.path.isfile(image_path):
try: try:
@@ -701,7 +785,7 @@ class PlayerSmall(Box):
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._set_label_text("")
self.play_image.set_property("icon-name", self.PLAY_ICON) self.play_image.set_property("icon-name", self.PLAY_ICON)
self.cover_stack.set_visible_child(self.fallback_icon) self.cover_stack.set_visible_child(self.fallback_icon)
self.center_stack.set_visible_child(self.mpris_label) self.center_stack.set_visible_child(self.mpris_label)
@@ -713,34 +797,37 @@ class PlayerSmall(Box):
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.strip() if mp.title and mp.title.strip() else ""
else: # "artist" else: # "artist"
text = mp.artist if mp.artist and mp.artist.strip() else "Nothing Playing" text = mp.artist.strip() if mp.artist and mp.artist.strip() else ""
self.mpris_label.set_text(text) self._set_label_text(text)
self.center_stack.set_visible_child(self.mpris_label) self.center_stack.set_visible_child(self.mpris_label)
def _on_icon_button_press(self, widget, event): def _on_icon_button_press(self, widget, event):
if event.type != Gdk.EventType.BUTTON_PRESS: if event.type != Gdk.EventType.BUTTON_PRESS:
return True return True
players = self.mpris_manager.players
if not players:
return True
if event.button == 2: if event.button == 2:
if not self.mpris_player:
return True
self._display_index = (self._display_index + 1) % len(self._display_options) self._display_index = (self._display_index + 1) % len(self._display_options)
self._current_display = self._display_options[self._display_index] self._current_display = self._display_options[self._display_index]
self._apply_mpris_properties() self._apply_mpris_properties()
return True return True
players = list(self._players.values())
if not players:
return True
idx = players.index(self.mpris_player) if self.mpris_player in players else -1
if event.button == 1: if event.button == 1:
self.current_index = (self.current_index + 1) % len(players) idx = (idx + 1) % len(players)
elif event.button == 3: elif event.button == 3:
self.current_index = (self.current_index - 1) % len(players) idx = (idx - 1) % len(players)
else: else:
return True return True
self.mpris_player = MprisPlayer(players[self.current_index]) self.mpris_player = players[idx]
self.mpris_player.connect("changed", self._on_mpris_changed)
self._apply_mpris_properties() self._apply_mpris_properties()
return True return True
@@ -774,24 +861,71 @@ class PlayerSmall(Box):
) )
self._update_spin() self._update_spin()
def _on_mpris_changed(self, *args): def _track_player(self, playerctl_player):
self._apply_mpris_properties() mp = MprisPlayer(playerctl_player)
name = mp.player_name
handler_id = mp.connect("changed", self._on_any_player_changed)
self._players[name] = mp
self._player_handlers[name] = handler_id
self._last_status[name] = mp.playback_status
def _untrack_player(self, name):
mp = self._players.pop(name, None)
handler_id = self._player_handlers.pop(name, None)
self._last_status.pop(name, None)
if mp and handler_id is not None:
with contextlib.suppress(Exception):
mp.disconnect(handler_id)
def _select_initial_player(self):
for mp in self._players.values():
if mp.playback_status == "playing":
self.mpris_player = mp
return
if self._players:
self.mpris_player = next(iter(self._players.values()))
def _on_any_player_changed(self, player):
name = player.player_name
prev_status = self._last_status.get(name)
cur_status = player.playback_status
self._last_status[name] = cur_status
if player is self.mpris_player:
self._apply_mpris_properties()
return
# Auto-follow: only on a fresh transition into "playing", and only
# if the active player isn't already playing (so a manual selection
# of a paused player isn't overridden by the player it was already
# competing with).
if cur_status == "playing" and prev_status != "playing":
active_playing = (
self.mpris_player
and self.mpris_player.playback_status == "playing"
)
if not active_playing:
self.mpris_player = player
self._apply_mpris_properties()
def on_player_appeared(self, manager, player): def on_player_appeared(self, manager, player):
self._track_player(player)
if not self.mpris_player: if not self.mpris_player:
self.mpris_player = MprisPlayer(player) name = player.get_property("player-name")
self.mpris_player.connect("changed", self._on_mpris_changed) self.mpris_player = self._players.get(name)
self._apply_mpris_properties() 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 was_active = (
if self.mpris_player and self.mpris_player.player_name == player_name: self.mpris_player and self.mpris_player.player_name == player_name
if players: )
self.current_index = self.current_index % len(players) self._untrack_player(player_name)
self.mpris_player = MprisPlayer(players[self.current_index]) if was_active:
self.mpris_player.connect("changed", self._on_mpris_changed) replacement = next(
else: (mp for mp in self._players.values() if mp.playback_status == "playing"),
self.mpris_player = None None,
elif not players: )
self.mpris_player = None if replacement is None and self._players:
self._apply_mpris_properties() replacement = next(iter(self._players.values()))
self.mpris_player = replacement
self._apply_mpris_properties()

View File

@@ -95,7 +95,6 @@
#compact-mpris-label { #compact-mpris-label {
color: var(--foreground); color: var(--foreground);
font-size: 13px; font-size: 13px;
margin: 0 6px;
} }
#nixos-label { #nixos-label {