feat: media hover card with playback controls

This commit is contained in:
2026-11-09 22:20:34 +01:00
parent a56d4d1c20
commit a890604001
7 changed files with 937 additions and 39 deletions
+1 -1
View File
@@ -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"]:
+503
View File
@@ -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
View File
@@ -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():
+18
View File
@@ -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))
+1
View File
@@ -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. */
+143
View File
@@ -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);
}
+95
View File
@@ -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)