✨ feat(controlcenter): implement media player widget with MPRIS integration
This commit is contained in:
10 files changed
+891
-2
No files matched your search
@@ -0,0 +1,9 @@
|
||||
<svg version="1.1" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<style id="current-color-scheme" type="text/css">.ColorScheme-Text {
|
||||
color:#dedede;
|
||||
}</style>
|
||||
</defs>
|
||||
<path class="ColorScheme-Text" d="m6.1738 5.6631c0.37981 1e-7 0.54189 0.12418 0.65234 0.18555 0.0072 0.00447 0.01438 0.00902 0.02148 0.013672l6.9356 4.0957c0.07518 0.043562 0.20874 0.10765 0.36914 0.30469 0.16225 0.1993 0.25781 0.52881 0.25781 0.73633 0 0.20754-0.08765 0.52075-0.24219 0.7207-0.15288 0.1978-0.29598 0.27643-0.38477 0.32812l-6.9355 4.0957c-0.0071 0.0046-0.01426 0.0092-0.02148 0.01367-0.16196 0.09008-0.38778 0.17969-0.65234 0.17969-0.25637-5e-6 -0.58772-0.09652-0.83203-0.3418-0.24432-0.24527-0.3418-0.587-0.3418-0.84766v-8.2871c0-0.26064 0.08983-0.5862 0.32031-0.83203 0.23049-0.24583 0.57064-0.36523 0.85352-0.36523zm0 1c-0.09815 0-0.17383 0.068844-0.17383 0.19727v8.2871c0 0.12841 0.07568 0.18945 0.17383 0.18945 0.04538 0 0.09802-0.01687 0.16602-0.05469l6.9355-4.0957c0.09064-0.05286 0.13477-0.09682 0.13477-0.1875s-0.04412-0.12878-0.13477-0.18164l-6.9355-4.0957c-0.06799-0.037776-0.12063-0.058594-0.16602-0.058594z" fill="currentColor"/>
|
||||
<rect class="ColorScheme-Text" transform="scale(-1,1)" x="-17" y="6" width="1" height="10" rx=".5" ry=".5" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#dedede;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 16 5 L 16 7 L 3 7 L 3 8 L 16 8 L 16 10 L 19 7.5 L 16 5 z M 16 12 L 16 14 L 3 14 L 3 15 L 16 15 L 16 17 L 19 14.5 L 16 12 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 465 B |
@@ -0,0 +1,11 @@
|
||||
<svg version="1.1" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<style id="current-color-scheme" type="text/css">.ColorScheme-Text {
|
||||
color:#dedede;
|
||||
}</style>
|
||||
</defs>
|
||||
<g class="ColorScheme-Text" fill="currentColor">
|
||||
<path d="m9.5 8c-0.277 0-0.5 0.223-0.5 0.5v5c0 0.277 0.223 0.5 0.5 0.5s0.5-0.223 0.5-0.5v-5c0-0.277-0.223-0.5-0.5-0.5zm3 0c-0.277 0-0.5 0.223-0.5 0.5v5c0 0.277 0.223 0.5 0.5 0.5s0.5-0.223 0.5-0.5v-5c0-0.277-0.223-0.5-0.5-0.5z"/>
|
||||
<path d="m11 3a8 8 0 0 0-8 8 8 8 0 0 0 8 8 8 8 0 0 0 8-8 8 8 0 0 0-8-8zm0 1a7 7 0 0 1 7 7 7 7 0 0 1-7 7 7 7 0 0 1-7-7 7 7 0 0 1 7-7z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 634 B |
@@ -0,0 +1,9 @@
|
||||
<svg version="1.1" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<style id="current-color-scheme" type="text/css">.ColorScheme-Text {
|
||||
color:#dedede;
|
||||
}</style>
|
||||
</defs>
|
||||
<path class="ColorScheme-Text" d="m11 3a8 8 0 0 0-8 8 8 8 0 0 0 8 8 8 8 0 0 0 8-8 8 8 0 0 0-8-8zm0 1a7 7 0 0 1 7 7 7 7 0 0 1-7 7 7 7 0 0 1-7-7 7 7 0 0 1 7-7z" fill="currentColor"/>
|
||||
<path class="ColorScheme-Text" d="m9.498 7.5723c-0.26595 0.011578-0.49805 0.18369-0.49805 0.46875v5.9063c0 0.41453 0.46559 0.61778 0.86328 0.38086l4.873-2.8691c0.35533-0.20313 0.34686-0.71884 0-0.92188l-4.873-2.877c-0.11636-0.066104-0.24435-0.093154-0.36523-0.087891z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 683 B |
@@ -0,0 +1,9 @@
|
||||
<svg version="1.1" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<style id="current-color-scheme" type="text/css">.ColorScheme-Text {
|
||||
color:#dedede;
|
||||
}</style>
|
||||
</defs>
|
||||
<path class="ColorScheme-Text" d="m15.826 5.6631c-0.37981 1e-7 -0.54189 0.12418-0.65234 0.18555-0.0072 0.00447-0.01438 0.00902-0.02148 0.013672l-6.9356 4.0957c-0.075177 0.043562-0.20874 0.10765-0.36914 0.30469-0.16225 0.1993-0.25781 0.52881-0.25781 0.73633 0 0.20754 0.087648 0.52075 0.24219 0.7207 0.15287 0.1978 0.29598 0.27643 0.38477 0.32812l6.9355 4.0957c0.0071 0.0046 0.01426 0.0092 0.02148 0.01367 0.16196 0.09008 0.38778 0.17969 0.65234 0.17969 0.25637-5e-6 0.58772-0.09652 0.83203-0.3418 0.24432-0.24527 0.3418-0.587 0.3418-0.84766v-8.2871c0-0.26064-0.08983-0.5862-0.32031-0.83203-0.23049-0.24583-0.57064-0.36523-0.85352-0.36523zm0 1c0.09815 0 0.17383 0.068844 0.17383 0.19727v8.2871c0 0.12841-0.07568 0.18945-0.17383 0.18945-0.04538 0-0.09802-0.01687-0.16602-0.05469l-6.9355-4.0957c-0.09064-0.05286-0.13477-0.09682-0.13477-0.1875s0.04412-0.12878 0.13477-0.18164l6.9355-4.0957c0.06799-0.037776 0.12063-0.058594 0.16602-0.058594z" fill="currentColor"/>
|
||||
<rect class="ColorScheme-Text" x="5" y="6" width="1" height="10" rx=".5" ry=".5" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#dedede;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 16 5 L 16 7 L 13 7 C 10.784 7 9 8.784 9 11 C 9 12.662 7.662 14 6 14 L 3 14 L 3 15 L 6 15 C 8.216 15 10 13.216 10 11 C 10 9.338 11.338 8 13 8 L 16 8 L 16 10 L 19 7.5 L 16 5 z M 3 7 L 3 8 L 6 8 C 6.8536128 8 7.6168144 8.3572581 8.1621094 8.9257812 C 8.2578781 8.5814037 8.3956215 8.2568349 8.5742188 7.9570312 C 7.8779048 7.3680394 6.9875937 7 6 7 L 3 7 z M 16 12 L 16 14 L 13 14 C 12.146387 14 11.383186 13.642742 10.837891 13.074219 C 10.742121 13.418597 10.604379 13.743165 10.425781 14.042969 C 11.122095 14.631961 12.012406 15 13 15 L 16 15 L 16 17 L 19 14.5 L 16 12 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 914 B |
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user