diff --git a/config/data.py b/config/data.py index 9682e56..4e82928 100644 --- a/config/data.py +++ b/config/data.py @@ -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, diff --git a/config/info.py b/config/info.py new file mode 100644 index 0000000..f037081 --- /dev/null +++ b/config/info.py @@ -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) diff --git a/main.css b/main.css index 70ab211..f79b2a3 100644 --- a/main.css +++ b/main.css @@ -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; +} diff --git a/main.py b/main.py index 7b0ca69..95bebf4 100644 --- a/main.py +++ b/main.py @@ -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(): diff --git a/modules/dashboard/bluetooth.py b/modules/dashboard/bluetooth.py new file mode 100644 index 0000000..1726eeb --- /dev/null +++ b/modules/dashboard/bluetooth.py @@ -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") diff --git a/modules/dashboard/main.py b/modules/dashboard/main.py new file mode 100644 index 0000000..dfc2315 --- /dev/null +++ b/modules/dashboard/main.py @@ -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() diff --git a/modules/dashboard/network.py b/modules/dashboard/network.py new file mode 100644 index 0000000..f5e81b7 --- /dev/null +++ b/modules/dashboard/network.py @@ -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") diff --git a/modules/dashboard/player_mini.py b/modules/dashboard/player_mini.py new file mode 100644 index 0000000..930568a --- /dev/null +++ b/modules/dashboard/player_mini.py @@ -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 diff --git a/modules/dashboard/tile.py b/modules/dashboard/tile.py new file mode 100644 index 0000000..a019515 --- /dev/null +++ b/modules/dashboard/tile.py @@ -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) diff --git a/modules/dock/components/__init__.py b/modules/dock/components/__init__.py index 7e59c7d..8d685fe 100644 --- a/modules/dock/components/__init__.py +++ b/modules/dock/components/__init__.py @@ -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, diff --git a/modules/dock/components/battery.py b/modules/dock/components/battery.py index c56ce72..ec2ad50 100644 --- a/modules/dock/components/battery.py +++ b/modules/dock/components/battery.py @@ -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") diff --git a/modules/dock/components/music_player.py b/modules/dock/components/music_player.py deleted file mode 100644 index cf186ae..0000000 --- a/modules/dock/components/music_player.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/services/brightness_service.py b/services/brightness_service.py new file mode 100644 index 0000000..cf80103 --- /dev/null +++ b/services/brightness_service.py @@ -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}") diff --git a/services/nett.py b/services/nett.py new file mode 100644 index 0000000..db1e8e9 --- /dev/null +++ b/services/nett.py @@ -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() + ) diff --git "a/styles/\\" "b/styles/\\" new file mode 100644 index 0000000..1f88b04 --- /dev/null +++ "b/styles/\\" @@ -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; +} diff --git a/styles/player.css b/styles/player.css index dc818fb..15c2756 100644 --- a/styles/player.css +++ b/styles/player.css @@ -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; +} diff --git a/utils/icons.py b/utils/icons.py index eeb0c54..896dcae 100644 --- a/utils/icons.py +++ b/utils/icons.py @@ -74,18 +74,23 @@ bat_full: str = "" # Applets + +# Applets +wifi: str = chr(0xF1EB) wifi_0: str = "" wifi_1: str = "" wifi_2: str = "" wifi_3: str = "" world: str = "" world_off: str = "" -bluetooth: str = "" +bluetooth: str = chr(0xF294) night: str = "" coffee: str = "" notifications: str = "" notifications_dots: str = "" +blur: str = chr(0xF00B5) +arrow_head: str = chr(0xE285) wifi_off: str = "" bluetooth_off: str = "" night_off: str = "" @@ -114,6 +119,9 @@ music: str = "" rewind_backward_5: str = "謹" rewind_forward_5: str = "難" +next_fill: str = chr(0xF04AD) +previous_fill: str = chr(0xF04AE) + # Volume vol_off: str = "" vol_mute: str = "" diff --git a/utils/wavy_clock.py b/utils/wavy_clock.py new file mode 100644 index 0000000..6dd992a --- /dev/null +++ b/utils/wavy_clock.py @@ -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()