Media Control Applet for the Budgie Panel 
#    budgie-media-player-applet
#    Copyright (C) 2023 Alex Cizinsky
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <https://www.gnu.org/licenses/>.

import gi

gi.require_version("Gtk", "3.0")
gi.require_version("Gio", "2.0")
gi.require_version("Budgie", "1.0")
from gi.repository import Gtk, Gio, GLib, Budgie

from SingleAppPlayer import SingleAppPlayer


class BudgieMediaPlayer(Budgie.Applet):
    def __init__(self, uuid):
        Budgie.Applet.__init__(self)
        self.box = Gtk.Box(spacing=10)
        self.add(self.box)

        self.popup_icon = Gtk.Image.new_from_icon_name("arrow-down", Gtk.IconSize.MENU)
        self.popup_icon_event_box = Gtk.EventBox()
        self.popup_icon_event_box.add(self.popup_icon)
        self.popup_icon_event_box.connect("button-press-event", self.show_popup)
        self.box.pack_end(self.popup_icon_event_box, False, False, 0)

        self.popover = Budgie.Popover.new(self)
        self.popover_manager = Budgie.PopoverManager()
        self.popover_manager.register_popover(self, self.popover)

        self.popover_box = Gtk.Box.new(Gtk.Orientation.VERTICAL, 10)
        self.popover_box.set_margin_bottom(5)
        self.popover_box.set_margin_top(5)
        self.popover_box.set_margin_start(5)
        self.popover_box.set_margin_end(5)
        self.popover.add(self.popover_box)

        self.dbus_namespace_name = "org.mpris.MediaPlayer2"
        self.session_bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
        self.session_bus.signal_subscribe(
            None,  # Sender
            None,  # Interface
            "NameOwnerChanged",  # Member
            None,  # Object path
            "org.mpris.MediaPlayer2",  # Arg0 (sender name)
            Gio.DBusSignalFlags.MATCH_ARG0_NAMESPACE,  # Flags
            self.dbus_players_changed,  # Callback function
        )
        dbus_names = self.list_dbus_players()

        self.players_list: [SingleAppPlayer] = []
        for dbus_name in dbus_names:
            self.players_list.append(SingleAppPlayer(dbus_name))
            if len(self.players_list) < 2:
                self.box.pack_start(self.players_list[-1], False, False, 0)

            else:
                self.popover_box.add(self.players_list[-1])

        self.popover.get_child().show_all()
        self.show_all()

        if len(self.players_list) < 2:
            self.popup_icon.hide()

    def show_popup(self, *args):
        self.popover_manager.show_popover(self)

    def list_dbus_players(self):
        names = self.session_bus.call_sync(
            "org.freedesktop.DBus",  # Destination name
            "/org/freedesktop/DBus",  # Object path
            "org.freedesktop.DBus",  # Interface name
            "ListNames",  # Method name
            None,  # Input parameters
            GLib.VariantType.new("(as)"),  # Output type (array of strings)
            Gio.DBusCallFlags.NONE,  # Flags
            -1,  # Timeout (default)
            None,  # Cancellable (none)
        )
        return [x for x in names[0] if x.startswith(self.dbus_namespace_name)]

    def dbus_players_changed(
        self, a, b, c, d, e, changes
    ):  # args a-e are arguments i dont need, but get sent by gtk
        if (changes[0] not in self.players_list) and changes[2]:  # player was added
            self.players_list.append(SingleAppPlayer(changes[0]))
            if len(self.players_list) < 2:
                self.box.pack_start(self.players_list[-1], False, False, 0)

            else:
                self.popover_box.add(self.players_list[-1])
                self.popup_icon.show()

        elif not changes[2]:  # player was removed
            for player in enumerate(self.players_list):
                if player[1].service_name == changes[0]:
                    if player[0] == 0:
                        self.box.remove(player[1])
                        del self.players_list[player[0]]

                        if len(self.players_list) > 0:
                            self.popover_box.remove(self.players_list[0])
                            self.box.pack_start(self.players_list[0], False, False, 0)

                    else:
                        self.popover_box.remove(player[1])
                        del self.players_list[player[0]]

            if len(self.players_list) < 2:
                self.popup_icon.hide()

src/BudgieMediaPlayer.py
zalesyc/budgie-media-player-applet
#    budgie-media-player-applet
#    Copyright (C) 2023 Alex Cizinsky
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <https://www.gnu.org/licenses/>.

