|
|
|
|
@@ -1,8 +1,9 @@
|
|
|
|
|
import contextlib
|
|
|
|
|
import os
|
|
|
|
|
import urllib.parse
|
|
|
|
|
import urllib.request
|
|
|
|
|
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.centerbox import CenterBox
|
|
|
|
|
from fabric.widgets.label import Label
|
|
|
|
|
@@ -578,6 +579,9 @@ class PlayerSmall(Box):
|
|
|
|
|
max_chars_width=26,
|
|
|
|
|
h_align="center",
|
|
|
|
|
)
|
|
|
|
|
self._width_tween_id = None
|
|
|
|
|
self._current_label_width_px = None
|
|
|
|
|
self._width_tween_duration_ms = 220
|
|
|
|
|
|
|
|
|
|
self.play_image = Image(
|
|
|
|
|
name="compact-mpris-button-icon",
|
|
|
|
|
@@ -621,18 +625,98 @@ class PlayerSmall(Box):
|
|
|
|
|
|
|
|
|
|
self.mpris_manager = MprisPlayerManager()
|
|
|
|
|
self.mpris_player = None
|
|
|
|
|
self.current_index = 0
|
|
|
|
|
self._players = {}
|
|
|
|
|
self._player_handlers = {}
|
|
|
|
|
self._last_status = {}
|
|
|
|
|
|
|
|
|
|
players = self.mpris_manager.players
|
|
|
|
|
if players:
|
|
|
|
|
mp = MprisPlayer(players[self.current_index])
|
|
|
|
|
self.mpris_player = mp
|
|
|
|
|
self.mpris_player.connect("changed", self._on_mpris_changed)
|
|
|
|
|
for p in self.mpris_manager.players:
|
|
|
|
|
self._track_player(p)
|
|
|
|
|
self._select_initial_player()
|
|
|
|
|
self._apply_mpris_properties()
|
|
|
|
|
|
|
|
|
|
self.mpris_manager.connect("player-appeared", self.on_player_appeared)
|
|
|
|
|
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):
|
|
|
|
|
if image_path and os.path.isfile(image_path):
|
|
|
|
|
try:
|
|
|
|
|
@@ -701,7 +785,7 @@ class PlayerSmall(Box):
|
|
|
|
|
|
|
|
|
|
def _apply_mpris_properties(self):
|
|
|
|
|
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.cover_stack.set_visible_child(self.fallback_icon)
|
|
|
|
|
self.center_stack.set_visible_child(self.mpris_label)
|
|
|
|
|
@@ -713,34 +797,37 @@ class PlayerSmall(Box):
|
|
|
|
|
self.update_play_pause_icon()
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
text = mp.artist if mp.artist and mp.artist.strip() else "Nothing Playing"
|
|
|
|
|
self.mpris_label.set_text(text)
|
|
|
|
|
text = mp.artist.strip() if mp.artist and mp.artist.strip() else ""
|
|
|
|
|
self._set_label_text(text)
|
|
|
|
|
self.center_stack.set_visible_child(self.mpris_label)
|
|
|
|
|
|
|
|
|
|
def _on_icon_button_press(self, widget, event):
|
|
|
|
|
if event.type != Gdk.EventType.BUTTON_PRESS:
|
|
|
|
|
return True
|
|
|
|
|
players = self.mpris_manager.players
|
|
|
|
|
if not players:
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if event.button == 2:
|
|
|
|
|
if not self.mpris_player:
|
|
|
|
|
return True
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
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:
|
|
|
|
|
self.current_index = (self.current_index + 1) % len(players)
|
|
|
|
|
idx = (idx + 1) % len(players)
|
|
|
|
|
elif event.button == 3:
|
|
|
|
|
self.current_index = (self.current_index - 1) % len(players)
|
|
|
|
|
idx = (idx - 1) % len(players)
|
|
|
|
|
else:
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
self.mpris_player = MprisPlayer(players[self.current_index])
|
|
|
|
|
self.mpris_player.connect("changed", self._on_mpris_changed)
|
|
|
|
|
self.mpris_player = players[idx]
|
|
|
|
|
self._apply_mpris_properties()
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
@@ -774,24 +861,71 @@ class PlayerSmall(Box):
|
|
|
|
|
)
|
|
|
|
|
self._update_spin()
|
|
|
|
|
|
|
|
|
|
def _on_mpris_changed(self, *args):
|
|
|
|
|
def _track_player(self, playerctl_player):
|
|
|
|
|
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):
|
|
|
|
|
self._track_player(player)
|
|
|
|
|
if not self.mpris_player:
|
|
|
|
|
self.mpris_player = MprisPlayer(player)
|
|
|
|
|
self.mpris_player.connect("changed", self._on_mpris_changed)
|
|
|
|
|
name = player.get_property("player-name")
|
|
|
|
|
self.mpris_player = self._players.get(name)
|
|
|
|
|
self._apply_mpris_properties()
|
|
|
|
|
|
|
|
|
|
def on_player_vanished(self, manager, player_name):
|
|
|
|
|
players = self.mpris_manager.players
|
|
|
|
|
if self.mpris_player and self.mpris_player.player_name == player_name:
|
|
|
|
|
if players:
|
|
|
|
|
self.current_index = self.current_index % len(players)
|
|
|
|
|
self.mpris_player = MprisPlayer(players[self.current_index])
|
|
|
|
|
self.mpris_player.connect("changed", self._on_mpris_changed)
|
|
|
|
|
else:
|
|
|
|
|
self.mpris_player = None
|
|
|
|
|
elif not players:
|
|
|
|
|
self.mpris_player = None
|
|
|
|
|
was_active = (
|
|
|
|
|
self.mpris_player and self.mpris_player.player_name == player_name
|
|
|
|
|
)
|
|
|
|
|
self._untrack_player(player_name)
|
|
|
|
|
if was_active:
|
|
|
|
|
replacement = next(
|
|
|
|
|
(mp for mp in self._players.values() if mp.playback_status == "playing"),
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
if replacement is None and self._players:
|
|
|
|
|
replacement = next(iter(self._players.values()))
|
|
|
|
|
self.mpris_player = replacement
|
|
|
|
|
self._apply_mpris_properties()
|
|
|
|
|
|