From 7b75fe3817a81986d73a835c7b6f9c3756bacb38 Mon Sep 17 00:00:00 2001 From: MauricioBarrientos Date: Thu, 14 May 2026 17:32:16 -0400 Subject: [PATCH] fix: sync fullscreen state with WM and move GTK calls to main thread - Fixes #385: Connect window-state-event to sync self.fullscreen with the actual WM state. In Hyprland/Wayland the WM can toggle fullscreen independently, causing desync. full_screen_mode() now reads gdk_window.get_state() instead of blindly toggling the internal flag. - Fixes #409: Remove direct GTK calls from @async_function threads. Move info_menu_item.set_sensitive(False) into before_play() which runs via @idle_function on the main thread. Add set_cursor() helper decorated with @idle_function so Xtream cursor changes in reload() are marshaled to the main GTK thread, preventing X11 BadRequest errors. Co-Authored-By: Claude Sonnet 4.6 --- usr/lib/hypnotix/hypnotix.py | 54 ++++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index a6be99f..964ec16 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -272,6 +272,7 @@ def __init__(self, application): # Widget signals self.window.connect("key-press-event", self.on_key_press_event) + self.window.connect("window-state-event", self.on_window_state_event) self.mpv_drawing_area.connect("realize", self.on_mpv_drawing_area_realize) self.mpv_drawing_area.connect("draw", self.on_mpv_drawing_area_draw) self.fullscreen_button.connect("clicked", self.on_fullscreen_button_clicked) @@ -881,7 +882,7 @@ def play_async(self, channel): if channel is not None and channel.url is not None: # os.system("mpv --wid=%s %s &" % (self.wid, channel.url)) # self.mpv_drawing_area.show() - self.info_menu_item.set_sensitive(False) + # GTK calls must happen on the main thread — use before_play (idle_function) self.before_play(channel) self.reinit_mpv() self.mpv.play(channel.url) @@ -890,6 +891,7 @@ def play_async(self, channel): @idle_function def before_play(self, channel): + self.info_menu_item.set_sensitive(False) self.channel_stack.set_visible_child_name("channel_page") self.mpv_stack.set_visible_child_name("spinner_page") self.video_properties.clear() @@ -1554,14 +1556,12 @@ def reload(self, page=None, refresh=False): ) if self.x.auth_data != {}: print("XTREAM `{}` Loading Channels".format(provider.name)) - # Save default cursor - current_cursor = self.window.get_window().get_cursor() - # Set waiting cursor - self.window.get_window().set_cursor(Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "wait")) - # Load data + # Cursor changes must happen on the main GTK thread (issue #409) + self.set_cursor("wait") + # Load data (blocking I/O — safe to do in async thread) self.x.load_iptv() - # Restore default cursor - self.window.get_window().set_cursor(current_cursor) + # Restore default cursor on main thread + self.set_cursor(None) # Inform Provider of data provider.channels = self.x.channels provider.movies = self.x.movies @@ -1662,6 +1662,16 @@ def on_mpv_drawing_area_draw(self, widget, cr): cr.set_source_rgb(0.0, 0.0, 0.0) cr.paint() + @idle_function + def set_cursor(self, cursor_name): + # Helper to change the window cursor safely from any thread (issue #409). + if cursor_name is None: + self.window.get_window().set_cursor(None) + else: + self.window.get_window().set_cursor( + Gdk.Cursor.new_from_name(Gdk.Display.get_default(), cursor_name) + ) + def normal_mode(self): self.window.get_window().set_cursor(None) self.window.unfullscreen() @@ -1697,12 +1707,34 @@ def borderless_mode(self): else: self.normal_mode() + def on_window_state_event(self, widget, event): + # Sync self.fullscreen with the actual window state reported by the WM. + # This handles cases where the WM (e.g. Hyprland on Wayland) toggles + # fullscreen independently, which would otherwise desync our internal state. + is_fullscreen = bool(event.new_window_state & Gdk.WindowState.FULLSCREEN) + if self.fullscreen and not is_fullscreen: + # WM left fullscreen (e.g. via its own keybinding) — sync UI + self.fullscreen = False + self.mpv_top_box.show() + self.mpv_bottom_box.hide() + if self.content_type == TV_GROUP: + self.sidebar.show() + self.headerbar.show() + self.channels_box.set_border_width(12) + self.window.get_window().set_cursor(None) + def full_screen_mode(self): if self.stack.get_visible_child_name() == "channels_page": - self.fullscreen = not self.fullscreen - if self.fullscreen: + # Read the actual window state instead of relying on the internal toggle, + # so we stay in sync with the WM (fixes Hyprland/Wayland desync, issue #385). + gdk_window = self.window.get_window() + if gdk_window is not None: + actual_fullscreen = bool(gdk_window.get_state() & Gdk.WindowState.FULLSCREEN) + else: + actual_fullscreen = self.fullscreen + if not actual_fullscreen: + self.fullscreen = True self.window.get_window().set_cursor(Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "none")) - # Fullscreen mode self.window.fullscreen() self.mpv_top_box.hide() self.mpv_bottom_box.hide()