diff --git a/MediaAction.py b/MediaAction.py index 999adce..210bafe 100644 --- a/MediaAction.py +++ b/MediaAction.py @@ -102,10 +102,19 @@ def get_player_name(self): if settings is not None: return settings.get("player_name") + def get_display_player_name(self): + """Get the player name for display purposes (status, title, thumbnail). + When 'All Players' is selected, returns the currently-playing player + so the UI shows the active player's info instead of an arbitrary one.""" + name = self.get_player_name() + if name is not None: + return name + return self.plugin_base.mc.get_active_player_name() + def show_title(self, reload_key = True) -> bool: if self.get_settings() == None: return False - title = self.plugin_base.mc.title(self.get_player_name()) + title = self.plugin_base.mc.title(self.get_display_player_name()) if self.get_settings().setdefault("show_label", True) and title is not None: label = None if isinstance(title, list): @@ -143,6 +152,27 @@ def on_toggle_thumbnail(self, switch, *args): # Update image self.on_tick() + def _load_thumbnail(self, player_name: str = None) -> "Image.Image | None": + """Load the thumbnail image for the given player. + Returns a PIL Image if a thumbnail is available, or None.""" + import io as _io + settings = self.get_settings() + if settings is None: + return None + if not settings.setdefault("show_thumbnail", True): + return None + thumbnail = self.plugin_base.mc.thumbnail(player_name) + if thumbnail is None: + return None + if isinstance(thumbnail, list): + if thumbnail[0] is not None: + try: + return Image.open(thumbnail[0]) + except Exception as e: + return None + return None + return None + def generate_image(self, icon:Image.Image = None, background:Image.Image=None, valign: float = 0, halign: float = 0, size: float = 1): if background is None: background = Image.new("RGBA", (self.deck_controller.deck.key_image_format()["size"]), (0, 0, 0, 0)) @@ -151,12 +181,11 @@ def generate_image(self, icon:Image.Image = None, background:Image.Image=None, v if icon is not None: # Resize - lenght = int(self.deck_controller.deck.key_image_format()["size"][0] * size) - icon = icon.resize((lenght, lenght)) + length = int(self.deck_controller.deck.key_image_format()["size"][0] * size) + icon = icon.resize((length, length)) left_margin = int((background.width - icon.width) * (halign + 1) / 2) top_margin = int((background.height - icon.height) * (valign + 1) / 2) - background.paste(icon, (left_margin, top_margin), icon) - return background \ No newline at end of file + return background diff --git a/MediaController.py b/MediaController.py index b74bb7a..56066b6 100644 --- a/MediaController.py +++ b/MediaController.py @@ -16,6 +16,15 @@ class MediaController: def __init__(self): self.session_bus = dbus.SessionBus() + # Tracks the D-Bus bus names of players that were playing before a pause action. + # When pause is pressed, all currently-playing sources are recorded here. + # When play is pressed, only these remembered sources are resumed. + self._previously_playing: set[str] = set() + + # Remembers the last player that was seen playing, so that when all + # players are paused the UI doesn't jump back to an arbitrary player. + self._last_active_player: str | None = None + self.update_players() def update_players(self): @@ -29,6 +38,19 @@ def update_players(self): return self.mpris_players = mpris_players + def _get_bus_name(self, player) -> str: + """Get the D-Bus bus name for a player proxy object.""" + return str(player.bus_name) + + def _get_playback_status(self, iface) -> str | None: + """Get the playback status of a player interface.""" + try: + properties = dbus.Interface(iface, 'org.freedesktop.DBus.Properties') + return str(properties.Get('org.mpris.MediaPlayer2.Player', 'PlaybackStatus')) + except dbus.exceptions.DBusException as e: + log.error(e) + return None + def get_player_names(self, remove_duplicates = False): names = [] try: @@ -43,6 +65,22 @@ def get_player_names(self, remove_duplicates = False): log.error("Could not connect to D-Bus session bus. Is the D-Bus daemon running?", e) return names + def get_active_player_name(self) -> str | None: + """Return the Identity of the first currently-playing MPRIS player. + Falls back to the last known active player if none are currently playing.""" + self.update_players() + for player in self.mpris_players: + try: + properties = dbus.Interface(player, 'org.freedesktop.DBus.Properties') + status = str(properties.Get('org.mpris.MediaPlayer2.Player', 'PlaybackStatus')) + if status == "Playing": + name = str(properties.Get('org.mpris.MediaPlayer2', 'Identity')) + self._last_active_player = name + return name + except dbus.exceptions.DBusException: + pass + return self._last_active_player + def get_matching_ifaces(self, player_name: str = None) -> list[dbus.Interface]: self.update_players() """ @@ -65,10 +103,30 @@ def get_matching_ifaces(self, player_name: str = None) -> list[dbus.Interface]: except dbus.exceptions.DBusException as e: log.warning(e) return ifaces + + def get_matching_players(self, player_name: str = None) -> list[tuple[str, dbus.Interface]]: + """ + Like get_matching_ifaces, but returns (bus_name, iface) tuples + so callers can identify individual player instances. + """ + self.update_players() + players = [] + for player in self.mpris_players: + properties = dbus.Interface(player, 'org.freedesktop.DBus.Properties') + try: + if player_name in [None, "", properties.Get('org.mpris.MediaPlayer2', 'Identity')]: + bus_name = self._get_bus_name(player) + iface = dbus.Interface(player, 'org.mpris.MediaPlayer2.Player') + players.append((bus_name, iface)) + except dbus.exceptions.DBusException as e: + log.warning(e) + return players def pause(self, player_name: str = None): """ Pauses the media player specified by the `player_name` parameter. + Before pausing, records which players are currently playing so they + can be resumed later with play(). Args: player_name (str, optional): The name of the media player to pause. @@ -78,19 +136,32 @@ def pause(self, player_name: str = None): None """ status = [] - ifaces = self.get_matching_ifaces(player_name) - for iface in ifaces: + players = self.get_matching_players(player_name) + currently_playing = set() + for bus_name, iface in players: try: + playback_status = self._get_playback_status(iface) + if playback_status == "Playing": + currently_playing.add(bus_name) iface.Pause() status.append(True) except dbus.exceptions.DBusException as e: log.error(e) status.append(False) + + # Only update the remembered set if at least one player was actually playing. + # This avoids clearing the set if pause is pressed while already paused. + if currently_playing: + self._previously_playing = currently_playing + return self.compress_list(status) def play(self, player_name: str = None): """ Plays the media player specified by the `player_name` parameter. + If there are remembered previously-playing sources (from a prior pause), + only those sources will be resumed. Otherwise, all matching players + will be played. Args: player_name (str, optional): The name of the media player to play. @@ -100,19 +171,37 @@ def play(self, player_name: str = None): None """ status = [] - ifaces = self.get_matching_ifaces(player_name) - for iface in ifaces: + players = self.get_matching_players(player_name) + for bus_name, iface in players: try: - iface.Play() - status.append(True) + if player_name is not None: + # Specific player requested: always play it + iface.Play() + status.append(True) + elif self._previously_playing: + # General play with remembered set: only resume those + if bus_name in self._previously_playing: + iface.Play() + status.append(True) + else: + status.append(True) # skip, not an error + else: + iface.Play() + status.append(True) except dbus.exceptions.DBusException as e: log.error(e) status.append(False) + + # Clear the remembered set after resuming + self._previously_playing.clear() + return self.compress_list(status) def toggle(self, player_name: str = None): """ Toggles the playback state of the media player specified by the `player_name` parameter. + Uses the smart pause/play logic: when pausing, remembers which sources were playing; + when playing, only resumes those remembered sources. Args: player_name (str, optional): The name of the media player to toggle. @@ -121,16 +210,19 @@ def toggle(self, player_name: str = None): Returns: None """ - status = [] - ifaces = self.get_matching_ifaces(player_name) - for iface in ifaces: - try: - iface.PlayPause() - status.append(True) - except dbus.exceptions.DBusException as e: - log.error(e) - status.append(False) - return self.compress_list(status) + # Check if any matching player is currently playing + players = self.get_matching_players(player_name) + any_playing = False + for bus_name, iface in players: + playback_status = self._get_playback_status(iface) + if playback_status == "Playing": + any_playing = True + break + + if any_playing: + return self.pause(player_name) + else: + return self.play(player_name) def stop(self, player_name: str = None): """ @@ -256,8 +348,12 @@ def thumbnail(self, player_name: str = None) -> list[str]: try: properties = dbus.Interface(iface, 'org.freedesktop.DBus.Properties') metadata = properties.Get('org.mpris.MediaPlayer2.Player', 'Metadata') - path = str(metadata.get('mpris:artUrl')) - if path in [None, ""]: + art_url = metadata.get('mpris:artUrl') + if art_url is None: + thumbnails.append(None) + continue + path = str(art_url) + if not path: thumbnails.append(None) continue if path.startswith("data:"): diff --git a/README.md b/README.md new file mode 100644 index 0000000..b01a3c5 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# MediaPlugin + +This plugin controls your player using the +[MPRIS](https://specifications.freedesktop.org/mpris-spec/latest/) interface. + +It will let you play/pause the current playing item and start the previous/next +track using the [StreamController](https://github.com/StreamController/StreamController). + +## Actions + +- **Play** - Starts media playback if the player is not currently playing. +- **Pause** - Pauses media playback if the player is currently playing. +- **PlayPause** - Toggles between play and pause states based on the current + playback status. If more than one item is playing, it will pause/play all +- **Next** - Skips to the next track in the media queue. +- **Previous** - Returns to the previous track in the media queue. +- **Info** - Displays the current media title and artist information with and + optional separator. +- **ThumbnailBackground** - Sets the current media thumbnail as the deck + background image. diff --git a/main.py b/main.py index 41640f2..f4c4c97 100644 --- a/main.py +++ b/main.py @@ -37,7 +37,8 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def on_key_down(self): - status = self.plugin_base.mc.status(self.get_player_name()) + display_player = self.get_display_player_name() + status = self.plugin_base.mc.status(display_player) if status is None or status[0] != "Playing": self.plugin_base.mc.play(self.get_player_name()) @@ -54,7 +55,8 @@ def update_image(self): if self.get_settings() == None: # Page not yet fully loaded return - status = self.plugin_base.mc.status(self.get_player_name()) + display_player = self.get_display_player_name() + status = self.plugin_base.mc.status(display_player) if isinstance(status, list): status = status[0] @@ -66,11 +68,11 @@ def update_image(self): valign = 0 icon_path = os.path.join(self.plugin_base.PATH, "assets", "play.png") - + image = Image.open(icon_path) + if status == None: if self.current_status == None: self.current_status = "Playing" - image = Image.open(icon_path) enhancer = ImageEnhance.Brightness(image) image = enhancer.enhance(0.6) self.set_media(image=image, size=size, valign=valign) @@ -78,41 +80,25 @@ def update_image(self): self.current_status = status - ## Thumbnail - thumbnail = None - if self.get_settings().setdefault("show_thumbnail", True): - thumbnail = self.plugin_base.mc.thumbnail(self.get_player_name()) - if thumbnail == None: - thumbnail = Image.new("RGBA", (256, 256), (255, 255, 255, 0)) - elif isinstance(thumbnail, list): - if thumbnail[0] == None: - return - if isinstance(thumbnail[0], io.BytesIO): - pass - elif not os.path.exists(thumbnail[0]): - return - try: - thumbnail = Image.open(thumbnail[0]) - except: - return - - - image = Image.open(icon_path) - if status == "Playing": enhancer = ImageEnhance.Brightness(image) image = enhancer.enhance(0.6) - - image = self.generate_image(background=thumbnail, icon=image, size=size, valign=valign) - self.set_media(image=image) + ## Thumbnail + thumbnail = self._load_thumbnail(display_player) + if thumbnail is not None: + image = self.generate_image(background=thumbnail, icon=image, size=size, valign=valign) + self.set_media(image=image) + else: + self.set_media(image=image, size=size, valign=valign) class Pause(MediaAction): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def on_key_down(self): - status = self.plugin_base.mc.status(self.get_player_name()) + display_player = self.get_display_player_name() + status = self.plugin_base.mc.status(display_player) if status is None or status[0] == "Playing": self.plugin_base.mc.pause(self.get_player_name()) @@ -129,7 +115,8 @@ def update_image(self): if self.get_settings() == None: # Page not yet fully loaded return - status = self.plugin_base.mc.status(self.get_player_name()) + display_player = self.get_display_player_name() + status = self.plugin_base.mc.status(display_player) if isinstance(status, list): status = status[0] @@ -141,11 +128,11 @@ def update_image(self): valign = 0 icon_path = os.path.join(self.plugin_base.PATH, "assets", "pause.png") - + image = Image.open(icon_path) + if status == None: if self.current_status == None: self.current_status = "Playing" - image = Image.open(icon_path) enhancer = ImageEnhance.Brightness(image) image = enhancer.enhance(0.6) self.set_media(image=image, size=size, valign=valign) @@ -153,41 +140,24 @@ def update_image(self): self.current_status = status - ## Thumbnail - thumbnail = None - if self.get_settings().setdefault("show_thumbnail", True): - thumbnail = self.plugin_base.mc.thumbnail(self.get_player_name()) - if thumbnail == None: - thumbnail = Image.new("RGBA", (256, 256), (255, 255, 255, 0)) - elif isinstance(thumbnail, list): - if thumbnail[0] == None: - return - if isinstance(thumbnail[0], io.BytesIO): - pass - elif not os.path.exists(thumbnail[0]): - return - try: - thumbnail = Image.open(thumbnail[0]) - except: - return - - - image = Image.open(icon_path) - if status == "Paused": enhancer = ImageEnhance.Brightness(image) image = enhancer.enhance(0.6) - - image = self.generate_image(background=thumbnail, icon=image, size=size, valign=valign) - self.set_media(image=image) + thumbnail = self._load_thumbnail(display_player) + if thumbnail is not None: + image = self.generate_image(background=thumbnail, icon=image, size=size, valign=valign) + self.set_media(image=image) + else: + self.set_media(image=image, size=size, valign=valign) class PlayPause(MediaAction): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def on_key_down(self): - status = self.plugin_base.mc.status(self.get_player_name()) + display_player = self.get_display_player_name() + status = self.plugin_base.mc.status(display_player) if status is None: return status = status[0] @@ -209,7 +179,8 @@ def update_image(self): if self.get_settings() == None: # Page not yet fully loaded return - status = self.plugin_base.mc.status(self.get_player_name()) + display_player = self.get_display_player_name() + status = self.plugin_base.mc.status(display_player) if isinstance(status, list): status = status[0] @@ -218,14 +189,14 @@ def update_image(self): "Paused": os.path.join(self.plugin_base.PATH, "assets", "play.png"), "Stopped": os.path.join(self.plugin_base.PATH, "assets", "stop.png"), #play.png might make more sense } - + if self.show_title(): size = 0.75 valign = -1 else: size = 1 valign = 0 - + if status == None: if self.current_status == None: self.current_status = "Playing" @@ -238,30 +209,14 @@ def update_image(self): self.current_status = status - ## Thumbnail - thumbnail = None - if self.get_settings().setdefault("show_thumbnail", True): - thumbnail = self.plugin_base.mc.thumbnail(self.get_player_name()) - if thumbnail == None: - thumbnail = Image.new("RGBA", (256, 256), (255, 255, 255, 0)) - elif isinstance(thumbnail, list): - if thumbnail[0] == None: - return - if isinstance(thumbnail[0], io.BytesIO): - pass - elif not os.path.exists(thumbnail[0]): - return - try: - thumbnail = Image.open(thumbnail[0]) - except: - return - - image = Image.open(file.get(status, file["Stopped"])) - - image = self.generate_image(background=thumbnail, icon=image, size=size, valign=valign) - self.set_media(image=image) + thumbnail = self._load_thumbnail(display_player) + if thumbnail is not None: + image = self.generate_image(background=thumbnail, icon=image, size=size, valign=valign) + self.set_media(image=image) + else: + self.set_media(image=image, size=size, valign=valign) class Next(MediaAction): def __init__(self, *args, **kwargs): @@ -277,7 +232,8 @@ def on_tick(self): self.update_image() def update_image(self): - status = self.plugin_base.mc.status(self.get_player_name()) + display_player = self.get_display_player_name() + status = self.plugin_base.mc.status(display_player) if isinstance(status, list): status = status[0] @@ -293,23 +249,15 @@ def update_image(self): enhancer = ImageEnhance.Brightness(image) image = enhancer.enhance(0.6) - - thumbnail = None if self.get_settings() is None: return - if self.get_settings().setdefault("show_thumbnail", True): - thumbnail = self.plugin_base.mc.thumbnail(self.get_player_name()) - if thumbnail == None: - thumbnail = Image.new("RGBA", (256, 256), (255, 255, 255, 0)) - elif isinstance(thumbnail, list): - try: - thumbnail = Image.open(thumbnail[0]) - except: - return - - image = self.generate_image(background=thumbnail, icon=image, size=size, valign=valign) - self.set_media(image=image) + thumbnail = self._load_thumbnail(display_player) + if thumbnail is not None: + image = self.generate_image(background=thumbnail, icon=image, size=size, valign=valign) + self.set_media(image=image) + else: + self.set_media(image=image, size=size, valign=valign) class Previous(MediaAction): def __init__(self, *args, **kwargs): @@ -325,7 +273,8 @@ def on_tick(self): self.update_image() def update_image(self): - status = self.plugin_base.mc.status(self.get_player_name()) + display_player = self.get_display_player_name() + status = self.plugin_base.mc.status(display_player) if isinstance(status, list): status = status[0] @@ -340,23 +289,16 @@ def update_image(self): if status == None: enhancer = ImageEnhance.Brightness(image) image = enhancer.enhance(0.6) - - thumbnail = None + if self.get_settings() is None: return - if self.get_settings().setdefault("show_thumbnail", True): - thumbnail = self.plugin_base.mc.thumbnail(self.get_player_name()) - if thumbnail == None: - thumbnail = Image.new("RGBA", (256, 256), (255, 255, 255, 0)) - elif isinstance(thumbnail, list): - try: - thumbnail = Image.open(thumbnail[0]) - except: - return - - image = self.generate_image(background=thumbnail, icon=image, size=size, valign=valign) - self.set_media(image=image) + thumbnail = self._load_thumbnail(display_player) + if thumbnail is not None: + image = self.generate_image(background=thumbnail, icon=image, size=size, valign=valign) + self.set_media(image=image) + else: + self.set_media(image=image, size=size, valign=valign) class Info(MediaAction): def __init__(self, *args, **kwargs): @@ -366,8 +308,9 @@ def on_tick(self): self.update_image() def update_image(self): - title = self.plugin_base.mc.title(self.get_player_name()) - artist = self.plugin_base.mc.artist(self.get_player_name()) + display_player = self.get_display_player_name() + title = self.plugin_base.mc.title(display_player) + artist = self.plugin_base.mc.artist(display_player) if title is not None: title = self.shorten_label(title[0], 10) @@ -381,24 +324,7 @@ def update_image(self): self.set_center_label(self.get_settings().get("seperator_text", "--"), font_size=12) self.set_bottom_label(str(artist), font_size=12) - ## Thumbnail - thumbnail = None - if self.get_settings().setdefault("show_thumbnail", True): - thumbnail = self.plugin_base.mc.thumbnail(self.get_player_name()) - if thumbnail == None: - thumbnail = Image.new("RGBA", (256, 256), (255, 255, 255, 0)) - elif isinstance(thumbnail, list): - if thumbnail[0] == None: - return - if isinstance(thumbnail[0], io.BytesIO): - pass - elif not os.path.exists(thumbnail[0]): - return - try: - thumbnail = Image.open(thumbnail[0]) - except: - return - + thumbnail = self._load_thumbnail(display_player) self.set_media(image=thumbnail) def get_config_rows(self): @@ -692,8 +618,9 @@ def _should_update(self) -> bool: """Check if update is needed based on state changes.""" # Check if media is playing - title = self.plugin_base.mc.title(self.get_player_name()) # type: ignore[attr-defined] - artist = self.plugin_base.mc.artist(self.get_player_name()) # type: ignore[attr-defined] + display_player = self.get_display_player_name() + title = self.plugin_base.mc.title(display_player) # type: ignore[attr-defined] + artist = self.plugin_base.mc.artist(display_player) # type: ignore[attr-defined] # If both title and artist are None, no media is playing if title is None and artist is None: @@ -741,7 +668,7 @@ def _get_thumbnail_path(self) -> str | None: Returns None if no thumbnail is available or if the data format is unexpected. """ try: - thumbnail_data = self.plugin_base.mc.thumbnail(self.get_player_name()) # type: ignore[attr-defined] + thumbnail_data = self.plugin_base.mc.thumbnail(self.get_display_player_name()) # type: ignore[attr-defined] if isinstance(thumbnail_data, list) and thumbnail_data: first_item = thumbnail_data[0] # Validate that the first item is a non-empty string and a valid file @@ -1192,8 +1119,6 @@ def clear(self): - Request final composite to show remaining actions/background """ - log.debug("ThumbnailBackground: clear called, cleaning up cached images") - # Reset this instance's tracking variables self.last_thumbnail_path = None self.last_size_mode = None