from urllib.parse import urlparse
import requests
from PIL import Image
from io import BytesIO

import gi

gi.require_version("Gtk", "3.0")
gi.require_version("Gio", "2.0")
gi.require_version("GdkPixbuf", "2.0")
from gi.repository import Gtk, Gio, GLib, GdkPixbuf

from mprisWrapper import MprisWrapper


class SingleAppPlayer(Gtk.Box):
    def __init__(self, service_name: str):
        self.album_cover_height: int = Gtk.IconSize.lookup(Gtk.IconSize.DND)[2]
        

        Gtk.Box.__init__(self, spacing=0)
        self.service_name = service_name

        self.dbus_player = MprisWrapper(self.service_name)
        self.dbus_player.player_connect("PlaybackStatus", self.playing_changed)
        self.dbus_player.player_connect("Metadata", self.metadata_changed)

        start_song_metadata = self.dbus_player.get_player_property("Metadata")

        # album_cover
        self.album_cover = Gtk.Image.new_from_icon_name(
            "action-unavailable-symbolic", Gtk.IconSize.MENU
        )
        album_cover_event_box = Gtk.EventBox()
        album_cover_event_box.add(self.album_cover)
        album_cover_event_box.connect("button-press-event", self.song_clicked)
        self._set_album_cover(start_song_metadata.lookup_value("mpris:artUrl", None))

        # song_text
        self.song_text = Gtk.Label()
        self._set_song_label(
            start_song_metadata.lookup_value("xesam:artist", None),
            start_song_metadata.lookup_value("xesam:title", None),
        )
        song_text_event_box = Gtk.EventBox()
        song_text_event_box.add(self.song_text)
        song_text_event_box.connect("button-press-event", self.song_clicked)

        # control buttons
        play_pause_icon = Gtk.Image()
        self.can_play: bool = self.dbus_player.get_player_property("CanPlay").get_boolean()
        self.can_pause: bool = self.dbus_player.get_player_property("CanPause").get_boolean()
        if (self.dbus_player.get_player_property("PlaybackStatus").get_string() == "Playing"):
            self.play_pause_button = self._init_button(
                "media-playback-pause-symbolic", self.play_paused_clicked,
                enabled=(self.can_pause or self.can_play)
            )

        else:
            self.play_pause_button = self._init_button(
                "media-playback-start-symbolic", self.play_paused_clicked, 
                enabled=(self.can_pause or self.can_play)
            )
        self.dbus_player.player_connect("CanPlay", self.can_play_changed)
        self.dbus_player.player_connect("CanPause", self.can_pause_changed)
     
        self.backward_button = self._init_button("media-skip-backward-symbolic", self.backward_clicked, enabled=self.dbus_player.get_player_property("CanGoPrevious").get_boolean())
        self.dbus_player.player_connect("CanGoPrevious", self.can_go_previous_changed)

        self.forward_button = self._init_button("media-skip-forward-symbolic", self.forward_clicked, enabled=self.dbus_player.get_player_property("CanGoNext").get_boolean())
        self.dbus_player.player_connect("CanGoNext", self.can_go_next_changed)

        # add all widgets
        self.pack_start(album_cover_event_box, False, False, 5)
        self.pack_start(song_text_event_box, True, True, 5)
        self.pack_end(self.forward_button, False, False, 0)
        self.pack_end(self.play_pause_button, False, False, 0)
        self.pack_end(self.backward_button, False, False, 0)

        self.show_all()

    def playing_changed(self, status: GLib.Variant):
        if status.get_string() == "Playing":
            play_pause_icon = Gtk.Image.new_from_icon_name(
                "media-playback-pause-symbolic", Gtk.IconSize.MENU
            )
            self.play_pause_button.set_image(play_pause_icon)
            
        elif status.get_string() == "Paused":
            play_pause_icon = Gtk.Image.new_from_icon_name(
                "media-playback-start-symbolic", Gtk.IconSize.MENU
            )
            self.play_pause_button.set_image(play_pause_icon)

    def metadata_changed(self, metadata: GLib.Variant):
        self._set_song_label(
            metadata.lookup_value("xesam:artist", None),
            metadata.lookup_value("xesam:title", None),
        )
        self._set_album_cover(metadata.lookup_value("mpris:artUrl", None))
    
    def can_play_changed(self, metadata: GLib.Variant):
            self.can_play = metadata.get_boolean()
            self.backward_button.set_sensitive(self.can_play or self.can_pause)

    def can_pause_changed(self, metadata: GLib.Variant):
        self.can_pause = metadata.get_boolean()
        self.backward_button.set_sensitive(self.can_play or self.can_pause)

    def can_go_previous_changed(self, metadata: GLib.Variant):
            self.backward_button.set_sensitive(metadata.get_boolean())

    def can_go_previous_changed(self, metadata: GLib.Variant):
            self.backward_button.set_sensitive(metadata.get_boolean())
    
    def can_go_next_changed(self, metadata: GLib.Variant):
            self.forward_button.set_sensitive(metadata.get_boolean())

    def play_paused_clicked(self, *args):
        self.dbus_player.call_player_method("PlayPause")

    def forward_clicked(self, *args):
        self.dbus_player.call_player_method("Next")

    def backward_clicked(self, *args):
        self.dbus_player.call_player_method("Previous")

    def song_clicked(self, *args):
        self.dbus_player.call_app_method("Raise")
      

    def _init_button(self, icon_name: str, on_pressed: callable, icon_size: Gtk.IconSize = Gtk.IconSize.MENU, enabled: bool = True) -> Gtk.Button:
        icon = Gtk.Image.new_from_icon_name(icon_name, icon_size)
        button = Gtk.Button()
        button.set_image(icon)
        button.set_relief(Gtk.ReliefStyle.NONE)
        button.set_sensitive(enabled)
        button.connect("button-press-event", on_pressed)
        return button

    def _set_song_label(self, author, title):
        if author is None:
            author = ""
        else:
            author = author.unpack()
            if len(author) == 0:
                author = ""
            else:
                author = author[0]

        if title is None:
            title = ""
        else:
            title = title.unpack()

        if title == "":
            if author == "":
                song_text = "Unknown"
            else:
                song_text = f"{author} - Unknown"
        else:
            if author == "":
                song_text = f"{title}"
            else:
                song_text = f"{author} - {title}"

        self.song_text.set_label(song_text)

    def _set_album_cover(self, art_url):
        if art_url is None:
            self._set_album_cover_other()
            return

        url = art_url.get_string()
        parsed_url = urlparse(url)
        if parsed_url.scheme == "file":
            try:
                pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
                    parsed_url.path, -1, self.album_cover_height, True
                )
                self.album_cover.set_from_pixbuf(pixbuf)
            except gi.repository.GLib.GError:
                self._set_album_cover_other()

        elif parsed_url.scheme == "https":
            self._set_album_cover_https(url)

        else:
            self._set_album_cover_other()

    def _set_album_cover_other(self):
        desktop_file_name = self.dbus_player.get_app_property("DesktopEntry")
        if desktop_file_name is not None:
            desktop_file_name = desktop_file_name.get_string()
            try:
                desktop_app_info = Gio.DesktopAppInfo.new(
                    desktop_file_name + ".desktop"
                )
            except TypeError:
                pass
            else:
                self.album_cover.set_from_gicon(
                    desktop_app_info.get_icon(), Gtk.IconSize.MENU
                )
                return

        self.album_cover.set_from_icon_name(
            "applications-community-symbolic", Gtk.IconSize.MENU
        )

    def _set_album_cover_https(self, url):
        pixbuf = self._stream_image_to_gdkpixbuf(url)

        if pixbuf:
            self.album_cover.set_from_pixbuf(
                pixbuf.scale_simple(
                    int(
                        (self.album_cover_height / pixbuf.get_height())
                        * pixbuf.get_width()
                    ),  # calculating width based on height to have the same proportions
                    self.album_cover_height,
                    GdkPixbuf.InterpType.NEAREST,
                )
            )
        else:
            self._set_album_cover_other()

    def _stream_image_to_gdkpixbuf(self, url):
        try:
            # Send a GET request to the URL
            response = requests.get(url, stream=True)
            if response.status_code != 200:
                return None

            # Read the image data in chunks
            img_data = BytesIO()
            for chunk in response.iter_content(chunk_size=1024):
                img_data.write(chunk)

            # Rewind the buffer and open as an image with Pillow
            img_data.seek(0)
            pil_image = Image.open(img_data)

            # Convert the Pillow image to a GdkPixbuf
            gdkpixbuf = GdkPixbuf.Pixbuf.new_from_data(
                pil_image.tobytes(),  # Image data as bytes
                GdkPixbuf.Colorspace.RGB,
                False,
                8,  # Bits per sample
                pil_image.width,
                pil_image.height,
                pil_image.width * 3,  # Rowstride
            )

            return gdkpixbuf
        except Exception:
            return None

