feat: media hover card with playback controls
This commit is contained in:
+1
-1
@@ -87,7 +87,7 @@ class StatusBar(Window):
|
||||
overlays=[self.cpu_progress_bar, self.progress_label],
|
||||
)
|
||||
self.player = Player()
|
||||
self.player_small = PlayerSmall()
|
||||
self.player_small = PlayerSmall(monitor=monitor)
|
||||
|
||||
self.battery = None
|
||||
if BATTERY["enable"]:
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
"""Hover card for the bar's compact media player.
|
||||
|
||||
A layer-shell popup that drops down below `PlayerSmall` while the pointer
|
||||
rests on it: large spinning cover, full track metadata, a seekable progress
|
||||
bar, transport controls (shuffle / previous / play-pause / next / loop) and,
|
||||
when more than one MPRIS player is around, a row to switch between them.
|
||||
|
||||
The card holds no MPRIS state of its own — `PlayerSmall` owns the tracked
|
||||
players and the active selection and pushes updates in via `set_players`,
|
||||
`set_active`, `refresh` and `set_cover`.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.scale import Scale
|
||||
from fabric.widgets.stack import Stack
|
||||
from fabric.widgets.wayland import WaylandWindow as Window
|
||||
from gi.repository import GLib, Gdk
|
||||
from loguru import logger
|
||||
|
||||
from sims.services.mpris import MprisPlayer
|
||||
from sims.utils.artwork import file_key
|
||||
from sims.widgets.circle_image import CircleImage
|
||||
|
||||
CARD_WIDTH = 360
|
||||
COVER_SIZE = 96
|
||||
PROGRESS_INTERVAL_MS = 500
|
||||
|
||||
PLAY_ICON = "media-playback-start-symbolic"
|
||||
PAUSE_ICON = "media-playback-pause-symbolic"
|
||||
PREV_ICON = "media-skip-backward-symbolic"
|
||||
NEXT_ICON = "media-skip-forward-symbolic"
|
||||
SHUFFLE_ICON = "media-playlist-shuffle-symbolic"
|
||||
LOOP_ICON = "media-playlist-repeat-symbolic"
|
||||
LOOP_TRACK_ICON = "media-playlist-repeat-song-symbolic"
|
||||
FALLBACK_ICON = "audio-x-generic-symbolic"
|
||||
|
||||
LOOP_CYCLE = {"none": "playlist", "playlist": "track", "track": "none"}
|
||||
|
||||
|
||||
def _safe(fn: Callable, default=None):
|
||||
"""MPRIS properties raise once the underlying player has exited (the
|
||||
wrapper drops `_player`) and DBus calls can fail at any time; the card
|
||||
should degrade to defaults instead of throwing out of a GTK callback."""
|
||||
try:
|
||||
return fn()
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _format_time(us: int) -> str:
|
||||
seconds = max(0, int(us / 1_000_000))
|
||||
hours, rem = divmod(seconds, 3600)
|
||||
minutes, seconds = divmod(rem, 60)
|
||||
if hours:
|
||||
return f"{hours}:{minutes:02}:{seconds:02}"
|
||||
return f"{minutes}:{seconds:02}"
|
||||
|
||||
|
||||
def _icon_button(name: str, icon: str, size: int = 16, **kwargs) -> Button:
|
||||
return Button(
|
||||
name=name,
|
||||
child=Image(icon_name=icon, icon_size=size),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class MediaCard(Window):
|
||||
def __init__(
|
||||
self,
|
||||
monitor: int | None = None,
|
||||
on_select_player: Callable[[MprisPlayer], None] | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
name="media-card",
|
||||
title="sims-media-card",
|
||||
layer="top",
|
||||
anchor="top left",
|
||||
margin=(6, 0, 0, 0),
|
||||
exclusivity="none",
|
||||
keyboard_mode="none",
|
||||
visible=False,
|
||||
monitor=monitor,
|
||||
)
|
||||
self._on_select_player = on_select_player
|
||||
self._player: MprisPlayer | None = None
|
||||
self._players: list[MprisPlayer] = []
|
||||
self._progress_timer_id: int | None = None
|
||||
self._spin_timer_id: int | None = None
|
||||
self._seeking = False
|
||||
self._cover_key: tuple[str, int] | None = None
|
||||
# Player names whose shuffle / loop writes were rejected; their
|
||||
# buttons stay disabled so they don't look like they do something.
|
||||
self._no_shuffle: set[str] = set()
|
||||
self._no_loop: set[str] = set()
|
||||
|
||||
# --- cover -------------------------------------------------------
|
||||
self.cover = CircleImage(
|
||||
name="media-card-cover", size=COVER_SIZE, h_align="center", v_align="center"
|
||||
)
|
||||
self.cover_fallback = Image(
|
||||
name="media-card-cover-fallback", icon_name=FALLBACK_ICON, icon_size=48
|
||||
)
|
||||
self.cover_stack = Stack(
|
||||
name="media-card-cover-stack",
|
||||
transition_type="crossfade",
|
||||
transition_duration=200,
|
||||
children=[self.cover_fallback, self.cover],
|
||||
v_align="center",
|
||||
)
|
||||
self.cover_stack.set_size_request(COVER_SIZE, COVER_SIZE)
|
||||
self.cover_stack.set_visible_child(self.cover_fallback)
|
||||
|
||||
# --- metadata ----------------------------------------------------
|
||||
def meta_label(name: str) -> Label:
|
||||
label = Label(
|
||||
name=name, h_align="fill", ellipsization="end", max_chars_width=1
|
||||
)
|
||||
label.set_xalign(0.0)
|
||||
label.set_hexpand(True)
|
||||
return label
|
||||
|
||||
self.player_label = meta_label("media-card-player")
|
||||
self.title = meta_label("media-card-title")
|
||||
self.artist = meta_label("media-card-artist")
|
||||
self.album = meta_label("media-card-album")
|
||||
|
||||
meta = Box(
|
||||
name="media-card-meta",
|
||||
orientation="v",
|
||||
spacing=2,
|
||||
h_expand=True,
|
||||
v_align="center",
|
||||
children=[self.player_label, self.title, self.artist, self.album],
|
||||
)
|
||||
|
||||
header = Box(
|
||||
name="media-card-header",
|
||||
orientation="h",
|
||||
spacing=14,
|
||||
children=[self.cover_stack, meta],
|
||||
)
|
||||
|
||||
# --- progress ----------------------------------------------------
|
||||
self.progress = Scale(
|
||||
name="media-card-progress",
|
||||
min_value=0.0,
|
||||
max_value=1.0,
|
||||
value=0.0,
|
||||
draw_value=False,
|
||||
h_expand=True,
|
||||
increments=(0.01, 0.05),
|
||||
)
|
||||
self.progress.connect("button-press-event", self._on_seek_press)
|
||||
self.progress.connect("button-release-event", self._on_seek_release)
|
||||
# Scrolling over the card shouldn't silently scrub the track.
|
||||
self.progress.connect("scroll-event", lambda *_: True)
|
||||
|
||||
self.time_current = Label(name="media-card-time", label="0:00", h_align="start")
|
||||
self.time_total = Label(name="media-card-time", label="0:00", h_align="end")
|
||||
times = Box(orientation="h", h_expand=True)
|
||||
times.pack_start(self.time_current, False, False, 0)
|
||||
times.pack_end(self.time_total, False, False, 0)
|
||||
|
||||
progress_box = Box(
|
||||
name="media-card-progress-box",
|
||||
orientation="v",
|
||||
spacing=2,
|
||||
children=[self.progress, times],
|
||||
)
|
||||
|
||||
# --- controls ----------------------------------------------------
|
||||
self.shuffle_btn = _icon_button(
|
||||
"media-card-toggle", SHUFFLE_ICON, on_clicked=self._on_shuffle
|
||||
)
|
||||
self.prev_btn = _icon_button(
|
||||
"media-card-btn", PREV_ICON, size=20, on_clicked=self._on_prev
|
||||
)
|
||||
self.play_image = Image(icon_name=PLAY_ICON, icon_size=24)
|
||||
self.play_btn = Button(
|
||||
name="media-card-play", child=self.play_image, on_clicked=self._on_play_pause
|
||||
)
|
||||
self.next_btn = _icon_button(
|
||||
"media-card-btn", NEXT_ICON, size=20, on_clicked=self._on_next
|
||||
)
|
||||
self.loop_image = Image(icon_name=LOOP_ICON, icon_size=16)
|
||||
self.loop_btn = Button(
|
||||
name="media-card-toggle", child=self.loop_image, on_clicked=self._on_loop
|
||||
)
|
||||
|
||||
self.shuffle_btn.set_tooltip_text("Shuffle")
|
||||
self.prev_btn.set_tooltip_text("Previous")
|
||||
self.next_btn.set_tooltip_text("Next")
|
||||
self.loop_btn.set_tooltip_text("Repeat")
|
||||
|
||||
controls = Box(
|
||||
name="media-card-controls",
|
||||
orientation="h",
|
||||
spacing=10,
|
||||
h_align="center",
|
||||
children=[
|
||||
self.shuffle_btn,
|
||||
self.prev_btn,
|
||||
self.play_btn,
|
||||
self.next_btn,
|
||||
self.loop_btn,
|
||||
],
|
||||
)
|
||||
|
||||
# --- player switcher (only shown with >1 player) -----------------
|
||||
self.players_box = Box(
|
||||
name="media-card-players", orientation="h", spacing=6, h_align="center"
|
||||
)
|
||||
self.players_box.set_no_show_all(True)
|
||||
|
||||
self.body = Box(
|
||||
name="media-card-body",
|
||||
orientation="v",
|
||||
spacing=12,
|
||||
children=[header, progress_box, controls, self.players_box],
|
||||
)
|
||||
self.body.set_size_request(CARD_WIDTH, -1)
|
||||
self.add(self.body)
|
||||
|
||||
self.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK)
|
||||
self.connect("map", lambda *_: self._on_mapped())
|
||||
self.connect("unmap", lambda *_: self._on_unmapped())
|
||||
|
||||
self.refresh()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# public API (driven by PlayerSmall)
|
||||
# ------------------------------------------------------------------
|
||||
@property
|
||||
def player(self) -> MprisPlayer | None:
|
||||
return self._player
|
||||
|
||||
@property
|
||||
def seeking(self) -> bool:
|
||||
return self._seeking
|
||||
|
||||
def popup(self, x: int) -> None:
|
||||
"""Show the card with its left edge at `x` (output coordinates)."""
|
||||
self.margin = (6, 0, 0, max(0, x))
|
||||
if not self.get_visible():
|
||||
# Not show_all(): that would resurrect labels/rows refresh() hid.
|
||||
self.show()
|
||||
|
||||
def popdown(self) -> None:
|
||||
if self.get_visible():
|
||||
self.hide()
|
||||
|
||||
def set_players(self, players: list[MprisPlayer]) -> None:
|
||||
self._players = list(players)
|
||||
self._rebuild_player_switcher()
|
||||
|
||||
def set_active(self, player: MprisPlayer | None) -> None:
|
||||
self._player = player
|
||||
self._seeking = False
|
||||
self._sync_player_switcher()
|
||||
self.refresh()
|
||||
|
||||
def set_cover(self, path: str | None) -> None:
|
||||
key = file_key(path)
|
||||
if key is not None and key == self._cover_key:
|
||||
return
|
||||
self._cover_key = None
|
||||
if key is not None:
|
||||
try:
|
||||
self.cover.set_image_from_file(path)
|
||||
self._cover_key = key
|
||||
self.cover_stack.set_visible_child(self.cover)
|
||||
self._update_spin()
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug(f"[MediaCard] failed to load cover {path}: {e}")
|
||||
self.cover_stack.set_visible_child(self.cover_fallback)
|
||||
self._update_spin()
|
||||
|
||||
def refresh(self) -> None:
|
||||
mp = self._player
|
||||
if mp is None:
|
||||
self.player_label.set_text("")
|
||||
self.title.set_text("Nothing playing")
|
||||
self.artist.set_text("")
|
||||
self.album.set_text("")
|
||||
self.artist.set_visible(False)
|
||||
self.album.set_visible(False)
|
||||
self.play_image.set_property("icon-name", PLAY_ICON)
|
||||
for btn in (self.shuffle_btn, self.prev_btn, self.play_btn, self.next_btn, self.loop_btn):
|
||||
btn.set_sensitive(False)
|
||||
self.progress.set_sensitive(False)
|
||||
self.progress.set_value(0.0)
|
||||
self.time_current.set_text("0:00")
|
||||
self.time_total.set_text("0:00")
|
||||
self.cover_stack.set_visible_child(self.cover_fallback)
|
||||
self._cover_key = None
|
||||
self._update_spin()
|
||||
return
|
||||
|
||||
name = _safe(lambda: mp.player_name, "") or ""
|
||||
self.player_label.set_text(name.upper())
|
||||
|
||||
title = (_safe(lambda: mp.title, "") or "").strip()
|
||||
artist = (_safe(lambda: mp.artist, "") or "").strip()
|
||||
album = (_safe(lambda: mp.album, "") or "").strip()
|
||||
self.title.set_text(title or "Unknown title")
|
||||
self.title.set_tooltip_text(title or None)
|
||||
self.artist.set_text(artist)
|
||||
self.artist.set_visible(bool(artist))
|
||||
self.album.set_text(album)
|
||||
self.album.set_visible(bool(album))
|
||||
|
||||
playing = _safe(lambda: mp.playback_status, "") == "playing"
|
||||
self.play_image.set_property("icon-name", PAUSE_ICON if playing else PLAY_ICON)
|
||||
self.play_btn.set_tooltip_text("Pause" if playing else "Play")
|
||||
|
||||
self.play_btn.set_sensitive(bool(_safe(lambda: mp.can_pause, False)))
|
||||
self.prev_btn.set_sensitive(bool(_safe(lambda: mp.can_go_previous, False)))
|
||||
self.next_btn.set_sensitive(bool(_safe(lambda: mp.can_go_next, False)))
|
||||
|
||||
self.shuffle_btn.set_sensitive(name not in self._no_shuffle)
|
||||
self._set_class(self.shuffle_btn, "active", bool(_safe(lambda: mp.shuffle, False)))
|
||||
|
||||
loop = _safe(lambda: mp.loop_status, "none")
|
||||
self.loop_btn.set_sensitive(name not in self._no_loop)
|
||||
self.loop_image.set_property(
|
||||
"icon-name", LOOP_TRACK_ICON if loop == "track" else LOOP_ICON
|
||||
)
|
||||
self._set_class(self.loop_btn, "active", loop in ("playlist", "track"))
|
||||
self.loop_btn.set_tooltip_text(
|
||||
{"playlist": "Repeat: playlist", "track": "Repeat: track"}.get(
|
||||
loop, "Repeat: off"
|
||||
)
|
||||
)
|
||||
|
||||
self._update_progress()
|
||||
self._update_spin()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# player switcher
|
||||
# ------------------------------------------------------------------
|
||||
def _rebuild_player_switcher(self) -> None:
|
||||
for child in self.players_box.get_children():
|
||||
self.players_box.remove(child)
|
||||
child.destroy()
|
||||
|
||||
if len(self._players) < 2:
|
||||
self.players_box.hide()
|
||||
return
|
||||
|
||||
for mp in self._players:
|
||||
label = _safe(lambda mp=mp: mp.player_name, "?") or "?"
|
||||
btn = Button(
|
||||
name="media-card-player-chip",
|
||||
child=Label(label=label),
|
||||
on_clicked=lambda *_, mp=mp: self._select(mp),
|
||||
)
|
||||
btn.player = mp
|
||||
self.players_box.add(btn)
|
||||
btn.show_all()
|
||||
self.players_box.show()
|
||||
self._sync_player_switcher()
|
||||
|
||||
def _sync_player_switcher(self) -> None:
|
||||
for btn in self.players_box.get_children():
|
||||
self._set_class(btn, "active", getattr(btn, "player", None) is self._player)
|
||||
|
||||
def _select(self, mp: MprisPlayer) -> None:
|
||||
if self._on_select_player is not None:
|
||||
self._on_select_player(mp)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# controls
|
||||
# ------------------------------------------------------------------
|
||||
def _on_play_pause(self, *_):
|
||||
if self._player is not None:
|
||||
_safe(self._player.play_pause)
|
||||
|
||||
def _on_prev(self, *_):
|
||||
if self._player is not None:
|
||||
_safe(self._player.previous)
|
||||
|
||||
def _on_next(self, *_):
|
||||
if self._player is not None:
|
||||
_safe(self._player.next)
|
||||
|
||||
def _on_shuffle(self, *_):
|
||||
mp = self._player
|
||||
if mp is None:
|
||||
return
|
||||
try:
|
||||
mp.set_shuffle(not mp.shuffle)
|
||||
except Exception as e:
|
||||
logger.debug(f"[MediaCard] shuffle unsupported by {mp.player_name}: {e}")
|
||||
self._no_shuffle.add(mp.player_name)
|
||||
self.refresh()
|
||||
|
||||
def _on_loop(self, *_):
|
||||
mp = self._player
|
||||
if mp is None:
|
||||
return
|
||||
try:
|
||||
mp.set_loop(LOOP_CYCLE.get(mp.loop_status, "none"))
|
||||
except Exception as e:
|
||||
logger.debug(f"[MediaCard] loop unsupported by {mp.player_name}: {e}")
|
||||
self._no_loop.add(mp.player_name)
|
||||
self.refresh()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# progress / seeking
|
||||
# ------------------------------------------------------------------
|
||||
def _length(self) -> int:
|
||||
mp = self._player
|
||||
if mp is None:
|
||||
return 0
|
||||
return _safe(lambda: int(mp.length or 0), 0)
|
||||
|
||||
def _update_progress(self) -> bool:
|
||||
mp = self._player
|
||||
total = self._length()
|
||||
can_seek = bool(mp is not None and _safe(lambda: mp.can_seek, False))
|
||||
self.progress.set_sensitive(can_seek and total > 0)
|
||||
|
||||
if mp is None or total <= 0:
|
||||
if not self._seeking:
|
||||
self.progress.set_value(0.0)
|
||||
self.time_current.set_text("0:00" if mp is None else "--:--")
|
||||
self.time_total.set_text("0:00" if mp is None else "--:--")
|
||||
return True
|
||||
|
||||
self.time_total.set_text(_format_time(total))
|
||||
if self._seeking:
|
||||
# While dragging, show where the thumb is instead of the player.
|
||||
self.time_current.set_text(_format_time(self.progress.get_value() * total))
|
||||
return True
|
||||
|
||||
position = _safe(lambda: mp.position, 0) or 0
|
||||
self.progress.set_value(min(1.0, max(0.0, position / total)))
|
||||
self.time_current.set_text(_format_time(position))
|
||||
return True
|
||||
|
||||
def _on_seek_press(self, *_):
|
||||
self._seeking = True
|
||||
return False
|
||||
|
||||
def _on_seek_release(self, *_):
|
||||
if self._seeking and self._player is not None:
|
||||
total = self._length()
|
||||
if total > 0:
|
||||
target = int(self.progress.get_value() * total)
|
||||
_safe(lambda: setattr(self._player, "position", target))
|
||||
self._seeking = False
|
||||
# Let the scale finish its own release handling before we re-sync.
|
||||
GLib.timeout_add(150, lambda: (self._update_progress(), False)[1])
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# timers — only run while the card is on screen
|
||||
# ------------------------------------------------------------------
|
||||
def _on_mapped(self):
|
||||
self._update_progress()
|
||||
if self._progress_timer_id is None:
|
||||
self._progress_timer_id = GLib.timeout_add(
|
||||
PROGRESS_INTERVAL_MS, self._update_progress
|
||||
)
|
||||
self._update_spin()
|
||||
|
||||
def _on_unmapped(self):
|
||||
if self._progress_timer_id is not None:
|
||||
GLib.source_remove(self._progress_timer_id)
|
||||
self._progress_timer_id = None
|
||||
self._seeking = False
|
||||
self._update_spin()
|
||||
|
||||
def _update_spin(self):
|
||||
should_spin = (
|
||||
self.get_mapped()
|
||||
and self._player is not None
|
||||
and self.cover_stack.get_visible_child() is self.cover
|
||||
and _safe(lambda: self._player.playback_status, "") == "playing"
|
||||
)
|
||||
if should_spin and self._spin_timer_id is None:
|
||||
self._spin_timer_id = GLib.timeout_add(50, self._advance_spin)
|
||||
elif not should_spin and 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 + 1) % 360
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _set_class(widget, cls: str, on: bool) -> None:
|
||||
ctx = widget.get_style_context()
|
||||
if on and not ctx.has_class(cls):
|
||||
ctx.add_class(cls)
|
||||
elif not on and ctx.has_class(cls):
|
||||
ctx.remove_class(cls)
|
||||
+176
-38
@@ -12,7 +12,10 @@ from fabric.widgets.image import Image
|
||||
from fabric.widgets.circularprogressbar import CircularProgressBar
|
||||
from fabric.widgets.overlay import Overlay
|
||||
from fabric.widgets.stack import Stack
|
||||
from fabric.widgets.eventbox import EventBox
|
||||
from ..widgets.circle_image import CircleImage
|
||||
from sims.modules.media_card import MediaCard
|
||||
from sims.utils.artwork import file_key, resolve_artwork
|
||||
import sims.modules.icons as icons
|
||||
from sims.services.mpris import MprisPlayer, get_mpris_player_manager
|
||||
from fabric import Fabricator
|
||||
@@ -524,18 +527,41 @@ class Player(Box):
|
||||
return False
|
||||
|
||||
|
||||
class PlayerSmall(Box):
|
||||
class PlayerSmall(EventBox):
|
||||
"""Compact bar pill: spinning cover, title/artist and a play button.
|
||||
|
||||
Hovering the pill drops down a `MediaCard` with the full controls; the
|
||||
card stays open while the pointer is on either the pill or the card.
|
||||
"""
|
||||
|
||||
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
|
||||
# Delay before the card opens (so sweeping across the bar doesn't flash
|
||||
# it) and before it closes (so the pointer can cross the gap between the
|
||||
# pill and the card).
|
||||
CARD_SHOW_DELAY_MS = 250
|
||||
CARD_HIDE_DELAY_MS = 300
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, monitor: int | None = None):
|
||||
super().__init__(
|
||||
name="player-small", orientation="h", v_align="center"
|
||||
events=["enter-notify", "leave-notify"], v_align="center"
|
||||
)
|
||||
self.pill = Box(name="player-small", orientation="h", v_align="center")
|
||||
self.add(self.pill)
|
||||
|
||||
self._monitor = monitor
|
||||
self._card: MediaCard | None = None
|
||||
self._hover_pill = False
|
||||
self._hover_card = False
|
||||
self._card_show_id = None
|
||||
self._card_hide_id = None
|
||||
self._cover_url = None
|
||||
self._cover_path = None
|
||||
self._cover_key = None
|
||||
self._display_options = ["title", "artist"]
|
||||
self._display_index = 0
|
||||
self._current_display = "title"
|
||||
@@ -621,7 +647,10 @@ class PlayerSmall(Box):
|
||||
end_children=self.mpris_button,
|
||||
)
|
||||
|
||||
self.add(self.mpris_small)
|
||||
self.pill.add(self.mpris_small)
|
||||
|
||||
self.connect("enter-notify-event", self._on_pill_enter)
|
||||
self.connect("leave-notify-event", self._on_pill_leave)
|
||||
|
||||
self.mpris_manager = get_mpris_player_manager()
|
||||
self.mpris_player = None
|
||||
@@ -718,16 +747,23 @@ class PlayerSmall(Box):
|
||||
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:
|
||||
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)
|
||||
loaded = False
|
||||
key = file_key(image_path)
|
||||
if key is not None:
|
||||
if key == self._cover_key:
|
||||
loaded = True # already showing it; skip re-decoding
|
||||
else:
|
||||
try:
|
||||
self.cover.set_image_from_file(image_path)
|
||||
loaded = True
|
||||
except Exception:
|
||||
pass
|
||||
self._cover_path = image_path if loaded else None
|
||||
self._cover_key = key if loaded else None
|
||||
self.cover_stack.set_visible_child(self.cover if loaded else self.fallback_icon)
|
||||
self._update_spin()
|
||||
if self._card is not None:
|
||||
self._card.set_cover(self._cover_path)
|
||||
return False
|
||||
|
||||
def _start_spin(self):
|
||||
@@ -756,40 +792,31 @@ class PlayerSmall(Box):
|
||||
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)
|
||||
try:
|
||||
url = mp.arturl if mp else None
|
||||
except Exception:
|
||||
url = None
|
||||
self._cover_url = url
|
||||
|
||||
def done(path):
|
||||
# The track may have changed while a download was in flight.
|
||||
if self._cover_url == url:
|
||||
self._set_cover_from_path(path)
|
||||
|
||||
resolve_artwork(url, done)
|
||||
|
||||
def _apply_mpris_properties(self):
|
||||
if not self.mpris_player:
|
||||
self._set_label_text("")
|
||||
self.play_image.set_property("icon-name", self.PLAY_ICON)
|
||||
self.cover_stack.set_visible_child(self.fallback_icon)
|
||||
self._cover_url = None
|
||||
self._cover_path = None
|
||||
self._cover_key = None
|
||||
self.center_stack.set_visible_child(self.mpris_label)
|
||||
self._update_spin()
|
||||
self._sync_card()
|
||||
return
|
||||
|
||||
mp = self.mpris_player
|
||||
@@ -802,6 +829,113 @@ class PlayerSmall(Box):
|
||||
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)
|
||||
self._sync_card()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# hover card
|
||||
# ------------------------------------------------------------------
|
||||
def _ensure_card(self) -> MediaCard:
|
||||
if self._card is None:
|
||||
card = MediaCard(monitor=self._monitor, on_select_player=self._select_player)
|
||||
card.connect("enter-notify-event", self._on_card_enter)
|
||||
card.connect("leave-notify-event", self._on_card_leave)
|
||||
card.set_players(list(self._players.values()))
|
||||
card.set_active(self.mpris_player)
|
||||
card.set_cover(self._cover_path)
|
||||
self.connect("destroy", lambda *_: card.destroy())
|
||||
self._card = card
|
||||
return self._card
|
||||
|
||||
def _sync_card(self):
|
||||
card = self._card
|
||||
if card is None:
|
||||
return
|
||||
if card.player is not self.mpris_player:
|
||||
card.set_active(self.mpris_player)
|
||||
card.set_cover(self._cover_path)
|
||||
else:
|
||||
card.refresh()
|
||||
if self.mpris_player is None:
|
||||
card.popdown()
|
||||
|
||||
def _select_player(self, mp):
|
||||
if mp in self._players.values() and mp is not self.mpris_player:
|
||||
self.mpris_player = mp
|
||||
self._apply_mpris_properties()
|
||||
|
||||
@staticmethod
|
||||
def _is_grab_crossing(event) -> bool:
|
||||
# GTK synthesizes crossing events when a widget grabs the pointer
|
||||
# (e.g. while dragging the card's seek bar); those aren't the pointer
|
||||
# actually leaving.
|
||||
return event.mode in (Gdk.CrossingMode.GRAB, Gdk.CrossingMode.GTK_GRAB)
|
||||
|
||||
def _on_pill_enter(self, _widget, _event):
|
||||
self._hover_pill = True
|
||||
self._cancel_card_hide()
|
||||
card_open = self._card is not None and self._card.get_visible()
|
||||
if not card_open and self._card_show_id is None:
|
||||
self._card_show_id = GLib.timeout_add(
|
||||
self.CARD_SHOW_DELAY_MS, self._show_card
|
||||
)
|
||||
return False
|
||||
|
||||
def _on_pill_leave(self, _widget, event):
|
||||
if event.detail == Gdk.NotifyType.INFERIOR or self._is_grab_crossing(event):
|
||||
return False
|
||||
self._hover_pill = False
|
||||
if self._card_show_id is not None:
|
||||
GLib.source_remove(self._card_show_id)
|
||||
self._card_show_id = None
|
||||
self._schedule_card_hide()
|
||||
return False
|
||||
|
||||
def _on_card_enter(self, _widget, _event):
|
||||
self._hover_card = True
|
||||
self._cancel_card_hide()
|
||||
return False
|
||||
|
||||
def _on_card_leave(self, _widget, event):
|
||||
if event.detail == Gdk.NotifyType.INFERIOR or self._is_grab_crossing(event):
|
||||
return False
|
||||
self._hover_card = False
|
||||
self._schedule_card_hide()
|
||||
return False
|
||||
|
||||
def _show_card(self):
|
||||
self._card_show_id = None
|
||||
if not self._hover_pill or not self._players or self.mpris_player is None:
|
||||
return False
|
||||
card = self._ensure_card()
|
||||
res = self.translate_coordinates(self.get_toplevel(), 0, 0)
|
||||
# PyGObject returns (x, y), or (ok, x, y) depending on version.
|
||||
x = 0
|
||||
if res:
|
||||
x = res[1] if len(res) == 3 else res[0]
|
||||
card.popup(int(x))
|
||||
return False
|
||||
|
||||
def _schedule_card_hide(self):
|
||||
if self._card_hide_id is None:
|
||||
self._card_hide_id = GLib.timeout_add(
|
||||
self.CARD_HIDE_DELAY_MS, self._maybe_hide_card
|
||||
)
|
||||
|
||||
def _cancel_card_hide(self):
|
||||
if self._card_hide_id is not None:
|
||||
GLib.source_remove(self._card_hide_id)
|
||||
self._card_hide_id = None
|
||||
|
||||
def _maybe_hide_card(self):
|
||||
self._card_hide_id = None
|
||||
if self._hover_pill or self._hover_card or self._card is None:
|
||||
return False
|
||||
if self._card.seeking:
|
||||
# Pointer slipped off while scrubbing; close once released.
|
||||
self._schedule_card_hide()
|
||||
return False
|
||||
self._card.popdown()
|
||||
return False
|
||||
|
||||
def _on_icon_button_press(self, widget, event):
|
||||
if event.type != Gdk.EventType.BUTTON_PRESS:
|
||||
@@ -868,6 +1002,8 @@ class PlayerSmall(Box):
|
||||
self._players[name] = mp
|
||||
self._player_handlers[name] = handler_id
|
||||
self._last_status[name] = mp.playback_status
|
||||
if self._card is not None:
|
||||
self._card.set_players(list(self._players.values()))
|
||||
|
||||
def _untrack_player(self, name):
|
||||
mp = self._players.pop(name, None)
|
||||
@@ -876,6 +1012,8 @@ class PlayerSmall(Box):
|
||||
if mp and handler_id is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
mp.disconnect(handler_id)
|
||||
if self._card is not None:
|
||||
self._card.set_players(list(self._players.values()))
|
||||
|
||||
def _select_initial_player(self):
|
||||
for mp in self._players.values():
|
||||
|
||||
@@ -116,6 +116,24 @@ class MprisPlayer(Service):
|
||||
GLib.idle_add(lambda: (setattr(self, "shuffle", not self.shuffle), False))
|
||||
# else do nothing
|
||||
|
||||
def set_shuffle(self, do_shuffle: bool) -> None:
|
||||
"""Like assigning `shuffle`, but lets DBus errors propagate (GObject
|
||||
property setters swallow them), so callers can tell when a player
|
||||
doesn't support shuffling."""
|
||||
self._player.set_shuffle(do_shuffle)
|
||||
self.notifier("shuffle")
|
||||
|
||||
def set_loop(self, status: str) -> None:
|
||||
"""Like assigning `loop_status` ("none" / "track" / "playlist"), but
|
||||
lets DBus errors propagate."""
|
||||
loop_status = {
|
||||
"none": Playerctl.LoopStatus.NONE,
|
||||
"track": Playerctl.LoopStatus.TRACK,
|
||||
"playlist": Playerctl.LoopStatus.PLAYLIST,
|
||||
}[status]
|
||||
self._player.set_loop_status(loop_status)
|
||||
self.notifier("loop-status")
|
||||
|
||||
def play_pause(self):
|
||||
if self.can_pause:
|
||||
GLib.idle_add(lambda: (self._player.play_pause(), False))
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
@import url("./notmuch.css");
|
||||
@import url("./notifications.css");
|
||||
@import url("./org.css");
|
||||
@import url("./media.css");
|
||||
|
||||
|
||||
/* unset so we can style everything from the ground up. */
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/* Media hover card (drops down from the bar's compact player) */
|
||||
|
||||
#media-card {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#media-card-body {
|
||||
background-color: var(--window-bg);
|
||||
border: solid 2px var(--border-color);
|
||||
border-radius: 20px;
|
||||
padding: 16px 18px 14px 18px;
|
||||
}
|
||||
|
||||
#media-card-cover-fallback {
|
||||
color: var(--light-grey);
|
||||
background-color: var(--module-bg);
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
#media-card-player {
|
||||
color: var(--light-grey);
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
#media-card-title {
|
||||
font-size: 17px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#media-card-artist {
|
||||
font-size: 14px;
|
||||
color: var(--mid-fg);
|
||||
}
|
||||
|
||||
#media-card-album {
|
||||
font-size: 12px;
|
||||
color: var(--light-grey);
|
||||
}
|
||||
|
||||
/* Seek bar */
|
||||
#media-card-progress {
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
#media-card-progress trough {
|
||||
min-height: 4px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--light-bg);
|
||||
}
|
||||
|
||||
#media-card-progress highlight {
|
||||
min-height: 4px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--blue);
|
||||
}
|
||||
|
||||
#media-card-progress slider {
|
||||
min-width: 12px;
|
||||
min-height: 12px;
|
||||
margin: -4px;
|
||||
border-radius: 100px;
|
||||
background-color: var(--foreground);
|
||||
}
|
||||
|
||||
#media-card-progress:disabled highlight {
|
||||
background-color: var(--dark-grey);
|
||||
}
|
||||
|
||||
#media-card-time {
|
||||
font-size: 11px;
|
||||
color: var(--light-grey);
|
||||
}
|
||||
|
||||
/* Transport controls */
|
||||
#media-card-btn,
|
||||
#media-card-toggle {
|
||||
padding: 6px;
|
||||
border-radius: 100px;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
#media-card-btn:hover,
|
||||
#media-card-toggle:hover {
|
||||
background-color: var(--light-bg);
|
||||
}
|
||||
|
||||
/* `* { color }` in main.css sets every widget's color directly, so icons
|
||||
have to be targeted themselves rather than inheriting from the button. */
|
||||
#media-card-toggle image {
|
||||
color: var(--light-grey);
|
||||
}
|
||||
|
||||
#media-card-toggle.active image {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
#media-card-btn:disabled,
|
||||
#media-card-toggle:disabled,
|
||||
#media-card-play:disabled {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
#media-card-play {
|
||||
padding: 10px;
|
||||
border-radius: 100px;
|
||||
background-color: var(--blue);
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
#media-card-play image {
|
||||
color: var(--background);
|
||||
}
|
||||
|
||||
#media-card-play:hover {
|
||||
background-color: var(--turquoise);
|
||||
}
|
||||
|
||||
/* Player switcher (only shown with more than one player) */
|
||||
#media-card-player-chip {
|
||||
padding: 2px 10px;
|
||||
border-radius: 100px;
|
||||
background-color: var(--module-bg);
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
#media-card-player-chip label {
|
||||
font-size: 12px;
|
||||
color: var(--light-grey);
|
||||
}
|
||||
|
||||
#media-card-player-chip:hover {
|
||||
background-color: var(--light-bg);
|
||||
}
|
||||
|
||||
#media-card-player-chip.active {
|
||||
background-color: var(--light-bg);
|
||||
}
|
||||
|
||||
#media-card-player-chip.active label {
|
||||
color: var(--foreground);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Resolve MPRIS `mpris:artUrl` values to local file paths.
|
||||
|
||||
Remote artwork (Spotify, browsers) is downloaded once per URL on a worker
|
||||
thread and cached for the lifetime of the process, so every widget showing
|
||||
the cover — the bar pill and the hover card — shares a single download
|
||||
instead of re-fetching on every MPRIS `changed` emission.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections.abc import Callable
|
||||
|
||||
from gi.repository import GLib
|
||||
from loguru import logger
|
||||
|
||||
_CACHE_LIMIT = 32
|
||||
_cache: dict[str, str | None] = {}
|
||||
# url -> callbacks waiting for an in-flight download of that url
|
||||
_pending: dict[str, list[Callable[[str | None], None]]] = {}
|
||||
|
||||
|
||||
def _remember(url: str, path: str | None) -> None:
|
||||
_cache[url] = path
|
||||
while len(_cache) > _CACHE_LIMIT:
|
||||
old_url = next(iter(_cache))
|
||||
old_path = _cache.pop(old_url)
|
||||
if old_path and old_path.startswith(tempfile.gettempdir()):
|
||||
try:
|
||||
os.unlink(old_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _download(url: str) -> None:
|
||||
path: str | None = None
|
||||
try:
|
||||
suffix = os.path.splitext(urllib.parse.urlparse(url).path)[1] or ".png"
|
||||
with urllib.request.urlopen(url, timeout=10) as response:
|
||||
data = response.read()
|
||||
with tempfile.NamedTemporaryFile(
|
||||
delete=False, prefix="sims-art-", suffix=suffix
|
||||
) as tmp:
|
||||
tmp.write(data)
|
||||
path = tmp.name
|
||||
except Exception as e:
|
||||
logger.debug(f"[Artwork] download failed for {url}: {e}")
|
||||
|
||||
def finish():
|
||||
_remember(url, path)
|
||||
for cb in _pending.pop(url, []):
|
||||
cb(path)
|
||||
return False
|
||||
|
||||
GLib.idle_add(finish)
|
||||
|
||||
|
||||
def file_key(path: str | None) -> tuple[str, int] | None:
|
||||
"""`(path, mtime)` for an existing file, else None. Some players rewrite
|
||||
their artwork in place at a fixed path, so widgets compare this rather
|
||||
than the bare path to decide whether the cover needs reloading."""
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
return (path, os.stat(path).st_mtime_ns)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_artwork(
|
||||
arturl: str | None, callback: Callable[[str | None], None]
|
||||
) -> None:
|
||||
"""Call `callback(path_or_None)` on the main loop with a local file for
|
||||
`arturl`. Local files resolve synchronously; http(s) URLs resolve after a
|
||||
(cached) background download."""
|
||||
if not arturl:
|
||||
callback(None)
|
||||
return
|
||||
|
||||
parsed = urllib.parse.urlparse(arturl)
|
||||
if parsed.scheme in ("http", "https"):
|
||||
if arturl in _cache:
|
||||
callback(_cache[arturl])
|
||||
return
|
||||
waiters = _pending.get(arturl)
|
||||
if waiters is not None:
|
||||
waiters.append(callback)
|
||||
return
|
||||
_pending[arturl] = [callback]
|
||||
GLib.Thread.new("sims-artwork", _download, arturl)
|
||||
return
|
||||
|
||||
path = urllib.parse.unquote(parsed.path) if parsed.scheme == "file" else arturl
|
||||
callback(path if path and os.path.isfile(path) else None)
|
||||
Reference in New Issue
Block a user