From 56be78aa776c7556546db4ef866cdc8d069c04ee Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Sun, 3 Aug 2025 02:19:14 +0545 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(controlcenter):=20implement=20?= =?UTF-8?q?media=20player=20widget=20with=20MPRIS=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/assets/icons/media/next.svg | 9 + config/assets/icons/media/no-shuffle.svg | 14 + config/assets/icons/media/pause.svg | 11 + config/assets/icons/media/play.svg | 9 + config/assets/icons/media/previous.svg | 9 + config/assets/icons/media/shuffle.svg | 14 + main.css | 3 +- modules/controlcenter/main.py | 4 +- modules/controlcenter/player.py | 685 +++++++++++++++++++++++ styles/player.css | 135 +++++ 10 files changed, 891 insertions(+), 2 deletions(-) create mode 100644 config/assets/icons/media/next.svg create mode 100644 config/assets/icons/media/no-shuffle.svg create mode 100644 config/assets/icons/media/pause.svg create mode 100644 config/assets/icons/media/play.svg create mode 100644 config/assets/icons/media/previous.svg create mode 100644 config/assets/icons/media/shuffle.svg create mode 100644 styles/player.css diff --git a/config/assets/icons/media/next.svg b/config/assets/icons/media/next.svg new file mode 100644 index 0000000..de6d70b --- /dev/null +++ b/config/assets/icons/media/next.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/config/assets/icons/media/no-shuffle.svg b/config/assets/icons/media/no-shuffle.svg new file mode 100644 index 0000000..aadbd5d --- /dev/null +++ b/config/assets/icons/media/no-shuffle.svg @@ -0,0 +1,14 @@ + + + + + + diff --git a/config/assets/icons/media/pause.svg b/config/assets/icons/media/pause.svg new file mode 100644 index 0000000..4d02c52 --- /dev/null +++ b/config/assets/icons/media/pause.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/config/assets/icons/media/play.svg b/config/assets/icons/media/play.svg new file mode 100644 index 0000000..bcd3011 --- /dev/null +++ b/config/assets/icons/media/play.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/config/assets/icons/media/previous.svg b/config/assets/icons/media/previous.svg new file mode 100644 index 0000000..792a245 --- /dev/null +++ b/config/assets/icons/media/previous.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/config/assets/icons/media/shuffle.svg b/config/assets/icons/media/shuffle.svg new file mode 100644 index 0000000..81cf35e --- /dev/null +++ b/config/assets/icons/media/shuffle.svg @@ -0,0 +1,14 @@ + + + + + + diff --git a/main.css b/main.css index 5fd1b8d..58cff50 100644 --- a/main.css +++ b/main.css @@ -9,12 +9,13 @@ @import url("./styles/dropdown.css"); @import url("./styles/about.css"); @import url("./styles/notification-center.css"); +@import url("./styles/player.css"); * { all: unset; color: var(--foreground); font-size: 12px; - font-family: "Rubik", sans-serif; + font-family: unset; } #corner { diff --git a/modules/controlcenter/main.py b/modules/controlcenter/main.py index 190da28..1e61a46 100644 --- a/modules/controlcenter/main.py +++ b/modules/controlcenter/main.py @@ -16,6 +16,8 @@ from services.brightness import Brightness from utils.exml import exml from utils.roam import audio_service, modus_service from widgets.wayland import WaylandWindow as Window +from modules.controlcenter.player import PlayerContainer +# from modules.controlcenter.player1 import PlayerContainer brightness_service = Brightness.get_initial() @@ -104,7 +106,7 @@ class ModusControlCenter(Window): self.music_widget = Box( name="music-widget", - # children=[Player()], + children=[PlayerContainer()], ) self.wifi_man = WifiConnections(self) diff --git a/modules/controlcenter/player.py b/modules/controlcenter/player.py index e69de29..c9d5f0d 100644 --- a/modules/controlcenter/player.py +++ b/modules/controlcenter/player.py @@ -0,0 +1,685 @@ +import urllib.parse +from urllib.parse import urlparse + +from gi.repository import GLib +from loguru import logger + +import config.data as data +from fabric.utils import get_relative_path +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.eventbox import EventBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.stack import Stack +from fabric.widgets.svg import Svg +from services.mpris import PlayerManager, PlayerService + + +class Player(Box): + def __init__(self, player, **kwargs): + super().__init__(style_classes="player-box", **kwargs) + + self._player = PlayerService(player=player) + + self.duration = 0.0 + + self._player.connect("pause", self.on_pause) + self._player.connect("play", self.on_play) + self._player.connect("meta-change", self.on_metadata) + self._player.connect("shuffle-toggle", self.on_shuffle) + self._player.connect("track-position", self.on_update_track_position) + + player.connect("metadata", self.on_direct_metadata_change) + + self.player_name = Image( + name=player.props.player_name, + style_classes="player-icon", + icon_name=self._get_player_icon_name(player.props.player_name), + size=20, + ) + + self.song = Label( + name="song", + label="song", + justification="left", + h_align="start", + ellipsization="end", + max_chars_width=15, + ) + self.artist = Label( + name="artist", + label="artist", + justification="left", + h_align="start", + ) + self.music = Box( + name="music", + h_expand=True, + v_expand=True, + children=[self.song], + ) + + self.play_pause_button = Button( + name="play-pause-button", + child=Svg( + svg_file=get_relative_path("../../config/assets/icons/media/play.svg"), + size=20, + ), + tooltip_text="Play/Pause", + on_clicked=lambda b, *_: self.handle_play_pause(player), + ) + + self.shuffle_button = Button( + name="shuffle-button", + child=Svg( + svg_file=get_relative_path( + "../../config/assets/icons/media/shuffle.svg" + ), + size=20, + ), + on_clicked=lambda b, *_: self.handle_shuffle(b, player), + ) + + self.album_cover = Box(style_classes="album-image") + self.album_cover.set_style( + f"background-image:url('{data.HOME_DIR}/.current.wall')" + ) + + self.children = [ + self.album_cover, + Box( + name="source", + v_align="end", + children=self.player_name, + ), + Box( + orientation="v", + v_expand=True, + h_expand=True, + style="padding-left:10px;", + children=[ + CenterBox( + name="details", + center_children=self.music, + ), + Box( + name="controls", + spacing=5, + h_expand=True, + children=[ + CenterBox( + h_expand=True, + v_expand=True, + center_children=[ + Button( + name="prev-button", + child=Svg( + svg_file=get_relative_path( + "../../config/assets/icons/media/previous.svg" + ), + size=20, + ), + on_clicked=lambda b, *_: self.handle_prev( + player + ), + ), + self.play_pause_button, + Button( + name="next-button", + child=Svg( + svg_file=get_relative_path( + "../../config/assets/icons/media/next.svg" + ), + size=20, + ), + on_clicked=lambda b, *_: self.handle_next( + player + ), + ), + self.shuffle_button, + ], + ) + ], + ), + ], + ), + ] + + self.on_metadata(self._player, metadata=player.props.metadata, player=player) + + def _get_player_icon_name(self, player_name): + symbolic_players = [ + "spotify", + "firefox", + "chromium", + "chrome", + "brave", + "vivaldi", + ] + + if player_name.lower() in symbolic_players: + icon_name = f"{player_name.lower()}-symbolic" + return icon_name + else: + return player_name.lower() + + def _update_play_pause_icon(self, is_playing): + if is_playing: + self.play_pause_button.get_child().set_from_file( + get_relative_path("../../config/assets/icons/media/pause.svg") + ) + else: + self.play_pause_button.get_child().set_from_file( + get_relative_path("../../config/assets/icons/media/play.svg") + ) + + def _update_shuffle_icon(self, is_shuffled): + if is_shuffled: + self.shuffle_button.get_child().set_from_file( + get_relative_path("../../config/assets/icons/media/shuffle.svg") + ) + self.shuffle_button.get_child().set_name("shuffle") + else: + self.shuffle_button.get_child().set_from_file( + get_relative_path("../../config/assets/icons/media/no-shuffle.svg") + ) + self.shuffle_button.get_child().set_name("disable-shuffle") + + def on_direct_metadata_change(self, player, metadata): + try: + logger.debug(f"Direct metadata change for {player.props.player_name}") + self.on_metadata(self._player, metadata, player) + except Exception as e: + logger.warning(f"Failed to handle direct metadata change: {e}") + + def on_update_track_position(self, sender, pos, dur): + if dur == 0: + return + self.duration = dur + + def on_seek(self, sender, ratio): + pos = ratio * self.duration # duration in seconds + print(f"Seeking to {pos:.2f}s") + self._player.set_position(int(pos)) + + def skip_forward(self, seconds=10): + self._player._player.seek(seconds * 1000000) + + def skip_backward(self, seconds=10): + self._player._player.seek(-1 * seconds * 1000000) + + def _is_shuffle_supported(self, player): + try: + _ = player.props.shuffle + return True + except Exception: + return False + + def on_metadata(self, sender, metadata, player): + keys = metadata.keys() + if "xesam:artist" in keys and "xesam:title" in keys: + song_title = metadata["xesam:title"] + self.song.set_label(song_title) + + artist_list = metadata["xesam:artist"] + artist_name = artist_list[0] if artist_list else "Unknown Artist" + self.artist.set_label(artist_name) + + self._handle_thumbnail(metadata, player) + + if ( + player.props.playback_status.value_name + == "PLAYERCTL_PLAYBACK_STATUS_PLAYING" + ): + self.on_play(self._player) + + if self._is_shuffle_supported(player): + if player.props.shuffle == True: + self._update_shuffle_icon(True) + else: + self._update_shuffle_icon(False) + else: + self.shuffle_button.set_visible(False) + + def _handle_thumbnail(self, metadata, player): + keys = metadata.keys() + player_name = player.props.player_name + + browser_players = ["firefox", "chromium", "vivaldi", "brave"] + is_browser = player_name in browser_players + + thumbnail_url = None + + if "mpris:artUrl" in keys: + art_url = metadata["mpris:artUrl"] + if art_url and art_url.strip(): + if art_url.startswith("data:image/"): + thumbnail_url = art_url + elif art_url.startswith("http"): + thumbnail_url = art_url + elif art_url.startswith("file://"): + thumbnail_url = art_url + + if is_browser and not thumbnail_url and "xesam:url" in keys: + webpage_url = metadata["xesam:url"] + if webpage_url and webpage_url.startswith("http"): + # Try to extract actual media thumbnail from the webpage + thumbnail_url = self._extract_media_thumbnail( + webpage_url, metadata, player_name + ) + + if is_browser and not thumbnail_url: + thumbnail_url = self._extract_from_browser_metadata(metadata, player_name) + + if thumbnail_url: + logger.debug(f"Setting media thumbnail for {player_name}: {thumbnail_url}") + self.album_cover.set_style(f"background-image:url('{thumbnail_url}')") + else: + logger.debug( + f"No media thumbnail found for {player_name}, using default wallpaper" + ) + self.album_cover.set_style( + f"background-image:url('{data.HOME_DIR}/.current.wall')" + ) + + def _extract_media_thumbnail(self, webpage_url, metadata, player_name): + try: + parsed_url = urlparse(webpage_url) + domain = parsed_url.netloc + path = parsed_url.path + + logger.debug( + f"Extracting media thumbnail for {player_name} from: {webpage_url}" + ) + + if "youtube.com" in domain or "youtu.be" in domain: + video_id = self._extract_youtube_video_id(webpage_url) + if video_id: + logger.debug(f"Extracted YouTube video ID: {video_id}") + return f"https://img.youtube.com/vi/{video_id}/maxresdefault.jpg" + + elif "vimeo.com" in domain: + video_id = self._extract_vimeo_video_id(webpage_url) + if video_id: + logger.debug(f"Extracted Vimeo video ID: {video_id}") + return f"https://vumbnail.com/{video_id}.jpg" + + elif "twitch.tv" in domain: + channel_name = self._extract_twitch_channel(webpage_url) + if channel_name: + logger.debug(f"Extracted Twitch channel: {channel_name}") + return f"https://static-cdn.jtvnw.net/previews-ttv/live_user_{channel_name}-1280x720.jpg" + + return self._extract_from_metadata_fields(metadata, player_name) + + except Exception as e: + logger.warning(f"Failed to extract media thumbnail: {e}") + return None + + def _extract_youtube_video_id(self, url): + try: + if "youtube.com/watch" in url: + query = urlparse(url).query + params = dict(urllib.parse.parse_qsl(query)) + return params.get("v") + elif "youtu.be/" in url: + path = urlparse(url).path + return path.strip("/") + elif "youtube.com/embed/" in url: + path = urlparse(url).path + return path.split("/")[-1] + except Exception as e: + logger.warning(f"Failed to extract YouTube video ID: {e}") + return None + + def _extract_vimeo_video_id(self, url): + try: + path = urlparse(url).path + video_id = path.strip("/") + if video_id.isdigit(): + return video_id + except Exception as e: + logger.warning(f"Failed to extract Vimeo video ID: {e}") + return None + + def _extract_twitch_channel(self, url): + try: + path = urlparse(url).path + channel = path.strip("/") + if channel and not channel.startswith("videos"): + return channel + except Exception as e: + logger.warning(f"Failed to extract Twitch channel: {e}") + return None + + def _extract_from_browser_metadata(self, metadata, player_name): + try: + keys = metadata.keys() + + thumbnail_fields = [ + "mpris:artUrl", + "xesam:artUrl", + "xesam:albumArt", + "xesam:thumbnail", + "xesam:image", + "xesam:cover", + ] + + for field in thumbnail_fields: + if field in keys: + value = metadata[field] + if value and isinstance(value, str) and value.strip(): + if value.startswith(("http://", "https://", "data:image/")): + logger.debug(f"Found thumbnail in {field}: {value}") + return value + + return None + + except Exception as e: + logger.warning(f"Failed to extract from browser metadata: {e}") + return None + + def _extract_from_metadata_fields(self, metadata, player_name): + try: + keys = metadata.keys() + + thumbnail_fields = [ + "mpris:artUrl", + "xesam:artUrl", + "xesam:albumArt", + "xesam:thumbnail", + "xesam:image", + "xesam:cover", + "xesam:albumArtUrl", + ] + + for field in thumbnail_fields: + if field in keys: + value = metadata[field] + if value and isinstance(value, str) and value.strip(): + if value.startswith( + ("http://", "https://", "data:image/", "file://") + ): + logger.debug(f"Found thumbnail in {field}: {value}") + return value + + return None + + except Exception as e: + logger.warning(f"Failed to extract from metadata fields: {e}") + return None + + def on_pause(self, sender): + self._update_play_pause_icon(False) + + def on_play(self, sender): + self._update_play_pause_icon(True) + + def on_shuffle(self, sender, player, status): + print("callback status", status) + self._update_shuffle_icon(status) + + def handle_next(self, player): + self._player._player.next() + self._refresh_metadata_after_delay(player) + + if player.props.player_name in ["firefox", "chromium", "vivaldi", "brave"]: + self._refresh_metadata_immediate(player) + + def handle_prev(self, player): + self._player._player.previous() + self._refresh_metadata_after_delay(player) + + if player.props.player_name in ["firefox", "chromium", "vivaldi", "brave"]: + self._refresh_metadata_immediate(player) + + def _refresh_metadata_immediate(self, player): + try: + current_metadata = player.props.metadata + if current_metadata: + logger.debug( + f"Immediate metadata refresh for {player.props.player_name}" + ) + self.on_metadata(self._player, current_metadata, player) + except Exception as e: + logger.warning(f"Failed immediate metadata refresh: {e}") + + def _refresh_metadata_after_delay(self, player): + def refresh_metadata(): + try: + current_metadata = player.props.metadata + if current_metadata: + logger.debug(f"Refreshing metadata for {player.props.player_name}") + self.on_metadata(self._player, current_metadata, player) + else: + logger.debug( + f"No metadata available for { + player.props.player_name + }, retrying..." + ) + GLib.timeout_add(1000, refresh_metadata) + except Exception as e: + logger.warning(f"Failed to refresh metadata: {e}") + + GLib.timeout_add(500, refresh_metadata) + + def handle_play_pause(self, player): + is_playing = ( + self._player._player.props.playback_status.value_name + == "PLAYERCTL_PLAYBACK_STATUS_PLAYING" + ) + + def _set_play_ui(): + self._update_play_pause_icon(True) + + def _set_pause_ui(): + self._update_play_pause_icon(False) + + if is_playing: + _set_pause_ui() + else: + _set_play_ui() + + try: + self._player._player.play_pause() + except Exception as e: + if is_playing: + _set_pause_ui() + else: + _set_play_ui() + logger.warning("Failed to toggle playback:", e) + + def handle_shuffle(self, shuffle_button, player): + print("shuffle", player.props.shuffle) + + if not self._is_shuffle_supported(player): + print(f"Shuffle not supported for {player.props.player_name}") + return + + try: + if player.props.shuffle == False: + player.set_shuffle(True) + print("setting to true", player.props.player_name) + else: + player.set_shuffle(False) + except Exception as e: + print(f"Failed to toggle shuffle for {player.props.player_name}: {e}") + pass + shuffle_button.get_child().set_style("color: var(--outline)") + + +class PlacheholderMini(Box): + def __init__(self, **kwargs): + super().__init__(style_classes="player-mini", **kwargs) + + self.album_cover = Box(style_classes="album-image") + self.album_cover.set_style( + f"background-image:url('{data.HOME_DIR}/.current.wall')" + ) + self.player_name = Image( + name="player-icon", + icon_name="disc-symbolic", + size=20, + ) + + self.children = [ + self.album_cover, + Box( + name="source", + v_align="end", + children=self.player_name, + ), + Box( + orientation="v", + v_expand=True, + h_expand=True, + style="padding-left:10px;", + v_align="center", + children=[ + CenterBox( + name="details", + center_children=Label( + label="Nothing Playing", style="color:black;" + ), + ) + ], + ), + ] + + +class PlayerContainer(Box): + def __init__(self, **kwargs): + super().__init__(name="player-container", **kwargs) + + self.manager = PlayerManager() + self.manager.connect("new-player", self.new_player) + self.manager.connect("player-vanish", self.on_player_vanish) + self.placeholder = PlacheholderMini() + self.stack = Stack( + transition_type="crossfade", + transition_duration=100, + children=[self.placeholder], + ) + + self.player_stack = Stack( + name="player-stack", + transition_type="crossfade", + transition_duration=100, + children=[], + ) + + self.player_switch_container = CenterBox( + name="player-switch-container", + orientation="v", + center_children=[], + ) + self.mini_tile_icon = Image( + name="disc", + style="font-size:30px; margin:0px; padding:0px;", + icon_name="disc-symbolic", + size=30, + ) + + self.mini_tile_view = CenterBox( + v_expand=False, + center_children=self.mini_tile_icon, + ) + self.event_box = EventBox( + events=["scroll"], + child=Box(children=[self.stack, self.player_switch_container]), + ) + self.children = self.event_box + self.players = [] + self.manager.init_all_players() + self.event_box.connect("scroll-event", self.on_scroll) + self.stack.connect("notify::visible-child", self.on_visible_child_changed) + + def new_player(self, manager, player): + print(player.props.player_name, "new player") + print(player) + new_player = Player(player=player) + new_player.set_name(player.props.player_name) + self.players.append(new_player) + print("stacking mini", player.props.player_name) + self.stack.add_named(new_player, player.props.player_name) + if len(self.players) == 1: + self.stack.remove(self.placeholder) + + self.player_switch_container.add_center( + Button( + name=player.props.player_name, + style_classes="player-button", + on_clicked=lambda b: self.switch_player(player.props.player_name, b), + ) + ) + self.update_player_list() + + def switch_player(self, player_name, button): + self.stack.set_visible_child_name(player_name) + + for btn in self.player_switch_container.center_children: + btn.remove_style_class("active") + button.add_style_class("active") + + def on_player_vanish(self, manager, player): + for player_instance in self.players: + if player_instance.get_name() == player.props.player_name: + self.stack.remove(player_instance) + self.players.remove(player_instance) + for btn in self.player_switch_container.center_children: + if btn.get_name() == player_instance.get_name(): + self.player_switch_container.remove_center(btn) + self.update_player_list() + break + + if len(self.players) == 0: + self.stack.add_named(self.placeholder, "placeholder") + + def update_player_list(self): + curr = self.stack.get_visible_child() + if curr is None: + return + for btn in self.player_switch_container.center_children: + print(btn.get_name()) + if btn.get_name() == curr.get_name(): + btn.add_style_class("active") + else: + btn.remove_style_class("active") + + def on_scroll(self, widget, event): + match event.direction: + case 0: + self.switch_relative_player(forward=False) + case 1: + self.switch_relative_player(forward=True) + + def switch_relative_player(self, forward=True): + if not self.players: + return + + current_player = self.stack.get_visible_child() + if current_player is None: + return + + current_index = self.players.index(current_player) + + next_index = (current_index + (1 if forward else -1)) % len(self.players) + next_player = self.players[next_index] + + for btn in self.player_switch_container.center_children: + if btn.get_name() == next_player.get_name(): + self.switch_player(next_player.get_name(), btn) + break + + def on_visible_child_changed(self, *args): + curr_child = self.stack.get_visible_child() + if curr_child is None: + return + curr_player = curr_child.get_name() + self.mini_tile_icon.set_name(curr_player) + self.mini_tile_icon.set_icon_name(self._get_player_icon_name(curr_player)) + + def get_mini_view(self): + return self.mini_tile_view diff --git a/styles/player.css b/styles/player.css new file mode 100644 index 0000000..32f7671 --- /dev/null +++ b/styles/player.css @@ -0,0 +1,135 @@ +.player-box { + min-height: 60px; + min-width: 313px; + margin: 2px; + padding: 2px; + box-shadow: + inset 0 0 100px rgba(148, 148, 148, 0.3), + inset 70px 0 20px rgba(255, 255, 255, 0.3), + inset 0px 0px 20px 5px rgba(255, 255, 255, 0.3); + border-radius: 10px; + background-position: center; + background-size: cover; +} + +#player-container { + min-height: 60px; + border-radius: 10px; + background-position: center; + background-size: cover; +} + +.album-image { + min-width: 60px; + background-position: center; + background-size: cover; + border-radius: 10px; +} + +#artist { + color: var(--primary); +} + +#song { + color: var(--surface); + font-size: 15px; + margin-top: 5px; +} + +#source { + margin-left: -20px; + border-radius: 10px; +} + +#play-pause-label { + color: var(--surface); + text-shadow: none; +} + +#pause-button { + background-color: whitesmoke; + border-radius: 16px; + padding: 10px; +} + +#pause-label, +#play-label { + color: var(--shadow); + font-size: 20px; + padding: 2px; + border-radius: 999px; + min-width: 25px; +} + +#pause-label.mini, +#play-label.mini { + color: white; + font-size: 20px; + padding: 2px; + border-radius: 999px; + min-width: 25px; +} + +#shuffle-button, +#next-button, +#prev-button, +#play-pause-button { + border: none; + padding: 0px; + padding-left: 11px; + padding-right: 11px; +} + +#shuffle-button:hover, +#next-button:hover, +#prev-button:hover, +#play-pause-button:hover { + border-radius: 100%; + + box-shadow: inset rgba(255, 255, 255, 0.5) 0 0 10px; +} + +.player-button { + border-radius: 5px; + background-color: var(--shadow); + /* background-color: white; */ + opacity: 1; + min-width: 10px; + min-height: 10px; + padding: 0px; + margin: 0px; + border: none; + margin-top: 5px; +} + +.player-button.active { + opacity: 1; + /* min-width: 40px; */ + /* border-radius: 5px; */ +} + +#player-switch-container { + margin-top: 0px; + margin-left: -18px; + padding-right: 10px; +} + +#controls { + padding-bottom: 10px; + margin-top: 5px; + margin-bottom: 0px; +} + +#player-icon { + font-size: 20px; + color: black; + text-shadow: 0 0 5px white; +} + +#shuffle { + font-size: 17px; +} + +#disable-shuffle { + font-size: 27px; +}