diff --git a/sims/modules/bar.py b/sims/modules/bar.py index 767bf24..7fbeef5 100644 --- a/sims/modules/bar.py +++ b/sims/modules/bar.py @@ -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"]: diff --git a/sims/modules/media_card.py b/sims/modules/media_card.py new file mode 100644 index 0000000..329fd43 --- /dev/null +++ b/sims/modules/media_card.py @@ -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) diff --git a/sims/modules/player.py b/sims/modules/player.py index 0b9c60d..db196bc 100644 --- a/sims/modules/player.py +++ b/sims/modules/player.py @@ -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(): diff --git a/sims/services/mpris.py b/sims/services/mpris.py index 3ffd43a..9a4289b 100644 --- a/sims/services/mpris.py +++ b/sims/services/mpris.py @@ -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)) diff --git a/sims/styles/main.css b/sims/styles/main.css index 881d071..854c0e5 100644 --- a/sims/styles/main.css +++ b/sims/styles/main.css @@ -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. */ diff --git a/sims/styles/media.css b/sims/styles/media.css new file mode 100644 index 0000000..de0a290 --- /dev/null +++ b/sims/styles/media.css @@ -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); +} diff --git a/sims/utils/artwork.py b/sims/utils/artwork.py new file mode 100644 index 0000000..b495529 --- /dev/null +++ b/sims/utils/artwork.py @@ -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)