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 Placheholder(Box): def __init__(self, **kwargs): super().__init__(style_classes="player-box", **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="media-player-48", 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 = Placheholder() 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.tile_icon = Image( name="disc", style="font-size:30px; margin:0px; padding:0px;", icon_name="disc-symbolic", size=30, ) self.tile_view = CenterBox( v_expand=False, center_children=self.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): new_player = Player(player=player) new_player.set_name(player.props.player_name) self.players.append(new_player) 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.tile_icon.set_name(curr_player) self.tile_icon.set_icon_name(self._get_player_icon_name(curr_player)) def get_view(self): return self.tile_view