src/SingleAppPlayer.py
zalesyc/budgie-media-player-applet
#    budgie-media-player-applet
#    Copyright (C) 2023 Alex Cizinsky
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <https://www.gnu.org/licenses/>.

import gi.repository

gi.require_version("Budgie", "1.0")
from gi.repository import Budgie, GObject
from BudgieMediaPlayer import BudgieMediaPlayer


class BudgieMediaPlayerApplet(GObject.GObject, Budgie.Plugin):
    # This is simply an entry point into your Budgie Applet implementation.
    # Note: you must always override Object, and implement Plugin.

    # Good manners, make sure we have unique name in GObject type system
    __gtype_name__ = "BudgieMediaPlayer"

    def __init__(self):
        GObject.Object.__init__(self)

    def do_get_panel_widget(self, uuid):
        # This is where the real fun happens. Return a new Budgie.Applet
        # instance with the given UUID. The UUID is determined by the
        # BudgiePanelManager, and is used for lifetime tracking.
        return BudgieMediaPlayer(uuid)

src/applet.py
zalesyc/budgie-media-player-applet
#    budgie-media-player-applet
#    Copyright (C) 2023 Alex Cizinsky
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <https://www.gnu.org/licenses/>.

import gi

gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib
from typing import Callable


class MprisWrapper:
    def __init__(self, service_name: str):
        self._object_path = "/org/mpris/MediaPlayer2"
        self._interface_name_player = "org.mpris.MediaPlayer2.Player"
        self._interface_name_app = "org.mpris.MediaPlayer2"
        self._connected_functions: dict[str:Callable] = {}
        self.service_name: str = service_name

        self.player_proxy = Gio.DBusProxy.new_for_bus_sync(
            Gio.BusType.SESSION,
            Gio.DBusProxyFlags.NONE,
            None,
            self.service_name,
            self._object_path,
            self._interface_name_player,
            None,
        )

        self.app_proxy = Gio.DBusProxy.new_for_bus_sync(
            Gio.BusType.SESSION,
            Gio.DBusProxyFlags.NONE,
            None,
            self.service_name,
            self._object_path,
            self._interface_name_app,
            None,
        )

        self.player_proxy.connect("g-properties-changed", self._property_changed)

    def player_connect(
        self, property_name: str, func: Callable[[GLib.Variant], None]
    ) -> None:
        self._connected_functions.update({property_name: func})

    def get_player_property(self, property_name: str) -> GLib.Variant:
        return self.player_proxy.get_cached_property(property_name)

    def get_app_property(self, property_name: str) -> GLib.Variant:
        return self.app_proxy.get_cached_property(property_name)

    def call_player_method(self, method_name: str, callback: Callable = None) -> None:
        self.player_proxy.call(
            method_name=method_name,
            parameters=None,
            flags=0,
            timeout_msec=-1,
            cancellable=None,
            callback=callback,
            user_data=None,
        )

    def call_app_method(self, method_name: str, callback: Callable = None) -> None:
        self.app_proxy.call(
            method_name=method_name,
            parameters=None,
            flags=0,
            timeout_msec=-1,
            cancellable=None,
            callback=callback,
            user_data=None,
        )

    def _property_changed(self, proxy, parameters, *args):
        for key in self._connected_functions.keys():
            property_value = parameters.lookup_value(key, None)
            if property_value is not None:
                func = self._connected_functions.get(key)
                func(property_value)

src/mprisWrapper.py
zalesyc/budgie-media-player-applet
#! /bin/python3

#    budgie-media-player-applet
#    Copyright (C) 2023 Alex Cizinsky
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <https://www.gnu.org/licenses/>.

import gi.repository

gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib
from BudgieMediaPlayer import BudgieMediaPlayer


class MyWindow(Gtk.Window):
    def __init__(self):
        super().__init__(title="MediaPlayerTestWin")
        self.mp = BudgieMediaPlayer(0)
        self.add(self.mp)


def main():
    mainloop = GLib.MainLoop()

    def quit(*args):
        mainloop.quit()

    win = MyWindow()
    win.connect("destroy", quit)
    win.show()

    mainloop.run()


if __name__ == "__main__":
    main()

src/testWin.py
zalesyc/budgie-media-player-applet