diff --git a/src/services/mpris.py b/src/services/mpris.py index e24a879..1b11170 100644 --- a/src/services/mpris.py +++ b/src/services/mpris.py @@ -6,19 +6,14 @@ import urllib.request from pathlib import Path import gi -from fabric.utils import GLib, exec_shell_command_async, logger +from fabric.core.service import Property, Service, Signal +from fabric.utils import Gio, GLib, logger -gi.require_version("Playerctl", "2.0") -from fabric.core.service import Property, Service, Signal # noqa: E402 -from gi.repository import Playerctl # noqa: E402 +from shared.data import CACHE_DIR -from shared.data import CACHE_DIR # noqa: E402 - -TEMP_DIR = CACHE_DIR - -# Native Linux media artwork cache (libmediaart). Optional: when the GI -# namespace is unavailable we transparently fall back to our own cache dir, -# so the service keeps working without adding a hard dependency. +# libmediaart: native Linux album art cache used by GNOME/GTK apps (Rhythmbox, +# Lollypop, etc.). We follow its cache paths so artwork is shared across apps +# instead of duplicated in our own cache dir. try: gi.require_version("MediaArt", "2.0") from gi.repository import MediaArt @@ -28,13 +23,54 @@ except (ValueError, ImportError): MediaArt = None _MEDIAART_AVAILABLE = False +TEMP_DIR = CACHE_DIR + +MPRIS_PLAYER_PREFIX = "org.mpris.MediaPlayer2." +PLAYERCTLD_SERVICE = "org.mpris.MediaPlayer2.playerctld" +MPRIS_PLAYER_IFACE = "org.mpris.MediaPlayer2.Player" +MPRIS_PLAYER_PATH = "/org/mpris/MediaPlayer2" + +_MPRIS_PLAYER_IFACE_INFO = Gio.DBusNodeInfo.new_for_xml( + """ + + + + + + + + + + + + + + + +""" +).lookup_interface("org.mpris.MediaPlayer2.Player") + + +def _variant_to_str(variant) -> str | None: + if variant is None: + return None + vtype = variant.get_type_string() + if vtype == "s": + return variant.get_string() + if vtype == "ay": + raw = variant.get_fixed_array() + return raw.tobytes().decode("utf-8", errors="replace") if len(raw) > 0 else None + return None + + +def _is_valid_art_url(url: str) -> bool: + if not url: + return False + parsed = urllib.parse.urlparse(url) + return parsed.scheme in ("http", "https", "file") + def _mediaart_cache_path(artist: str | None, album: str | None, title: str | None): - """Standard media-art cache path for a track, or None when unavailable. - - Does NOT download; merely derives the canonical cache location so we can - both look artwork up and store new downloads following the same rules. - """ if not _MEDIAART_AVAILABLE: return None try: @@ -44,7 +80,6 @@ def _mediaart_cache_path(artist: str | None, album: str | None, title: str | Non except Exception as e: logger.debug(f"MediaArt.get_path failed: {e}") return None - # PyGObject return shape varies by version: (found, path, uri) or (path, uri). if isinstance(result, tuple): if len(result) == 3: found, path, _uri = result @@ -58,7 +93,7 @@ def _mediaart_cache_path(artist: str | None, album: str | None, title: str | Non class PlayerService(Service): @Signal - def meta_change(self, metadata: GLib.Variant, player: Playerctl.Player) -> None: ... + def meta_change(self, metadata: object, player: object) -> None: ... @Signal def artwork_change(self, local_path: str) -> None: ... @@ -74,115 +109,104 @@ class PlayerService(Service): @Property(bool, "readable", default_value=False) def can_seek(self) -> bool: - return self._player.get_property("can_seek") + v = self._proxy.get_cached_property("CanSeek") + return v.get_boolean() if v else False @Property(str, "readable", default_value="") def player_name(self) -> str: - try: - return self._player.props.player_name or "" - except Exception as e: - logger.warning( - f"[mpris] return self._player.props.player_name or '' failed: {e}" - ) - return "" + return self._name + + @Property(object, "readable") + def metadata(self) -> dict: + return self._get_metadata() or {} @Property(str, "readable", default_value="") def title(self) -> str: - try: - return self._player.get_title() or "" - except Exception as e: - logger.warning(f"[mpris] return self._player.get_title() or '' failed: {e}") - return "" + m = self._get_metadata() + v = m.get("xesam:title") if m else None + return _variant_to_str(v) or "" @Property(object, "readable") def artist(self) -> list: - try: - return list(self._player.props.metadata["xesam:artist"]) or [] - except Exception as e: - logger.warning( - f"[mpris] return list(self._player.props.metadata['xesam:artist']) ... failed: {e}" - ) + m = self._get_metadata() + v = m.get("xesam:artist") if m else None + if v is None: return [] + if v.get_type_string() == "as": + return list(v.unpack()) or [] + s = _variant_to_str(v) + return [s] if s else [] @Property(str, "readable", default_value="") def album(self) -> str: - try: - return self._player.props.metadata["xesam:album"] or "" - except Exception as e: - logger.warning( - f"[mpris] return self._player.props.metadata['xesam:album'] or '' failed: {e}" - ) - return "" + m = self._get_metadata() + v = m.get("xesam:album") if m else None + return _variant_to_str(v) or "" @Property(str, "readable", default_value="Stopped") def playback_status(self) -> str: - try: - val = int(self._player.props.playback_status) - if val == 0: - return "Playing" - elif val == 1: - return "Paused" - else: - return "Stopped" - except Exception as e: - logger.warning( - f"[mpris] val = int(self._player.props.playback_status) failed: {e}" - ) - return "Stopped" + v = self._proxy.get_cached_property("PlaybackStatus") + return v.get_string() if v else "Stopped" @Property(int, "readable", default_value=0) def length(self) -> int: - try: - return int(self._player.props.metadata["mpris:length"]) - except Exception as e: - logger.warning( - f"[mpris] return int(self._player.props.metadata['mpris:length']) failed: {e}" - ) + m = self._get_metadata() + v = m.get("mpris:length") if m else None + if v is None: return 0 + return v.get_uint64() if v.get_type_string() == "t" else int(v) @Property(int, "readable", default_value=0) def position(self) -> int: + if self._is_cleaning_up: + return 0 try: - return int(self._player.get_position()) - except Exception as e: - logger.warning( - f"[mpris] return int(self._player.get_position()) failed: {e}" + result = self._proxy.call_sync( + "org.freedesktop.DBus.Properties.Get", + GLib.Variant("(ss)", (MPRIS_PLAYER_IFACE, "Position")), + Gio.DBusCallFlags.NONE, + -1, + None, ) + return result.get_child_value(0).unpack() + except GLib.Error as e: + logger.warning(f"[mpris] position failed: {e}") return 0 def play_pause(self, *_): try: - self._player.play_pause() + self._proxy.call_sync("PlayPause", None, Gio.DBusCallFlags.NONE, -1, None) except GLib.Error as e: logger.warning(f"play_pause failed: {e}") def next(self, *_): try: - self._player.next() + self._proxy.call_sync("Next", None, Gio.DBusCallFlags.NONE, -1, None) except GLib.Error as e: logger.warning(f"next failed: {e}") def previous(self, *_): try: - self._player.previous() + self._proxy.call_sync("Previous", None, Gio.DBusCallFlags.NONE, -1, None) except GLib.Error as e: logger.warning(f"previous failed: {e}") - def __init__(self, player: Playerctl.Player, **kwargs): + def __init__(self, name: str, proxy: Gio.DBusProxy, **kwargs): super().__init__(**kwargs) - self._player: Playerctl.Player = player + self._name = name + self._proxy = proxy self._current_artwork_hash = "" self._current_artwork_path = "" self._is_cleaning_up = False self._artwork_generation = 0 self._signal_ids = [] self._last_emitted_status = "" + self._failed_covers: set[str] = set() + self._download_threads: dict[str, threading.Thread] = {} self._signal_ids.append( - self._player.connect("playback-status", self.on_playback_status) + self._proxy.connect("g-properties-changed", self._on_properties_changed) ) - self._signal_ids.append(self._player.connect("metadata", self.on_metadata)) - self._signal_ids.append(self._player.connect("seeked", self.on_seeked)) self._pos_source_id = 0 self._status_source_id = 0 @@ -190,50 +214,27 @@ class PlayerService(Service): self._start_status_polling() self.poll_progress() - try: - metadata = self._player.props.metadata - if metadata: - # meta_change emission must not block artwork discovery. - try: - self.meta_change(metadata, self._player) - except Exception as e: - logger.debug(f"meta_change emit failed: {e}") - self._handle_artwork(metadata) - except Exception as e: - logger.warning(f"Failed to initialize metadata: {e}") + metadata = self._get_metadata() + if metadata: + self.meta_change(metadata, self) + self._handle_artwork(metadata) - # Playerctl loads player properties asynchronously. For a player that - # was already playing before Modus started, the initial metadata read - # above can be empty, and the `metadata` signal only fires on *change* - # — so it would never re-fire and artwork would stay missing. Browser - # players (YouTube in Firefox/Chromium) in particular populate - # `mpris:artUrl` lazily, seconds after playback begins. Re-read metadata - # repeatedly for a short window so the artwork is eventually picked up. + # Browser players (YouTube in Firefox/Chromium) populate + # `mpris:artUrl` lazily, seconds after playback begins. Re-read + # metadata repeatedly for a short window so artwork is picked up. GLib.timeout_add(500, self._refresh_initial_state, 0) def _refresh_initial_state(self, attempt: int = 0): if self._is_cleaning_up: return False - try: - metadata = self._player.props.metadata - except Exception as e: - logger.warning( - f"[mpris] metadata = self._player.props.metadata failed: {e}" - ) - return False + metadata = self._get_metadata() if metadata: - # Push full metadata only once to avoid UI churn on every retry. if attempt == 0: - try: - self.meta_change(metadata, self._player) - except Exception as e: - logger.debug(f"meta_change emit failed: {e}") + self.meta_change(metadata, self) self._handle_artwork(metadata) self.fabricating(metadata) - # Stop as soon as artwork was found/queued. if self._current_artwork_hash: return False - # Retry for ~10s (browser players can be very slow to expose artUrl). if attempt < 12: GLib.timeout_add(800, self._refresh_initial_state, attempt + 1) return False @@ -245,19 +246,24 @@ class PlayerService(Service): if self._is_cleaning_up: return 0 try: - return self._player.get_position() - except Exception as e: + result = self._proxy.call_sync( + "org.freedesktop.DBus.Properties.Get", + GLib.Variant("(ss)", (MPRIS_PLAYER_IFACE, "Position")), + Gio.DBusCallFlags.NONE, + -1, + None, + ) + return result.get_child_value(0).unpack() / 1_000_000 + except GLib.Error as e: logger.warning(f"Could not get position: {e}") return 0 def _start_pos_fabricator(self): - """Start position polling only if not already running.""" if self._pos_source_id: return self._pos_source_id = GLib.timeout_add(2000, self._on_pos_poll) def _stop_pos_fabricator(self): - """Stop position polling.""" if not self._pos_source_id: return GLib.source_remove(self._pos_source_id) @@ -267,17 +273,15 @@ class PlayerService(Service): if self._is_cleaning_up: return False self.fabricating() - return True # keep polling while playing + return True def _start_status_polling(self): - """Start status polling for browser players that don't emit signals.""" if self._status_source_id: return self._last_polled_status = self.playback_status self._status_source_id = GLib.timeout_add(5000, self._on_status_poll) def _stop_status_polling(self): - """Stop status polling.""" if not self._status_source_id: return GLib.source_remove(self._status_source_id) @@ -291,26 +295,33 @@ class PlayerService(Service): self._last_polled_status = status self.poll_progress() self._notify_playback(status) - return True # keep polling + return True def seek_position(self, pos: float): if self._is_cleaning_up: return self._stop_pos_fabricator() - current = self.get_position() / 1_000_000 try: - self._player.set_position(int(pos * 1_000_000)) + self._proxy.call_sync( + "SetPosition", + GLib.Variant("(i)", (int(pos * 1_000_000),)), + Gio.DBusCallFlags.NONE, + -1, + None, + ) except GLib.Error: try: + current = self.get_position() / 1_000_000 offset = pos - current - self._player.seek(int(offset * 1_000_000)) - except GLib.Error: - name = self.player_name - if name: - # No shell: pass args as a list so player_name can't inject. - exec_shell_command_async( - ["playerctl", "-p", name, "position", str(pos)] - ) + self._proxy.call_sync( + "Seek", + GLib.Variant("(x)", (int(offset * 1_000_000),)), + Gio.DBusCallFlags.NONE, + -1, + None, + ) + except GLib.Error as e: + logger.error(f"seek_position fallback failed: {e}") finally: if self.playback_status.lower() == "playing": self._start_pos_fabricator() @@ -320,7 +331,13 @@ class PlayerService(Service): return self._stop_pos_fabricator() try: - self._player.seek(int(offset * 1_000_000)) + self._proxy.call_sync( + "Seek", + GLib.Variant("(x)", (int(offset * 1_000_000),)), + Gio.DBusCallFlags.NONE, + -1, + None, + ) except GLib.Error as e: logger.error(f"Failed to seek: {e}") finally: @@ -339,39 +356,52 @@ class PlayerService(Service): if self._is_cleaning_up: return try: - pos = self._player.get_position() / 1_000_000 + result = self._proxy.call_sync( + "org.freedesktop.DBus.Properties.Get", + GLib.Variant("(ss)", (MPRIS_PLAYER_IFACE, "Position")), + Gio.DBusCallFlags.NONE, + -1, + None, + ) + pos = result.get_child_value(0).unpack() / 1_000_000 except GLib.Error as e: logger.warning(f"Failed to get position: {e}") return dur = 0 if metadata is None: - try: - metadata = self._player.props.metadata - except Exception as e: - logger.debug(f"[mpris] Failed to read metadata: {e}") - metadata = None + metadata = self._get_metadata() if metadata is not None: - try: - dur = metadata["mpris:length"] / 1_000_000 - except Exception as e: - logger.debug(f"[mpris] Failed to read duration: {e}") - dur = 0 - # Browser players (YouTube in Firefox/Chromium) populate - # `mpris:artUrl` lazily and don't reliably emit the `metadata` - # signal. Since the position poll runs every 2s while playing, this - # also polls the artwork so it shows up even for an already-playing tab. + v = metadata.get("mpris:length") + if v is not None: + dur = ( + v.get_uint64() / 1_000_000 + if v.get_type_string() == "t" + else int(v) / 1_000_000 + ) self._handle_artwork(metadata) self.track_position(pos, dur) - def on_seeked(self, player, position): + def _on_properties_changed(self, _proxy, changed_properties, _changed_invalidated): if self._is_cleaning_up: return - if self.playback_status.lower() == "playing": - self._start_pos_fabricator() + props = changed_properties.unpack() + + if "PlaybackStatus" in props: + self.poll_progress() + self._notify_playback(props["PlaybackStatus"]) + + if "Metadata" in props: + metadata = props["Metadata"] + if isinstance(metadata, dict): + self.meta_change(metadata, self) + self.fabricating(metadata) + elif isinstance(metadata, GLib.Variant): + unpacked = metadata.unpack() + if isinstance(unpacked, dict): + self.meta_change(unpacked, self) + self.fabricating(unpacked) def _notify_playback(self, status_str: str): - """Emit play/pause once per actual state change (dedupes the - Playerctl signal and the status poller so listeners don't churn).""" if self._is_cleaning_up: return status_str = (status_str or "").lower() @@ -383,39 +413,23 @@ class PlayerService(Service): else: self.pause() - def on_playback_status(self, player, status): - """DBus signal handler - instant when it fires (e.g. Modus buttons).""" - if self._is_cleaning_up: - return - self.poll_progress() - self._notify_playback(self.playback_status) - - def _on_polled_status_change(self, new_status: str): - """Status poll change - fires reliably for browser players - that don't emit playback-status signals.""" - if self._is_cleaning_up: - return - self.poll_progress() - self._notify_playback(new_status) - - def on_metadata(self, player, metadata): - if self._is_cleaning_up: - return - self.meta_change(metadata, player) - self.fabricating(metadata) + def _get_metadata(self) -> dict | None: + v = self._proxy.get_cached_property("Metadata") + if v is None: + return None + return v.unpack() if isinstance(v, GLib.Variant) else v def _meta_str(self, metadata, key: str) -> str | None: - try: - value = metadata[key] - except (KeyError, TypeError, GLib.Error) as e: - logger.warning(f"[mpris] value = metadata[key] failed: {e}") + value = metadata.get(key) + if value is None: return None + if isinstance(value, GLib.Variant): + return _variant_to_str(value) if isinstance(value, (list, tuple)): return " ".join(str(v) for v in value) if value else None return str(value) if value else None def _find_cached_artwork(self, metadata) -> str | None: - """Locate already-cached artwork for this track (native MediaArt cache).""" if not _MEDIAART_AVAILABLE: return None artist = self._meta_str(metadata, "xesam:artist") @@ -429,7 +443,6 @@ class PlayerService(Service): base = Path(base) if base.exists(): return str(base) - # MediaArt files usually carry an extension; look for any sibling. matches = list(base.parent.glob(base.stem + ".*")) return str(matches[0]) if matches else None @@ -444,19 +457,28 @@ class PlayerService(Service): def _handle_artwork(self, metadata): if self._is_cleaning_up: return - try: - art_url = metadata["mpris:artUrl"] - except (KeyError, TypeError) as e: - logger.warning(f"[mpris] art_url = metadata['mpris:artUrl'] failed: {e}") + art_url_raw = metadata.get("mpris:artUrl") + if art_url_raw is None: return - if not art_url: + art_url = ( + art_url_raw.get_string() + if isinstance(art_url_raw, GLib.Variant) + else str(art_url_raw) + ) + if not _is_valid_art_url(art_url): return artwork_hash = hashlib.md5(art_url.encode()).hexdigest() if artwork_hash == self._current_artwork_hash: return + if artwork_hash in self._failed_covers: + return self._current_artwork_hash = artwork_hash + for h in list(self._download_threads): + if h != artwork_hash: + del self._download_threads[h] + parsed = urllib.parse.urlparse(art_url) if parsed.scheme == "file": local = urllib.parse.unquote(parsed.path) @@ -469,14 +491,14 @@ class PlayerService(Service): if existing and Path(existing).exists(): self._set_artwork(existing) return - # Capture generation so the thread can bail out if cleanup runs - # while the download is in-flight. gen = self._artwork_generation - threading.Thread( + t = threading.Thread( target=self._download_artwork, args=(art_url, artwork_hash, metadata, gen), daemon=True, - ).start() + ) + self._download_threads[artwork_hash] = t + t.start() def _set_artwork(self, path: str): if self._is_cleaning_up: @@ -485,8 +507,6 @@ class PlayerService(Service): self.artwork_change(path) def _artwork_target_path(self, artwork_hash: str, metadata, suffix: str) -> Path: - """Where to store a freshly downloaded cover: the standard MediaArt - cache location when available, otherwise our own fallback cache.""" ma_base = self._find_cached_artwork(metadata) if ma_base: base = Path(ma_base) @@ -508,20 +528,20 @@ class PlayerService(Service): tmp.write_bytes(data) tmp.replace(target) - # Bail out if cleanup ran while we were downloading — the idle - # callback would reference a stale service. if not self._is_cleaning_up and gen == self._artwork_generation: GLib.idle_add(self._set_artwork, str(target)) except Exception as e: logger.error(f"Failed to download artwork: {e}") + if not self._is_cleaning_up: + self._failed_covers.add(artwork_hash) + finally: + self._download_threads.pop(artwork_hash, None) def cleanup(self): if self._is_cleaning_up: return self._is_cleaning_up = True - # Invalidate any in-flight artwork download threads so they won't - # call _set_artwork on a stale service. self._artwork_generation += 1 self._stop_pos_fabricator() @@ -529,11 +549,13 @@ class PlayerService(Service): for signal_id in self._signal_ids: try: - self._player.disconnect(signal_id) + self._proxy.disconnect(signal_id) except Exception as e: logger.warning(f"Error disconnecting signal: {e}") self._signal_ids.clear() self._current_artwork_path = "" + self._failed_covers.clear() + self._download_threads.clear() class PlayerManager(Service): @@ -553,44 +575,84 @@ class PlayerManager(Service): def _init_singleton(self): super().__init__() - self._manager = Playerctl.PlayerManager() + self._connection: Gio.DBusConnection | None = None self._services: dict[str, PlayerService] = {} - self._player_objects: dict[str, Playerctl.Player] = {} + self._owner_watch_id = 0 - self._manager.connect("name-appeared", self._on_name_appeared, self._manager) - self._manager.connect( - "player-vanished", self._on_player_vanished, self._manager + try: + self._connection = Gio.bus_get_sync(Gio.BusType.SESSION, None) + except GLib.Error as e: + logger.error(f"Failed to connect to session bus: {e}") + return + + self._owner_watch_id = self._connection.signal_subscribe( + None, + "org.freedesktop.DBus", + "NameOwnerChanged", + "/org/freedesktop/DBus", + None, + Gio.DBusSignalFlags.NONE, + self._on_name_owner_changed, ) self._init_existing_players() def _init_existing_players(self): - for player_obj in self._manager.props.player_names: - self._create_player(player_obj) - - def _create_player(self, name_obj): - name_str = name_obj.name - if name_str in self._services: + if not self._connection: return try: - player = Playerctl.Player.new_from_name(name_obj) - self._manager.manage_player(player) - service = PlayerService(player) - self._services[name_str] = service - self._player_objects[name_str] = player - self.new_player(name_str, service) - except Exception as e: - logger.error(f"Failed to create player {name_str}: {e}") + result = self._connection.call_sync( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "ListNames", + None, + GLib.VariantType("(as)"), + Gio.DBusCallFlags.NONE, + -1, + None, + ) + names = result.get_child_value(0).unpack() + for name in names: + if name.startswith(MPRIS_PLAYER_PREFIX) and name != PLAYERCTLD_SERVICE: + self._create_player(name) + except GLib.Error as e: + logger.error(f"Failed to list bus names: {e}") - def _on_name_appeared(self, sender, name, _manager): - self._create_player(name) + def _on_name_owner_changed( + self, _connection, _sender, _object_path, _interface, _signal_name, parameters + ): + name, old_owner, new_owner = parameters.unpack() + if not name.startswith(MPRIS_PLAYER_PREFIX) or name == PLAYERCTLD_SERVICE: + return + if new_owner and not old_owner: + self._create_player(name) + elif old_owner and not new_owner: + self._on_player_vanished(name) - def _on_player_vanished(self, sender, player, _manager): - name = player.props.player_name + def _create_player(self, name: str): + if name in self._services: + return + try: + proxy = Gio.DBusProxy.new_sync( + self._connection, + Gio.DBusProxyFlags.NONE, + _MPRIS_PLAYER_IFACE_INFO, + name, + MPRIS_PLAYER_PATH, + MPRIS_PLAYER_IFACE, + None, + ) + service = PlayerService(name, proxy) + self._services[name] = service + self.new_player(name, service) + except GLib.Error as e: + logger.error(f"Failed to create player {name}: {e}") + + def _on_player_vanished(self, name: str): if name in self._services: self._services[name].cleanup() del self._services[name] - self._player_objects.pop(name, None) - self.player_vanish(name) + self.player_vanish(name) def get_player_service(self, name: str) -> PlayerService | None: return self._services.get(name) diff --git a/src/window/controlcenter/expanded_player.py b/src/window/controlcenter/expanded_player.py index 51e9388..44afb56 100644 --- a/src/window/controlcenter/expanded_player.py +++ b/src/window/controlcenter/expanded_player.py @@ -509,7 +509,7 @@ class PlayerBox(Box): def _load_initial_metadata(self): try: - metadata = self.player._player.props.metadata + metadata = self.player.metadata if metadata: keys = metadata.keys() self.track_title.set_label( @@ -529,7 +529,7 @@ class PlayerBox(Box): ) except Exception as e: logger.warning( - f"[expanded_player] metadata = self.player._player.props.metadata failed: {e}" + f"[expanded_player] metadata = self.player.metadata failed: {e}" ) def update_buttons(self, player_buttons, show_buttons): diff --git a/src/window/controlcenter/player.py b/src/window/controlcenter/player.py index 08c28ee..2eafef7 100644 --- a/src/window/controlcenter/player.py +++ b/src/window/controlcenter/player.py @@ -460,7 +460,7 @@ class PlayerBox(Box): self.cover_path = result try: - metadata = self.player._player.props.metadata + metadata = self.player.metadata if metadata: keys = metadata.keys() self.track_title.set_label( @@ -472,9 +472,7 @@ class PlayerBox(Box): else "No Artist" ) except Exception as e: - logger.warning( - f"[player] metadata = self.player._player.props.metadata failed: {e}" - ) + logger.warning(f"[player] metadata = self.player.metadata failed: {e}") def update_buttons(self, player_buttons, show_buttons): if show_buttons and len(player_buttons) > 1: @@ -576,7 +574,7 @@ class PlayerBox(Box): self.play_pause_icon.dynamic_file( "player/play.svg" if current_status == "paused" else "player/pause.svg" ) - self._on_metadata(self.player, self.player._player.props.metadata) + self._on_metadata(self.player, self.player.metadata) self.set_image() def _on_map(self, *_):