feat: player animations

This commit is contained in:
2026-05-05 23:47:47 +02:00
parent 81e1a1fc1f
commit cc3bac5ce7
2 changed files with 88 additions and 6 deletions

View File

@@ -2,7 +2,7 @@ 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 +578,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",
@@ -633,6 +636,86 @@ class PlayerSmall(Box):
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 +784,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,10 +796,10 @@ 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):

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 {