✨ feat: add dashboard module with network, bluetooth and player controls

This commit is contained in:
sankalp committed 2025-07-24 12:46:41 +05:45
1 parent fef4312996
commit 943fe24a55
18 files changed
+1946 -849

No files matched your search

+3 -3
View File
@@ -70,7 +70,9 @@ if os.path.exists(CONFIG_FILE):
WORKSPACE_DOTS = config.get("workspace_dots", False)
WINDOW_SWITCHER_ITEMS_PER_ROW = config.get("window_switcher_items_per_row", 13)
DOCK_HIDE_SPECIAL_WORKSPACE = config.get("dock_hide_special_workspace", True)
DOCK_HIDE_SPECIAL_WORKSPACE_APPS = config.get("dock_hide_special_workspace_apps", True)
DOCK_HIDE_SPECIAL_WORKSPACE_APPS = config.get(
"dock_hide_special_workspace_apps", True
)
NOTIF_POS = config.get(NOTIF_POS_KEY, NOTIF_POS_DEFAULT)
@@ -81,7 +83,6 @@ if os.path.exists(CONFIG_FILE):
"date_time": config.get("dock_date_time_visible", True),
"controls": config.get("dock_controls_visible", True),
"indicators": config.get("dock_indicators_visible", True),
"music_player": config.get("dock_music_player_visible", True),
"notifications": config.get("dock_notifications_visible", True),
"systray": config.get("dock_tray_visible", True),
"applications": config.get("dock_applications_visible", True),
@@ -115,7 +116,6 @@ else:
"date_time": True,
"controls": True,
"indicators": True,
"music_player": True,
"notifications": True,
"systray": True,
"applications": True,
+15
View File
@@ -0,0 +1,15 @@
from gi.repository import Gtk
import os
import gi
gi.require_version("Gtk", "3.0")
ALLOWED_PLAYERS = ["vlc", "cmus", "firefox", "spotify", "chromium"]
USERNAME = os.getlogin()
HOSTNAME = os.uname().nodename
HOME_DIR = os.path.expanduser("~")
WALLPAPERS_DIR = os.path.expanduser("~/Pictures/wallpapers/")
VERTICAL = False
BRIGHTNESS_DEV = "intel_backlight"
print(USERNAME, HOSTNAME, HOME_DIR)
print(Gtk)
+108 -3
View File
@@ -52,9 +52,6 @@ tooltip > * {
border-radius: 10px;
}
#workspaces,
#workspaces-container,
#launcher,
@@ -72,3 +69,111 @@ tooltip > * {
box-shadow: 0 0 3px alpha(black, 0.7);
}
#dashboard.hide {
margin: -999px;
transition: margin 0.25s cubic-bezier(0.5, 0.25, 0, 1);
}
#dashboard.reveal {
margin: 0px;
transition: margin 0.25s cubic-bezier(0.5, 0.25, 0, 1);
}
#dashboard {
background-color: black;
border-radius: 25px;
padding: 10px;
}
#inner {
margin: 3px;
}
.tile {
margin: 2px;
min-height: 60px;
min-width: 140px;
border-radius: 20px;
}
.tile.mini {
min-height: 46px;
min-width: 46px;
border-radius: 100%;
}
.tile.on {
background-color: var(--primary);
}
.tile.off {
background-color: var(--surface-bright);
}
.tile.on .desc-label,
.tile.on .tile-label,
.tile.on .tile-icon {
color: black;
transition: all;
}
.tile.off .desc-label,
.tile.off .tile-label,
.tile.off .tile-icon {
color: var(--foreground);
transition: all;
}
.tile-icon {
font-size: 16px;
margin: 10px;
}
.tile-icon.mini {
font-size: 20px;
transition: font-size 0.25s cubic-bezier(0.5, 0.25, 0, 1);
}
.tile-icon.maxi {
font-size: 16px;
transition: font-size 0.25s cubic-bezier(0.5, 0.25, 0, 1);
}
.tile-button {
all: unset;
}
.tile-menu {
all: unset;
min-width: 400px;
min-height: 300px;
margin: -999px;
}
.tile-menu.expand {
margin: 0px;
transition: margin 0.15s cubic-bezier(0.5, 0.25, 0, 1);
}
.tile-menu.contract {
margin: -200px;
transition: margin 0.25s cubic-bezier(0.5, 0.25, 0, 1);
}
.wavy-circle {
color: var(--primary);
background-color: var(--surface);
border-color: var(--primary);
}
#notification-source {
color: var(--outline);
}
#notificationss-icon {
font-size: 36px;
margin-right: 27px;
margin-left: 7px;
color: var(--surface-bright);
}
#notification-container {
background-color: var(--surface);
border-radius: 20px;
}
#notification-box {
background-color: black;
border-radius: 25px;
padding: 10px;
min-width: 364px; /* 290+74 */
margin-top: 3px;
}
+3 -1
View File
@@ -12,6 +12,7 @@ from modules.launcher import Launcher
from modules.notification_popup import NotificationPopup
from modules.osd import OSD
from modules.switcher import ApplicationSwitcher
from modules.dashboard.main import DashboardWindow
for log in [
@@ -48,6 +49,7 @@ if __name__ == "__main__":
osd = OSD()
launcher = Launcher()
nc = NotificationPopup()
dashboard = DashboardWindow()
# Set corners visibility based on config
corners_visible = config.get("corners_visible", True)
@@ -61,7 +63,7 @@ if __name__ == "__main__":
_ = color_css_file.connect("changed", lambda *_: set_css())
app = Application(
f"{APP_NAME}", dock, corners, switcher, osd, launcher, nc
f"{APP_NAME}", dock, corners, switcher, osd, launcher, nc, dashboard
) # Make sure corners is added to the app
def set_css():
+28
View File
@@ -0,0 +1,28 @@
from fabric.widgets.label import Label
from modules.dashboard.tile import Tile
import utils.icons as icons
class Bluetooth(Tile):
def __init__(self, **kwargs):
self.label = Label(
style_classes=["desc-label", "off"],
label="Off",
h_align="start",
)
self.state = False
super().__init__(
label="Bluetooth",
props=self.label,
markup=icons.bluetooth,
menu=True,
**kwargs,
)
if self.state:
self.add_style_class("on")
else:
self.add_style_class("off")
+186
View File
@@ -0,0 +1,186 @@
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.button import Button
from fabric.widgets.revealer import Revealer
from modules.dashboard.bluetooth import Bluetooth
from modules.dashboard.network import Network
from modules.dashboard.tile import Tile, TileSpecial
from utils.wavy_clock import WavyCircle
from modules.dashboard.player_mini import PlayerContainerMini
from utils.wayland import WaylandWindow as Window
import utils.icons as icons
import config.info as info
class Dashboard(Box):
def __init__(self, **kwargs):
super().__init__(
name="dashboard",
visible=True,
orientation="v",
spacing=8,
all_visible=True,
**kwargs,
)
self.wifi = Network()
self.bluetooth = Bluetooth()
self.mini_player = PlayerContainerMini()
self.mini_player_tile = TileSpecial(
props=self.mini_player, mini_props=self.mini_player.get_mini_view()
)
# self.controls = controls
# self.brightness_revealer_mui = self.controls.get_brightness_slider_mui()
#
self.wavy = self.gtk_wrapper = CenterBox(
orientation="v",
h_expand=True,
v_expand=True,
h_align="fill",
v_align="fill",
center_children=WavyCircle(),
)
self.low_bat_msg = Label(label="Low Battery fam (<15%)")
self.content_box = Box(
orientation="v",
children=[
Label(name="notification-source", label="Modus", h_align=True),
self.low_bat_msg,
],
)
close_btn = Button(
name="close-button",
child=Label(name="close-label", markup=icons.cancel),
tooltip_text="Exit",
on_clicked=lambda *_: print("clicked close"),
)
self.notification_box = Box(
name="notification-box",
style_classes="vertical" if info.VERTICAL else "horizontal",
children=[
Label(name="notificationss-icon", h_align=True, markup=icons.blur),
Box(h_expand=True, children=self.content_box),
Box(children=close_btn),
],
)
self.tiles = Box(
children=[
self.wifi,
self.bluetooth,
self.mini_player_tile,
],
)
self.low_bat_msg_2 = Label(label="Notification ctl test")
self.content_box_2 = Box(
orientation="v",
children=[
Label(name="notification-source", label="System", h_align=True),
self.low_bat_msg_2,
],
)
close_btn_2 = Button(
name="close-button",
child=Label(name="close-label", markup=icons.cancel),
tooltip_text="Exit",
on_clicked=lambda *_: print("clicked close"),
)
self.notification_box_2 = Box(
name="notification-box",
style_classes="vertical" if info.VERTICAL else "horizontal",
children=[
Label(name="notificationss-icon", h_align=True, markup=icons.blur),
Box(h_expand=True, children=self.content_box_2),
Box(children=close_btn_2),
],
)
self.notification_container = Revealer(
transition_duration=250,
transition_type="slide-down",
style="background-color:blue;",
h_expand=True,
child=Box(
h_expand=True,
v_expand=True,
children=[
Box(
name="notification-container",
orientation="v",
v_expand=True,
style="padding:3px; padding-top:0px",
children=[self.notification_box, self.notification_box_2],
),
Box(
v_expand=True,
h_expand=True,
children=self.wavy,
),
],
),
child_revealed=True,
)
self.children = [
# Box(
# name="inner",
# orientation="h",
# children=[
# CenterBox(
# style_classes="scale-icon-container",
# center_children=Label(
# style_classes="scale-icon", markup=icons.brightness
# ),
# ),
# self.brightness_revealer_mui,
# ],
# ),
Box(name="inner", children=self.tiles),
Box(
children=[
self.notification_container,
]
),
]
def handle_tile_menu_expand(self, tile: str, toggle: bool):
if toggle:
self.notification_container.set_reveal_child(False)
else:
self.notification_container.set_reveal_child(True)
for i in self.tiles:
if i.get_name() == tile:
print("found")
else:
if toggle:
i.mini_view()
i.icon.add_style_class("mini")
i.icon.remove_style_class("maxi")
else:
i.maxi_view()
i.icon.add_style_class("maxi")
i.icon.remove_style_class("mini")
print("search complete")
class DashboardWindow(Window):
def __init__(self, **kwargs):
super().__init__(
layer="top",
anchor="bottom",
child=Dashboard(),
visible=True,
all_visible=True,
)
self.hide()
+52
View File
@@ -0,0 +1,52 @@
from fabric.widgets.label import Label
from modules.dashboard.tile import Tile
from services.nett import NetworkService
import utils.icons as icons
class Network(Tile):
def __init__(self, **kwargs):
self.label = Label(
style_classes=["desc-label", "off"],
label="Disconnected",
h_align="start",
)
self.state = False
super().__init__(
label="Wi-Fi",
props=self.label,
markup=icons.wifi,
menu=True,
**kwargs,
)
self.nm = NetworkService()
self.nm.connect("connection-change", self.handle_connection_change)
self.nm.init_props()
def handle_connection_change(
self, source, con_label: str, connected: bool, status: str
):
if self.state != connected:
self.state = connected
if self.state:
self.remove_style_class("off")
self.add_style_class("on")
else:
self.remove_style_class("on")
self.add_style_class("off")
print("State change:", connected)
if not connected:
if status == "Wi-Fi Off":
self.label.set_label("Off")
elif status == "Wi-Fi On (No Connection)":
self.label.set_label("Disconnected")
elif status == "Connecting…":
self.label.set_label("Connecting…")
else:
self.label.set_label("Unknown")
else:
self.label.set_label(con_label or "Connected")
+421
View File
@@ -0,0 +1,421 @@
from gi.repository import Gdk
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.button import Button
from fabric.widgets.stack import Stack
from fabric.widgets.eventbox import EventBox
from services.mpris import PlayerManager, PlayerService
from utils.wiggle_bar import WigglyWidget
import utils.icons as icons
import config.data as data
from loguru import logger
import gi
gi.require_version("Gtk", "3.0")
class PlayerMini(Box):
def __init__(self, player, **kwargs):
super().__init__(style_classes="player-mini", **kwargs)
if not data.VERTICAL:
self.remove_style_class(
"vertical"
) # vertical class binding from unknown source
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)
self.player_name = Label(
name=player.props.player_name,
style_classes=["player-icon", "mini"],
markup=getattr(icons, player.props.player_name, icons.disc),
)
self.set_style(f"background-image:url('{data.HOME_DIR}/.current.wall')")
self.song = Label(
name="song",
label="song",
style_classes="mini",
justification="left",
h_align="start",
ellipsization="end",
max_chars_width=15,
)
self.artist = Label(
name="artist",
label="artist",
style_classes="mini",
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=Label(name="play-pause-label", markup=icons.play),
style_classes="mini",
tooltip_text="Play/Pause",
on_clicked=lambda b, *_: self.handle_play_pause(player),
)
self.shuffle_button = Button(
name="shuffle-button",
child=Label(name="shuffle", markup=icons.shuffle),
on_clicked=lambda b, *_: self.handle_shuffle(b, player),
)
self.wiggly = WigglyWidget()
self.wiggly.connect("on-seek", self.on_seek)
self.wiggly_bar = Box(
orientation="v",
h_expand=True,
v_expand=True,
h_align="fill",
v_align="fill",
children=self.wiggly,
)
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",
style_classes="mini",
v_align="end",
children=self.player_name,
),
# CenterBox(
# name="progress-container",
# h_expand=True,
# v_expand=True,
# orientation="v",
# center_children=[self.wiggly_bar],
# ),
Box(
orientation="v",
v_expand=True,
h_expand=True,
style="padding-left:10px;",
children=[
CenterBox(
name="details",
center_children=self.music,
),
Box(
name="controls",
style_classes="horizontal" if not data.VERTICAL else "vertical",
spacing=5,
h_expand=True,
children=[
CenterBox(
h_expand=True,
v_expand=True,
center_children=[
Button(
name="prev-button",
child=Label(
name="play-previous",
style_classes="mini",
markup=icons.previous_fill,
),
on_clicked=lambda b, *_: self.handle_prev(
player
),
),
self.play_pause_button,
Button(
name="next-button",
child=Label(
name="play-next",
style_classes="mini",
markup=icons.next_fill,
),
on_clicked=lambda b, *_: self.handle_next(
player
),
),
# self.shuffle_button,
],
)
],
),
],
),
]
self.on_metadata(self._player, metadata=player.props.metadata, player=player)
def on_update_track_position(self, sender, pos, dur):
if dur == 0:
return
self.duration = dur
self.wiggly.update_value_from_signal(pos / 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 on_metadata(self, sender, metadata, player):
keys = metadata.keys()
if "xesam:artist" in keys and "xesam:title" in keys:
# _max_chars = 20 if not info.VERTICAL else 30
song_title = metadata["xesam:title"]
# if len(song_title) > _max_chars:
# song_title = song_title[: _max_chars - 1] + "…"
self.song.set_label(song_title)
artist_list = metadata["xesam:artist"]
artist_name = artist_list[0] if artist_list else "Unknown Artist"
# if len(artist_name) > _max_chars:
# artist_name = artist_name[: _max_chars - 1] + "…"
self.artist.set_label(artist_name)
if "mpris:artUrl" in keys:
self.set_style(f"background-image:url('{metadata['mpris:artUrl']}')")
self.album_cover.set_style(
f"background-image:url('{metadata['mpris:artUrl']}')"
)
if (
player.props.playback_status.value_name
== "PLAYERCTL_PLAYBACK_STATUS_PLAYING"
):
self.on_play(self._player)
if player.props.shuffle == True:
self.shuffle_button.get_child().set_markup(icons.disable_shuffle)
self.shuffle_button.get_child().set_name("disable-shuffle")
else:
self.shuffle_button.get_child().set_markup(icons.shuffle)
self.shuffle_button.get_child().set_name("shuffle")
def on_pause(self, sender):
self.play_pause_button.get_child().set_markup(icons.play)
self.wiggly.dragging = True
self.wiggly.update_amplitude(True)
self.wiggly.pause = True
# self.play_pause_button.add_style_class("pause-track")
def on_play(self, sender):
self.play_pause_button.get_child().set_markup(icons.pause)
self.wiggly.pause = False
self.wiggly.dragging = False
self.wiggly.update_amplitude(False)
# self.play_pause_button.remove_style_class("pause-track")
def on_shuffle(self, sender, player, status):
print("callback status", status)
if status == False:
self.shuffle_button.get_child().set_markup(icons.shuffle)
self.shuffle_button.get_child().set_name("shuffle")
else:
self.shuffle_button.get_child().set_markup(icons.disable_shuffle)
self.shuffle_button.get_child().set_name("disable-shuffle")
self.shuffle_button.get_child().set_style("color: white")
def handle_next(self, player):
self._player._player.next()
def handle_prev(self, player):
self._player._player.previous()
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.play_pause_button.get_child().set_markup(icons.pause)
# self.play_pause_button.remove_style_class("pause-track")
# self.play_pause_button.get_child().set_name("pause-label")
def _set_pause_ui():
self.play_pause_button.get_child().set_markup(icons.play)
# self.play_pause_button.add_style_class("pause-track")
# self.play_pause_button.get_child().set_name("play-label")
if is_playing:
_set_pause_ui()
else:
_set_play_ui()
try:
self._player._player.play_pause()
except Exception as e:
# revert if signal failed
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 player.props.shuffle == False:
player.set_shuffle(True)
print("setting to true", player.props.player_name)
else:
player.set_shuffle(False)
shuffle_button.get_child().set_style("color: var(--outline)")
class PlayerContainerMini(Box):
def __init__(self, **kwargs):
super().__init__(name="player-container", style_classes="mini", **kwargs)
self.manager = PlayerManager()
self.manager.connect("new-player", self.new_player)
self.manager.connect("player-vanish", self.on_player_vanish)
self.stack = Stack(
# name="player-container",
transition_type="crossfade",
transition_duration=100,
children=[],
)
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",
style_classes=(
["horizontal-player", "mini"]
if not data.VERTICAL
else "vertical-player"
),
center_children=[],
)
self.mini_tile_icon = Label(
name="disc",
style_classes=["tile-icon"],
style="font-size:30px; margin:0px; padding:0px;",
markup=icons.disc,
justification="center",
)
self.mini_tile_view = CenterBox(
style_classes=["tile", "mini", "on"],
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 = PlayerMini(player=player)
new_player.wiggly_bar.queue_draw()
new_player.set_name(player.props.player_name)
self.players.append(new_player)
print("stacking dis bitvch")
self.stack.add_named(new_player, player.props.player_name)
self.player_switch_container.add_center(
Button(
name=player.props.player_name,
style_classes=["player-button", "mini"],
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
def update_player_list(self):
curr = self.stack.get_visible_child()
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()
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_player = self.stack.get_visible_child().get_name()
self.mini_tile_icon.set_name(curr_player)
self.mini_tile_icon.set_markup(getattr(icons, curr_player, icons.disc))
def get_mini_view(self):
return self.mini_tile_view
+141
View File
@@ -0,0 +1,141 @@
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from fabric.widgets.stack import Stack
from fabric.widgets.revealer import Revealer
from fabric.widgets.centerbox import CenterBox
import utils.icons as icons
from fabric.utils.helpers import exec_shell_command_async
class Tile(Box):
def __init__(self, *, menu: bool, markup: str, label: str, props: Label, **kwargs):
default_classes = ["tile"]
extra_classes = kwargs.pop("style_classes", [])
merged_classes = default_classes + extra_classes
super().__init__(style_classes=merged_classes, v_align="start", **kwargs)
self.props = props
self.icon = Label(style_classes="tile-icon", markup=markup)
self.tile_label = Label(
style_classes="tile-label", label=label, h_align="start"
)
self.toggle = False
self.type_box = Box(
style_classes="tile-type",
orientation="v",
v_expand=True,
h_expand=True,
v_align="center",
children=[self.tile_label, self.props],
)
self.menu_button = Button(
style_classes="tile-button",
h_expand=True,
child=Label(style_classes="tile-icon", markup=icons.arrow_head),
on_clicked=self.handle_click,
)
self.content_button = None
if menu:
self.content_button = Revealer(
transition_duration=150,
transition_type="slide-left",
h_expand=True,
child=Box(children=[self.type_box, self.menu_button]),
child_revealed=True,
)
else:
self.content_button = Revealer(
transition_duration=150,
transition_type="slide-left",
child=Box(children=[self.type_box]),
child_revealed=True,
)
self.normal_view = Box(
children=[self.icon, self.content_button],
)
self.menu = Button(
style_classes="tile-menu",
child=Label(label="hi!"),
on_clicked=self.handle_click,
)
self.stack = Stack(
transition_type="crossfade",
transition_duration=150,
h_expand=True,
children=[self.normal_view, self.menu],
)
self.children = self.stack
def handle_click(self, source):
if self.toggle:
self.toggle = False
self.menu.add_style_class("contract")
self.menu.remove_style_class("expand")
self.stack.set_visible_child(self.normal_view)
else:
self.toggle = True
self.stack.set_visible_child(self.menu)
self.menu.add_style_class("expand")
self.menu.remove_style_class("contract")
print(self.toggle, self.get_name())
name = self.get_name()
exec_shell_command_async(
f"fabric-cli exec bar-example \"notch.dashboard.handle_tile_menu_expand('{
name
}', {self.toggle})\""
)
def mini_view(self):
self.content_button.set_reveal_child(False)
self.icon.set_h_expand(True)
self.content_button.set_h_expand(False)
self.add_style_class("mini")
def maxi_view(self):
self.content_button.set_reveal_child(True)
self.icon.set_h_expand(False)
self.content_button.set_h_expand(True)
self.remove_style_class("mini")
class TileSpecial(Box):
def __init__(self, *, mini_props, props, **kwargs):
super().__init__(**kwargs)
self.props = props
self.mini_props = mini_props
self.toggle = False
self.content_button = None
self.normal_view = Box(
children=self.props,
)
self.collapsed_view = Box(children=self.mini_props)
self.stack = Stack(
transition_type="crossfade",
transition_duration=150,
h_expand=True,
v_align="start",
children=[self.normal_view, self.collapsed_view],
)
self.children = self.stack
def mini_view(self):
self.normal_view.set_style("margin:-999px")
self.collapsed_view.set_style("margin:0px;")
self.stack.set_visible_child(self.collapsed_view)
def maxi_view(self):
self.collapsed_view.set_style("margin:-999px")
self.normal_view.set_style("margin:0px;")
self.stack.set_visible_child(self.normal_view)
-10
View File
@@ -20,7 +20,6 @@ from .battery import Battery
from .controls import Controls
from .indicators import Indicators
from .metrics import Metrics
from .music_player import MusicPlayer
from .workspaces import workspace
from .systemtray import SystemTray
@@ -66,7 +65,6 @@ class DockComponents(Box):
)
self.controls = Controls()
self.indicators = Indicators()
self.music_player = MusicPlayer()
self.systray = SystemTray()
# Create list of components to add
@@ -74,7 +72,6 @@ class DockComponents(Box):
self.workspaces,
self.metrics,
self.controls,
self.music_player,
self.applications,
self.indicators,
self.battery,
@@ -109,7 +106,6 @@ class DockComponents(Box):
"date_time": self.date_time,
"controls": self.controls,
"indicators": self.indicators,
"music_player": self.music_player,
"systray": self.systray,
"applications": self.applications,
"language": self.language,
@@ -130,11 +126,6 @@ class DockComponents(Box):
# Battery component manages its own visibility based on config and battery presence
# Don't override its visibility here, let it handle it internally
pass
# Special handling for music player - let it manage its own visibility based on media content
elif component_name == "music_player":
# Music player component manages its own visibility based on config and media presence
# Don't override its visibility here, let it handle it internally
pass
else:
widget.set_visible(visibility)
@@ -146,7 +137,6 @@ class DockComponents(Box):
"date_time": self.date_time,
"controls": self.controls,
"indicators": self.indicators,
"music_player": self.music_player,
"systray": self.systray,
"applications": self.applications,
"language": self.language,
-5
View File
@@ -39,8 +39,6 @@ class Battery(Box):
child=self.circle,
)
# Create percentage label
self.level = Label(
name="battery-level",
@@ -76,8 +74,6 @@ class Battery(Box):
# Initial update
GLib.idle_add(self.update_battery)
def on_query_tooltip(self, _widget, _x, _y, _keyboard_mode, tooltip):
"""Handle tooltip query"""
tooltip.set_markup(self.get_tooltip_text())
@@ -161,7 +157,6 @@ class Battery(Box):
else:
self.level.set_visible(False)
# Apply alert styling if battery is low AND not charging
if percentage <= 15 and not charging:
self.icon.add_style_class("alert")
-826
View File
@@ -1,826 +0,0 @@
import time
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.circularprogressbar import CircularProgressBar
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from fabric.widgets.revealer import Revealer
from gi.repository import Gdk, GLib, Playerctl
import config.data as data
import utils.icons as icons
from modules.dock.components.player import PlayerContainer
from services.mpris import PlayerManager, PlayerService
from utils.wayland import WaylandWindow as Window
class MusicPlayerPopup(Window):
"""Popup window that shows the full music player interface"""
def __init__(self, dock_component=None, **kwargs):
# Get dock position for proper anchoring
dock_position = data.DOCK_POSITION
popup_anchor = self._get_popup_anchor(dock_position)
margin = self._get_popup_margin(dock_position)
super().__init__(
name="music-player-popup",
layer="top",
anchor=popup_anchor,
margin=margin,
exclusive=False,
keyboard_mode="on-demand",
visible=False,
all_visible=False,
**kwargs,
)
self.dock_component = dock_component
# Create player container first
self.player_container = PlayerContainer()
# Get transition type based on dock position
transition_type = self._get_revealer_transition(dock_position)
# Create revealer with position-appropriate transition
self.revealer = Revealer(
transition_type=transition_type,
transition_duration=300,
child=self.player_container,
reveal_child=False,
)
self.add(self.revealer)
# Connect player container events to sync with dock
if self.dock_component:
self.player_container.connect(
"active-player-changed", self.on_active_player_changed
)
# Connect to escape key to close
self.connect("key-press-event", self.on_key_press)
self.connect("button-press-event", self.on_button_press)
self.set_can_focus(True)
def on_active_player_changed(self, container, player):
"""Handle active player change from player container"""
if self.dock_component and player:
self.dock_component.set_current_player(player)
def on_key_press(self, widget, event):
"""Handle key press events"""
if event.keyval == Gdk.KEY_Escape:
if self.dock_component:
self.dock_component._popup_visible = False
self.hide_popup()
return True
return False
def show_popup(self):
"""Show the music player popup"""
self.set_visible(True)
self.show_all()
# Use GLib.idle_add to ensure the window is shown before revealing
GLib.idle_add(self._reveal_popup)
def _reveal_popup(self):
"""Reveal the popup after window is shown"""
self.revealer.set_reveal_child(True)
self.grab_focus()
return False # Don't repeat
def _get_popup_anchor(self, dock_position):
"""Get popup anchor based on dock position"""
anchor_map = {
"Top": "top",
"Bottom": "bottom",
"Left": "top left",
"Right": "top right",
}
return anchor_map.get(dock_position, "bottom")
def _get_popup_margin(self, dock_position):
"""Get popup margin based on dock position"""
margin_map = {
"Top": "60px 10px 10px 10px",
"Bottom": "10px 10px 60px 10px",
"Left": "330px 10px 10px 60px",
"Right": "330px 60px 10px 10px",
}
return margin_map.get(dock_position, "10px 10px 60px 10px")
def _get_revealer_transition(self, dock_position):
"""Get revealer transition type based on dock position"""
transition_map = {
"Top": "slide-down",
"Bottom": "slide-up",
"Left": "slide-right",
"Right": "slide-left",
}
return transition_map.get(dock_position, "slide-up")
def on_button_press(self, widget, event):
"""Handle button press events - close popup when clicking outside"""
if self.dock_component:
self.dock_component._popup_visible = False
self.hide_popup()
return False
def hide_popup(self):
"""Hide the music player popup"""
self.revealer.set_reveal_child(False)
# Hide window after transition completes and notify dock component
GLib.timeout_add(350, self._complete_hide)
def _complete_hide(self):
"""Complete the hide process"""
self.set_visible(False)
if self.dock_component:
self.dock_component._popup_visible = False
return False
class MusicPlayer(Box):
"""Music player component for the dock with circular progress bar"""
def __init__(self, **kwargs):
super().__init__(name="music-player", **kwargs)
# Initialize player manager
self.manager = PlayerManager()
self.manager.connect("new-player", self.on_new_player)
self.manager.connect("player-vanish", self.on_player_vanish)
# Current player tracking
self.current_player = None
self.current_player_service = None
self.duration = 0.0
self.position = 0.0
self._current_track_info = "Music Player"
# Debouncing for player switching to prevent rapid changes
self._last_player_switch = 0
self._switch_debounce_time = 1.0 # 1 second minimum between switches
# Visibility debouncing to prevent immediate hiding when player closes
self._visibility_timeout = None
self._hide_delay = 2.0 # 2 seconds delay before hiding when no players
# Create circular progress bar
self.progress_bar = CircularProgressBar(
name="player-circle",
size=28,
line_width=2,
start_angle=150,
end_angle=390,
value=0.0, # Initialize with 0 progress
)
# Create music icon label - always use generic music icon
self.music_label = Label(name="music-label", markup=icons.music)
# Create button for the music icon with click handler
self.music_button = Button(child=self.music_label)
self.music_button.connect("clicked", self.on_music_button_clicked)
# Create overlay without EventBox to fix click issues
self.overlay = Overlay(child=self.progress_bar, overlays=self.music_button)
# Add scroll events directly to the overlay for seeking
self.overlay.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK)
self.overlay.connect("scroll-event", self.on_scroll)
self.add(self.overlay)
# Pre-create popup for immediate display on click
self.popup = MusicPlayerPopup(dock_component=self)
# Track popup visibility state
self._popup_visible = False
# Initialize players with delayed visibility check
GLib.timeout_add(500, self._delayed_init)
# Hide initially - will show only when media players are detected
self.hide()
# Set up periodic check for active player
self.active_player_check_timeout = GLib.timeout_add_seconds(
2, self.check_active_player
)
# Add recursion protection and debouncing
self._in_player_switch = False
self._last_player_switch_time = 0
self._switch_debounce_ms = 500 # 500ms debounce
# Connect to destroy signal for cleanup
self.connect("destroy", self.on_destroy)
def on_destroy(self, widget):
"""Clean up popup when component is destroyed"""
try:
# Remove timeout
if hasattr(self, "active_player_check_timeout"):
GLib.source_remove(self.active_player_check_timeout)
# No hover timeout to clean up anymore
# Remove visibility timeout
if hasattr(self, "_visibility_timeout") and self._visibility_timeout:
GLib.source_remove(self._visibility_timeout)
self._visibility_timeout = None
# Clean up current player service
if hasattr(self, 'current_player_service') and self.current_player_service:
try:
if hasattr(self.current_player_service, 'cleanup'):
self.current_player_service.cleanup()
except Exception:
pass
# Clean up popup
if hasattr(self, 'popup') and self.popup:
try:
self.popup.destroy()
self.popup = None
except Exception:
pass
except Exception:
pass
def on_new_player(self, manager, player):
"""Handle new player detection"""
# If this is our first player, make it current
if not self.current_player:
self.set_current_player(player)
else:
# If the new player is playing, switch to it
if (
player.props.playback_status.value_name
== "PLAYERCTL_PLAYBACK_STATUS_PLAYING"
and self.current_player.props.playback_status.value_name
!= "PLAYERCTL_PLAYBACK_STATUS_PLAYING"
):
self.set_current_player(player)
self.update_visibility()
def on_player_vanish(self, manager, player):
"""Handle player disappearing"""
# If the current player vanished, find another one
if (
self.current_player
and self.current_player.props.player_name == player.props.player_name
):
self.current_player = None
self.current_player_service = None
# Try to find another player with media content
if hasattr(self.manager, "_manager") and hasattr(
self.manager._manager, "props"
):
for player_name in self.manager._manager.props.player_names:
try:
candidate_player = Playerctl.Player.new_from_name(player_name)
# Check if this player has media content
if hasattr(candidate_player, "props") and hasattr(
candidate_player.props, "metadata"
):
metadata = candidate_player.props.metadata
if metadata and len(metadata.keys()) > 0:
keys = metadata.keys()
if (
"xesam:title" in keys
or "xesam:artist" in keys
or "mpris:length" in keys
):
pass
self.set_current_player(candidate_player)
break
except Exception:
continue
self.update_visibility()
def get_playing_player(self):
"""Find a currently playing player from the manager"""
try:
if not hasattr(self.manager, "_manager") or not hasattr(self.manager._manager, "props"):
return None
player_names = self.manager._manager.props.player_names
if not player_names:
return None
for player_name in player_names:
try:
# Validate player_name before creating player
if not player_name or not hasattr(player_name, 'name'):
continue
player = Playerctl.Player.new_from_name(player_name)
# Comprehensive validation of player object
if not player:
continue
if not hasattr(player, "props"):
continue
if not hasattr(player.props, "playback_status"):
continue
if not hasattr(player.props, "player_name"):
continue
# Check if player is actually playing
try:
status = player.props.playback_status
if status and hasattr(status, 'value_name'):
if status.value_name == "PLAYERCTL_PLAYBACK_STATUS_PLAYING":
return player
except Exception as e:
continue
except Exception as e:
continue
except Exception as e:
print(f"[DEBUG] Error in get_playing_player: {e}")
return None
def check_active_player(self):
"""Periodically check for the currently playing player and switch if needed"""
# Prevent recursion during player checking
if self._in_player_switch:
return True # Continue the timeout
try:
current_time = time.time()
# Debounce player switching to prevent rapid changes
if current_time - self._last_player_switch < self._switch_debounce_time:
return True
# Find the currently playing player
playing_player = self.get_playing_player()
if playing_player:
# If we found a playing player and it's different from current, switch to it
should_switch = False
if not self.current_player:
should_switch = True
else:
try:
# Safely compare player names
if (hasattr(self.current_player, 'props') and
hasattr(self.current_player.props, 'player_name') and
hasattr(playing_player, 'props') and
hasattr(playing_player.props, 'player_name')):
if self.current_player.props.player_name != playing_player.props.player_name:
should_switch = True
else:
# If we can't safely compare, assume we should switch
should_switch = True
except Exception as e:
print(f"[DEBUG] Error comparing players: {e}")
should_switch = True
if should_switch:
self.set_current_player(playing_player)
self._last_player_switch = current_time
elif self.current_player:
# If no player is playing, check if current player is still valid and paused
try:
if (hasattr(self.current_player, 'props') and
hasattr(self.current_player.props, 'playback_status') and
hasattr(self.current_player.props.playback_status, 'value_name')):
if self.current_player.props.playback_status.value_name != "PLAYERCTL_PLAYBACK_STATUS_PLAYING":
# Current player is paused, see if there's another player available
self._try_switch_to_alternative_player(current_time)
else:
# Current player is invalid, try to find another
self._try_switch_to_alternative_player(current_time)
except Exception as e:
# Current player seems invalid, try to find another
self._try_switch_to_alternative_player(current_time)
# Update visibility based on current state
self.update_visibility()
except Exception as e:
print(f"[DEBUG] Error in check_active_player: {e}")
# Return True to continue the timeout
return True
def _delayed_init(self):
"""Initialize players after a delay to prevent showing during dock startup"""
try:
self.manager.init_all_players()
self.update_visibility()
except Exception as e:
print(f"[DEBUG] Error in delayed music player init: {e}")
return False # Don't repeat this timeout
def _try_switch_to_alternative_player(self, current_time):
"""Try to switch to an alternative player when current is paused/invalid"""
try:
if not hasattr(self.manager, "_manager") or not hasattr(self.manager._manager, "props"):
return
player_names = self.manager._manager.props.player_names
if not player_names:
return
for player_name in player_names:
try:
# Validate player_name
if not player_name or not hasattr(player_name, 'name'):
continue
player = Playerctl.Player.new_from_name(player_name)
# Validate player object
if not player or not hasattr(player, 'props') or not hasattr(player.props, 'player_name'):
continue
# Skip if this is the same as current player
if (self.current_player and
hasattr(self.current_player, 'props') and
hasattr(self.current_player.props, 'player_name') and
player.props.player_name == self.current_player.props.player_name):
continue
# Switch to a different available player
self.set_current_player(player)
self._last_player_switch = current_time
break
except Exception as e:
continue
except Exception as e:
pass
def set_current_player(self, player):
"""Set the current active player"""
# Prevent recursion during player switching
if self._in_player_switch:
return
# Add debouncing to prevent rapid switching
import time
current_time = time.time() * 1000 # Convert to milliseconds
if current_time - self._last_player_switch_time < self._switch_debounce_ms:
return
self._in_player_switch = True
self._last_player_switch_time = current_time
try:
# Validate player before proceeding
if not player or not hasattr(player, 'props') or not hasattr(player.props, 'player_name'):
return
# Properly cleanup previous player service
if self.current_player_service:
try:
# Use the cleanup method if available
if hasattr(self.current_player_service, 'cleanup'):
self.current_player_service.cleanup()
else:
# Fallback: Stop any running fabricators/timers manually
if hasattr(self.current_player_service, 'pos_fabricator'):
try:
self.current_player_service.pos_fabricator.stop()
except Exception:
pass
# Disconnect signals safely - avoid bulk disconnect which can cause recursion
# Store signal handler IDs for proper cleanup
if hasattr(self.current_player_service, '_signal_handlers'):
try:
for handler_id in self.current_player_service._signal_handlers:
self.current_player_service.disconnect(handler_id)
self.current_player_service._signal_handlers.clear()
except Exception:
pass
except Exception:
pass
self.current_player = player
self.current_player_service = None
# Create new player service with error handling and recursion prevention
try:
# Validate player before creating service
if (hasattr(player, 'props') and
hasattr(player.props, 'player_name') and
player.props.player_name):
self.current_player_service = PlayerService(player=player)
# Connect to player events with error handling and store handler IDs
if self.current_player_service:
try:
# Initialize signal handlers list for proper cleanup
self.current_player_service._signal_handlers = []
# Connect signals and store handler IDs
handler_id = self.current_player_service.connect("track-position", self.on_track_position)
self.current_player_service._signal_handlers.append(handler_id)
handler_id = self.current_player_service.connect("play", self.on_play)
self.current_player_service._signal_handlers.append(handler_id)
handler_id = self.current_player_service.connect("pause", self.on_pause)
self.current_player_service._signal_handlers.append(handler_id)
handler_id = self.current_player_service.connect("meta-change", self.on_metadata)
self.current_player_service._signal_handlers.append(handler_id)
except Exception:
# If signal connection fails, clean up the service
if hasattr(self.current_player_service, 'cleanup'):
self.current_player_service.cleanup()
self.current_player_service = None
else:
pass
except Exception:
self.current_player_service = None
# Always use generic music icon, not player-specific icons
self.music_label.set_markup(icons.music)
# Update initial state only if we have a valid service
if self.current_player_service:
self.update_playback_state()
except Exception as e:
# Reset to safe state
self.current_player = None
self.current_player_service = None
finally:
# Always reset recursion protection flag
self._in_player_switch = False
def on_track_position(self, service, pos, dur):
"""Handle track position updates"""
try:
# Validate input parameters
if pos is None or dur is None:
return
# Ensure pos and dur are numeric
try:
pos = float(pos)
dur = float(dur)
except (ValueError, TypeError):
return
# Validate ranges
if pos < 0 or dur < 0:
return
self.position = pos
self.duration = dur
if dur > 0:
progress = min(pos / dur, 1.0) # Ensure progress doesn't exceed 1.0
# Safely update progress bar
try:
self.progress_bar.value = progress
except Exception:
pass
else:
# Duration is 0 or unknown - show position only if available
try:
if pos > 0:
# Set progress bar to indeterminate state or small progress
# Use a pulsing animation or small fixed progress to show activity
self.progress_bar.value = 0.1 # Small progress to show activity
else:
self.progress_bar.value = 0
except Exception:
pass
except Exception:
pass
def on_play(self, service):
"""Handle play state"""
self.music_label.set_markup(icons.music)
self.progress_bar.remove_style_class("paused")
# Trigger immediate check for active player when play state changes
self.check_active_player()
# Update visibility when playback state changes
self.update_visibility()
def on_pause(self, service):
"""Handle pause state"""
# Always use generic music icon when paused
self.music_label.set_markup(icons.music)
self.progress_bar.add_style_class("paused")
# Trigger immediate check for active player when pause state changes
self.check_active_player()
# Update visibility when playback state changes
self.update_visibility()
def on_metadata(self, service, metadata, player):
"""Handle metadata changes"""
# Store current track info for internal use
keys = metadata.keys()
if "xesam:artist" in keys and "xesam:title" in keys:
artist = (
metadata["xesam:artist"][0]
if metadata["xesam:artist"]
else "Unknown Artist"
)
title = metadata["xesam:title"]
self._current_track_info = f"{artist} - {title}"
else:
self._current_track_info = "Music Player"
# Update visibility when metadata changes (media content appears/disappears)
self.update_visibility()
def update_playback_state(self):
"""Update the visual state based on current playback"""
if not self.current_player:
return
if (
self.current_player.props.playback_status.value_name
== "PLAYERCTL_PLAYBACK_STATUS_PLAYING"
):
self.on_play(None)
else:
self.on_pause(None)
def on_music_button_clicked(self, button):
"""Handle music button click to toggle popup"""
if self._popup_visible:
# Hide popup if currently visible
self.popup.hide_popup()
self._popup_visible = False
else:
# Show popup if currently hidden
self.popup.show_popup()
self._popup_visible = True
return True
def on_scroll(self, widget, event):
"""Handle scroll events for seeking"""
if not self.current_player or self.duration <= 0:
return False
# Calculate seek amount (5 seconds per scroll)
seek_amount = 5.0
if event.direction == Gdk.ScrollDirection.UP:
new_position = min(self.position + seek_amount, self.duration)
elif event.direction == Gdk.ScrollDirection.DOWN:
new_position = max(self.position - seek_amount, 0)
else:
return False
# Seek to new position
if self.current_player_service:
self.current_player_service.set_position(int(new_position))
return True
def has_running_players(self):
"""Check if there are any players that actually have media content running"""
try:
# First check if we have a current player that's still valid
if self.current_player and self.current_player_service:
try:
# Check if current player still exists and has media
if hasattr(self.current_player.props, "metadata"):
metadata = self.current_player.props.metadata
if metadata and len(metadata.keys()) > 0:
keys = metadata.keys()
if ("xesam:title" in keys or "xesam:artist" in keys or "mpris:length" in keys):
return True
except Exception:
pass
# Check all available players
if not hasattr(self.manager, "_manager") or not hasattr(self.manager._manager, "props"):
return False
player_names = self.manager._manager.props.player_names
if not player_names:
return False
for player_name in player_names:
try:
# Validate player_name before creating player
if not player_name or not hasattr(player_name, 'name'):
continue
player = Playerctl.Player.new_from_name(player_name)
# Validate player object
if not player or not hasattr(player, "props"):
continue
# Check if player has metadata indicating actual media content
if hasattr(player.props, "metadata"):
try:
metadata = player.props.metadata
if metadata and len(metadata.keys()) > 0:
# Check for essential metadata that indicates real media
keys = metadata.keys()
if (
"xesam:title" in keys
or "xesam:artist" in keys
or "mpris:length" in keys
):
return True
except Exception:
pass
# Also check if player is currently playing (even without full metadata)
# But be more strict - only count as "has media" if actually playing
if hasattr(player.props, "playback_status"):
try:
status = player.props.playback_status
if (status and hasattr(status, 'value_name') and
status.value_name == "PLAYERCTL_PLAYBACK_STATUS_PLAYING"):
# Double-check that this isn't just a phantom player
# by trying to get position or metadata
try:
player.get_position() # This will fail if player is dead
return True
except Exception:
pass
except Exception:
pass
except Exception:
pass
except Exception:
pass
return False
def update_visibility(self):
"""Update component visibility based on players with actual media content"""
# Check if music player is enabled in configuration
if hasattr(data, "DOCK_COMPONENTS_VISIBILITY"):
music_visible = data.DOCK_COMPONENTS_VISIBILITY.get("music_player", True)
if not music_visible:
self.set_visible(False)
return
# Check if there are players with actual media content
has_media_players = self.has_running_players()
if has_media_players:
# Cancel any pending hide timeout
if self._visibility_timeout:
GLib.source_remove(self._visibility_timeout)
self._visibility_timeout = None
# Show immediately when media is available
self.set_visible(True)
else:
# Don't hide immediately - use a delay to prevent flickering
# when players are restarting or switching
if not self._visibility_timeout:
self._visibility_timeout = GLib.timeout_add_seconds(
int(self._hide_delay),
self._delayed_hide
)
def _delayed_hide(self):
"""Hide the component after delay if still no media players"""
# Double-check that we still don't have media players
if not self.has_running_players():
self.set_visible(False)
# Clear the timeout
self._visibility_timeout = None
return False # Don't repeat the timeout
+51
View File
@@ -0,0 +1,51 @@
import os
from loguru import logger
from gi.repository import GLib
from fabric.core.service import Service, Signal
from fabric.utils.helpers import monitor_file
from config.info import BRIGHTNESS_DEV
class BrightnessService(Service):
_instance = None
@Signal
def value_changed(self, new_value: int, max_value: int) -> None:...
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._init_singleton()
return cls._instance
def _init_singleton(self):
super().__init__()
self.max_brightness = -1
self.brightness_monitor = None
backlight_path = f"/sys/class/backlight/{BRIGHTNESS_DEV}"
try:
self.brightness_monitor = monitor_file(backlight_path)
self.max_brightness = self._read_max_brightness(backlight_path)
self.brightness_monitor.connect("changed", self._on_brightness_changed)
except Exception as e:
logger.error(f"Brightness device not found at {backlight_path}: {e}")
def _read_max_brightness(self, path: str) -> int:
max_path = os.path.join(path, "max_brightness")
if os.path.exists(max_path):
with open(max_path) as f:
return int(f.readline())
return -1
def _on_brightness_changed(self, monitor, file, *args):
try:
raw = file.load_bytes()[0].get_data()
new_value = round(int(raw))
self.value_changed(new_value, self.max_brightness)
except Exception as e:
logger.warning(f"Failed to read brightness: {e}")
+112
View File
@@ -0,0 +1,112 @@
import gi
from fabric.core.service import Service, Signal
from loguru import logger
try:
gi.require_version("NM", "1.0")
from gi.repository import NM
except ValueError:
logger.error("Failed to start network manager")
class NetworkService(Service):
@Signal
def connection_change(
self, ssid: str, connected: bool, status: str = ""
) -> None: ...
def __init__(self):
super().__init__()
self.client = NM.Client.new(None)
self.client.connect("device-added", self.on_device_added)
self.client.connect("device-removed", self.on_device_removed)
# self.client.connect("notify::state", self.handle_client_state_change)
# active_connections = self.client.get_active_connections()
# for ac in active_connections:
# connection = ac.get_connection()
# type = ac.get_connection_type()
# print("Active connection:", connection.get_id(), type)
devices = self.client.get_devices()
for dev in devices:
if dev.get_device_type() == NM.DeviceType.WIFI:
self.wifi_dev = dev
active_ap = self.wifi_dev.get_active_access_point()
if active_ap:
ssid = active_ap.get_ssid()
print("Connected SSID:", ssid.get_data().decode("utf-8"))
if self.wifi_dev:
self.wifi_dev.connect("state-changed", self.handle_device_state_change)
self.wifi_dev.connect(
"notify::active-access-point", self.handle_prop_change
)
def init_props(self):
self.handle_prop_change(self.wifi_dev, None)
def handle_prop_change(self, source, pspec):
if source is None:
self.connection_change("", False, "No Device")
return
device_state = source.get_state()
if device_state == NM.DeviceState.UNAVAILABLE:
self.connection_change("", False, "Wi-Fi Off")
return
active_ap = source.get_active_access_point()
if active_ap:
ssid = active_ap.get_ssid().get_data().decode("utf-8")
print("Connected SSID:", ssid)
self.connection_change(ssid, True, "Connected")
else:
self.connection_change("", False, "Wi-Fi On (No Connection)")
def handle_device_state_change(self, device, new_state, old_state, reason):
print("Device state changed:", old_state, "→", new_state)
match new_state:
case NM.DeviceState.UNAVAILABLE:
self.connection_change("", False, "Wi-Fi Off")
case NM.DeviceState.DISCONNECTED:
self.connection_change("", False, "Wi-Fi On (No Connection)")
case (
NM.DeviceState.PREPARE
| NM.DeviceState.CONFIG
| NM.DeviceState.NEED_AUTH
| NM.DeviceState.IP_CONFIG
| NM.DeviceState.IP_CHECK
| NM.DeviceState.SECONDARIES
):
self.connection_change("", False, "Connecting…")
case NM.DeviceState.ACTIVATED:
active_ap = device.get_active_access_point()
if active_ap:
ssid = active_ap.get_ssid().get_data().decode("utf-8")
self.connection_change(ssid, True, "Connected")
else:
self.connection_change("", True, "Connected (No SSID)")
case _:
self.connection_change("", False, "Unknown")
def on_device_added(self, source, device):
print("Added device")
if device.get_device_type() == NM.DeviceType.WIFI:
self.wifi_dev = device
self.wifi_dev.connect("state-changed", self.handle_device_state_change)
self.wifi_dev.connect(
"notify::active-access-point", self.handle_prop_change
)
def on_device_removed(self, client, device):
print("Removed device")
if self.wifi_dev and device.get_path() == self.wifi_dev.get_path():
self.wifi_dev = None
self.handle_prop_change(self.wifi_dev, None)
def print_state(self):
print(
"wifi: ", self.wifi_dev.get_state(), " client: ", self.client.get_state()
)
+395
View File
@@ -0,0 +1,395 @@
.player {
/* background-color: aqua; */
min-height: 100px;
min-width: 400px;
box-shadow:
inset 0 100px 100px black,
inset 10px 0px 10px rgba(0, 0, 0, 0.5),
inset -10px 0px 10px rgba(0, 0, 0, 0.5);
border-bottom-right-radius: 20px;
border-bottom-left-radius: 20px;
padding: 20px;
background-position: center;
background-size: cover;
margin: 4px;
}
.player.vertical {
box-shadow:
inset 100px 0px 150px black,
inset 0px 10px 10px rgba(0, 0, 0, 0.5),
inset 0px -10px 10px rgba(0, 0, 0, 0.5);
border-top-right-radius: 20px;
border-bottom-right-radius: 20px;
min-width: 400px;
min-height: 150px;
margin: 10px;
}
.player-icon {
font-size: 24px;
}
#artist {
color: var(--primary);
}
#song {
color: var(--surface);
font-size: 16px;
margin-top: 5px;
font-weight: 900;
}
#wiggle-bar {
color: var(--primary);
}
#source {
/* background-color: antiquewhite; */
margin-top: -10px;
}
#controls {
margin-bottom: -10px;
}
#play-next,
#play-previous {
font-size: 24px;
}
#progress {
/* min-height: 1px; */
background-color: antiquewhite;
}
#pause-button {
background-color: var(--surface);
border-radius: 20px;
padding: 8px;
}
#pause-button.pause-track {
border-radius: 100%;
}
#pause-label,
#play-label {
color: var(--primary);
font-size: 20px;
padding: 2px;
border-radius: 999px;
min-width: 25px;
}
#shuffle-button,
#next-button,
#prev-button {
border: none;
padding: 0px;
padding-left: 11px;
padding-right: 11px;
}
#shuffle-button:hover,
#next-button:hover,
#prev-button:hover {
background-color: var(--surface-bright);
}
#shuffle {
font-size: 17px;
}
.player-button {
border-radius: 5px;
/* background-color: var(--secondary); */
background-color: white;
opacity: 0.5;
min-width: 10px;
min-height: 10px;
padding: 0px;
margin: 0px;
border: none;
margin-left: 5px;
}
.player-button.active {
opacity: 1;
}
#player-switch-container.vertical-player {
margin-top: -23px;
padding-bottom: 13px;
}
#player-switch-container {
margin-top: -18px;
padding-bottom: 13px;
}
#controls.vertical {
padding-bottom: 10px;
margin-bottom: 0px;
}
#disable-shuffle {
font-size: 27px;
}
#progress-slider {
margin-left: -3px;
margin-right: -2px;
margin-top: 8px;
margin-bottom: 8px;
border-radius: 2px;
}
#progress-other-end {
min-width: 50px;
}
.player-mini {
min-height: 60px;
min-width: 220px;
padding-right: 20px;
/* box-shadow: inset 0 0 100px rgb(148, 148, 148),
inset 60px 0px 10px rgba(255, 255, 255, 1); */
box-shadow:
inset 0 0 100px rgba(148, 148, 148, 0.2),
inset 70px 0 20px rgba(255, 255, 255, 0.2),
inset 0px 0px 20px 5px rgba(255, 255, 255, 0.2);
border-top-right-radius: 15px;
border-bottom-right-radius: 15px;
border-top-left-radius: 20px;
border-bottom-left-radius: 20px;
background-position: center;
background-size: cover;
}
#player-container.mini {
min-height: 60px;
border-radius: 15px;
background-position: center;
background-size: cover;
}
.album-image {
min-width: 65px;
min-height: 60px;
background-position: center;
background-size: cover;
/* border-top-left-radius: 15px;
border-bottom-left-radius: 15px; */
border-radius: 15px;
/* margin: 2px; */
}
.hide-player {
margin: -350px;
transition: margin 0.25s cubic-bezier(0.5, 0.25, 0, 1);
/* animation: hidePlayer 0.5s ease; */
}
@keyframes hidePlayer {
0% {
margin: 0px;
}
100% {
margin: -999px;
}
}
.reveal-player {
margin: 0px;
transition: margin 0.25s cubic-bezier(0.5, 0.25, 0, 1);
/* animation: revealPlayer 0.5s ease; */
}
#artist {
color: var(--primary);
}
#song {
color: whitesmoke;
font-size: 16px;
margin-top: 5px;
}
#song.mini {
color: var(--surface);
font-size: 15px;
margin-top: 5px;
}
#source {
/* background-color: antiquewhite; */
margin-top: -10px;
}
#source.mini {
margin-left: -20px;
}
#controls {
margin-bottom: -10px;
}
#play-next,
#play-previous {
text-shadow: none;
font-size: 30px;
}
#play-next.mini,
#play-previous.mini {
text-shadow: none;
font-size: 30px;
color: var(--surface);
}
#play-pause-label {
color: var(--surface);
text-shadow: none;
}
#progress {
/* min-height: 1px; */
background-color: antiquewhite;
}
#pause-button {
/* background-color: var(--surface); */
background-color: whitesmoke;
border-radius: 16px;
padding: 10px;
}
.vertical #pause-button {
margin-bottom: 5px;
}
#pause-button.pause-track {
border-radius: 100%;
}
#pause-label,
#play-label {
color: var(--shadow);
/* background-color: var(--error-dim); */
font-size: 20px;
padding: 2px;
border-radius: 999px;
min-width: 25px;
}
#pause-label.mini,
#play-label.mini {
color: white;
/* background-color: var(--error-dim); */
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%;
/* background-color: white; */
/* opacity: .3; */
box-shadow: inset rgba(255, 255, 255, 0.5) 0 0 10px;
}
.player-button {
border-radius: 5px;
/* background-color: var(--secondary); */
background-color: white;
opacity: 0.5;
min-width: 10px;
min-height: 10px;
padding: 0px;
margin: 0px;
border: none;
margin-left: 5px;
}
.player-button.mini {
border-radius: 5px;
background-color: var(--surface);
/* background-color: white; */
opacity: 0.5;
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.vertical-player {
margin-top: -23px;
padding-bottom: 13px;
}
#player-switch-container {
margin-top: -17px;
padding-bottom: 7px;
}
#player-switch-container.mini {
margin-top: 0px;
margin-left: -20px;
padding-right: 10px;
}
#controls.vertical {
padding-bottom: 10px;
margin-bottom: 0px;
}
.player-icon {
font-size: 20px;
}
.player-icon.mini {
font-size: 20px;
color: black;
text-shadow: 0 0 5px white;
}
#shuffle {
font-size: 17px;
}
#disable-shuffle {
font-size: 27px;
}
#progress-slider {
background-color: white;
margin-left: -3px;
margin-right: -2px;
margin-top: 8px;
margin-bottom: 8px;
border-radius: 2px;
}
#progress-other-end {
min-width: 50px;
background-color: white;
}
+246
View File
@@ -37,6 +37,7 @@
color: whitesmoke;
font-size: 16px;
margin-top: 5px;
font-weight: 900;
}
#wiggle-bar {
@@ -147,3 +148,248 @@
#progress-other-end {
min-width: 50px;
}
.player-mini {
min-height: 60px;
min-width: 220px;
padding-right: 20px;
/* box-shadow: inset 0 0 100px rgb(148, 148, 148),
inset 60px 0px 10px rgba(255, 255, 255, 1); */
box-shadow:
inset 0 0 100px rgba(148, 148, 148, 0.2),
inset 70px 0 20px rgba(255, 255, 255, 0.2),
inset 0px 0px 20px 5px rgba(255, 255, 255, 0.2);
border-top-right-radius: 15px;
border-bottom-right-radius: 15px;
border-top-left-radius: 20px;
border-bottom-left-radius: 20px;
background-position: center;
background-size: cover;
}
#player-container.mini {
min-height: 60px;
border-radius: 15px;
background-position: center;
background-size: cover;
}
.album-image {
min-width: 65px;
min-height: 60px;
background-position: center;
background-size: cover;
/* border-top-left-radius: 15px;
border-bottom-left-radius: 15px; */
border-radius: 15px;
/* margin: 2px; */
}
.hide-player {
margin: -350px;
transition: margin 0.25s cubic-bezier(0.5, 0.25, 0, 1);
/* animation: hidePlayer 0.5s ease; */
}
@keyframes hidePlayer {
0% {
margin: 0px;
}
100% {
margin: -999px;
}
}
.reveal-player {
margin: 0px;
transition: margin 0.25s cubic-bezier(0.5, 0.25, 0, 1);
/* animation: revealPlayer 0.5s ease; */
}
#artist {
color: var(--primary);
}
#song {
color: whitesmoke;
font-size: 16px;
margin-top: 5px;
}
#song.mini {
color: var(--surface);
font-size: 15px;
margin-top: 5px;
}
#source {
/* background-color: antiquewhite; */
margin-top: -10px;
}
#source.mini {
margin-left: -20px;
}
#controls {
margin-bottom: -10px;
}
#play-next,
#play-previous {
text-shadow: none;
font-size: 30px;
}
#play-next.mini,
#play-previous.mini {
text-shadow: none;
font-size: 30px;
color: var(--surface);
}
#play-pause-label {
color: var(--surface);
text-shadow: none;
}
#progress {
/* min-height: 1px; */
background-color: antiquewhite;
}
#pause-button {
/* background-color: var(--surface); */
background-color: whitesmoke;
border-radius: 16px;
padding: 10px;
}
.vertical #pause-button {
margin-bottom: 5px;
}
#pause-button.pause-track {
border-radius: 100%;
}
#pause-label,
#play-label {
color: var(--shadow);
/* background-color: var(--error-dim); */
font-size: 20px;
padding: 2px;
border-radius: 999px;
min-width: 25px;
}
#pause-label.mini,
#play-label.mini {
color: white;
/* background-color: var(--error-dim); */
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%;
/* background-color: white; */
/* opacity: .3; */
box-shadow: inset rgba(255, 255, 255, 0.5) 0 0 10px;
}
.player-button {
border-radius: 5px;
/* background-color: var(--secondary); */
background-color: white;
opacity: 0.5;
min-width: 10px;
min-height: 10px;
padding: 0px;
margin: 0px;
border: none;
margin-left: 5px;
}
.player-button.mini {
border-radius: 5px;
background-color: var(--surface);
/* background-color: white; */
opacity: 0.5;
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.vertical-player {
margin-top: -23px;
padding-bottom: 13px;
}
#player-switch-container {
margin-top: -17px;
padding-bottom: 7px;
}
#player-switch-container.mini {
margin-top: 0px;
margin-left: -20px;
padding-right: 10px;
}
#controls.vertical {
padding-bottom: 10px;
margin-bottom: 0px;
}
.player-icon {
font-size: 20px;
}
.player-icon.mini {
font-size: 20px;
color: black;
text-shadow: 0 0 5px white;
}
#shuffle {
font-size: 17px;
}
#disable-shuffle {
font-size: 27px;
}
#progress-slider {
background-color: white;
margin-left: -3px;
margin-right: -2px;
margin-top: 8px;
margin-bottom: 8px;
border-radius: 2px;
}
#progress-other-end {
min-width: 50px;
background-color: white;
}
+9 -1
View File
@@ -74,18 +74,23 @@ bat_full: str = "&#xea38;"
# Applets
# Applets
wifi: str = chr(0xF1EB)
wifi_0: str = "&#xeba3;"
wifi_1: str = "&#xeba4;"
wifi_2: str = "&#xeba5;"
wifi_3: str = "&#xeb52;"
world: str = "&#xeb54;"
world_off: str = "&#xf1ca;"
bluetooth: str = "&#xea37;"
bluetooth: str = chr(0xF294)
night: str = "&#xeaf8;"
coffee: str = "&#xef0e;"
notifications: str = "&#xea35;"
notifications_dots: str = "&#xede3;"
blur: str = chr(0xF00B5)
arrow_head: str = chr(0xE285)
wifi_off: str = "&#xecfa;"
bluetooth_off: str = "&#xeceb;"
night_off: str = "&#xf162;"
@@ -114,6 +119,9 @@ music: str = "&#xeafc;"
rewind_backward_5: str = "&#xfabf;"
rewind_forward_5: str = "&#xfac7;"
next_fill: str = chr(0xF04AD)
previous_fill: str = chr(0xF04AE)
# Volume
vol_off: str = "&#xf1c3;"
vol_mute: str = "&#xeb50;"
+176
View File
@@ -0,0 +1,176 @@
from gi.repository import Gtk, GLib, Gdk
import cairo
import datetime
import math
import os
import re
import gi
gi.require_version("Gtk", "3.0")
class WavyCircle(Gtk.DrawingArea):
def __init__(self):
super().__init__()
self.connect("draw", self.on_draw)
self.set_size_request(-1, 153)
GLib.timeout_add_seconds(1, self.on_tick)
self.show()
def _hex_to_rgba(self, hex_color: str) -> Gdk.RGBA:
"""Convert hex color string to Gdk.RGBA object."""
# Remove # if present
hex_color = hex_color.lstrip("#")
# Convert hex to RGB values (0-255)
r = int(hex_color[0:2], 16) / 255.0
g = int(hex_color[2:4], 16) / 255.0
b = int(hex_color[4:6], 16) / 255.0
return Gdk.RGBA(r, g, b, 1.0)
def _read_css_colors(self) -> dict:
"""Read color values from styles/colors.css file."""
colors = {}
# Get the path to colors.css relative to this file
current_dir = os.path.dirname(os.path.abspath(__file__))
colors_css_path = os.path.join(current_dir, "..", "styles", "colors.css")
colors_css_path = os.path.normpath(colors_css_path)
try:
with open(colors_css_path, "r") as f:
content = f.read()
# Parse CSS variables using regex
# Match patterns like: --primary: #ffb598;
pattern = r"--([a-zA-Z0-9-]+):\s*(#[0-9a-fA-F]{6});"
matches = re.findall(pattern, content)
for var_name, hex_value in matches:
colors[var_name] = self._hex_to_rgba(hex_value)
except (FileNotFoundError, IOError) as e:
print(f"Error reading colors.css: {e}")
# Return empty dict, fallback colors will be used
return colors
def on_tick(self):
self.queue_draw()
return True
def on_draw(self, widget, ctx):
width = self.get_allocated_width()
height = self.get_allocated_height()
cx, cy = width / 2, height / 2
base_radius = min(width, height) * 0.4
amplitude = base_radius * 0.05
frequency = 10
# Get colors from CSS file
css_colors = self._read_css_colors()
# Use colors from CSS with fallbacks
primary_color = css_colors.get(
"primary", Gdk.RGBA(0.29, 0.56, 0.89, 1.0)
) # Blue fallback
secondary_color = css_colors.get(
"foreground", Gdk.RGBA(0.17, 0.24, 0.31, 1.0)
) # Dark fallback
accent_color = css_colors.get(
"foreground", Gdk.RGBA(0.91, 0.3, 0.24, 1.0)
) # Use foreground color
# wavy outer circle
ctx.set_line_width(4)
angle_step = 2 * math.pi / 500
ctx.move_to(
cx + (base_radius + amplitude * math.sin(frequency * 0)) * math.cos(0),
cy + (base_radius + amplitude * math.sin(frequency * 0)) * math.sin(0),
)
angle = 0
while angle <= math.tau:
r = base_radius + amplitude * math.sin(frequency * angle)
x = cx + r * math.cos(angle)
y = cy + r * math.sin(angle)
ctx.line_to(x, y)
angle += angle_step
ctx.set_source_rgba(
primary_color.red,
primary_color.green,
primary_color.blue,
primary_color.alpha,
)
ctx.close_path()
ctx.fill_preserve()
ctx.set_source_rgba(
primary_color.red,
primary_color.green,
primary_color.blue,
primary_color.alpha,
)
ctx.stroke()
ANGLE_OFFSET = 0.25
now = datetime.datetime.now()
seconds = now.second + now.microsecond / 1e6
hour = now.hour % 12 + now.minute / 60.0
minute = now.minute + now.second / 60.0
second_angle = (seconds / 60.0 - ANGLE_OFFSET) * math.tau
hour_angle = (hour / 12.0 - ANGLE_OFFSET) * math.tau
minute_angle = (minute / 60.0 - ANGLE_OFFSET) * math.tau
hour_orbit = base_radius * 0.8 - 28
minute_orbit = base_radius * 0.8 - 14
second_orbit = base_radius * 0.8
dot_radius = 9
ctx.set_line_cap(cairo.LINE_CAP_ROUND)
# hour hand
ctx.set_line_width(6) # Reduced width for better visibility
ctx.set_source_rgba(
secondary_color.red, secondary_color.green, secondary_color.blue, 1.0
)
ctx.move_to(cx, cy)
ctx.line_to(
cx + hour_orbit * math.cos(hour_angle),
cy + hour_orbit * math.sin(hour_angle),
)
ctx.stroke()
# minute hand
ctx.set_line_width(4) # Reduced width for better visibility
ctx.set_source_rgba(
secondary_color.red, secondary_color.green, secondary_color.blue, 1.0
)
ctx.move_to(cx, cy)
ctx.line_to(
cx + minute_orbit * math.cos(minute_angle),
cy + minute_orbit * math.sin(minute_angle),
)
ctx.stroke()
# second dot
x = cx + second_orbit * math.cos(second_angle)
y = cy + second_orbit * math.sin(second_angle)
ctx.arc(x, y, dot_radius, 0, math.tau)
ctx.set_source_rgba(
accent_color.red, accent_color.green, accent_color.blue, 1.0
)
ctx.fill()
# center dot to anchor the hands
ctx.arc(cx, cy, 4, 0, math.tau)
ctx.set_source_rgba(
secondary_color.red, secondary_color.green, secondary_color.blue, 1.0
)
ctx.fill()