From b027957be5b31660eb230a37b9ea693bf4c178d8 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Fri, 7 Aug 2026 00:34:37 +0200 Subject: [PATCH 01/30] Basic VLC backend to chose --- usr/lib/hypnotix/hypnotix.py | 24 +-- usr/lib/hypnotix/player.py | 153 ++++++++++++++++++ .../schemas/org.x.hypnotix.gschema.xml | 5 + 3 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 usr/lib/hypnotix/player.py diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index a6be99fe..b997e038 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -24,7 +24,7 @@ gi.require_version("XApp", "1.0") from gi.repository import Gtk, Gdk, Gio, XApp, GdkPixbuf, GLib, Pango -import mpv +from player import MpvEngine, VlcEngine import requests import setproctitle from unidecode import unidecode @@ -1645,15 +1645,19 @@ def reinit_mpv(self): osc = options.pop("osc") != "no" if self.mpv is None: - self.mpv = mpv.MPV( - **options, - script_opts="osc-layout=box,osc-seekbarstyle=bar,osc-deadzonesize=0,osc-minmousemove=3", - input_default_bindings=True, - input_vo_keyboard=True, - osc=osc, - ytdl=True, - wid=str(self.mpv_drawing_area.get_window().get_xid()) - ) + chosen_backend = self.settings.get_string("video-backend") + xid = str(self.mpv_drawing_area.get_window().get_xid()) + + if chosen_backend == "vlc": + try: + self.mpv = VlcEngine() + except ImportError: + print("VLC Python bindings missing! Falling back to default MPV.") + chosen_backend = "mpv" + + if chosen_backend != "vlc": + self.mpv = MpvEngine(options=options, osc=osc) + self.mpv.set_window(xid) self.mpv.volume = self.volume self.mpv.observe_property("volume", self.on_volume_prop) diff --git a/usr/lib/hypnotix/player.py b/usr/lib/hypnotix/player.py new file mode 100644 index 00000000..817f13e0 --- /dev/null +++ b/usr/lib/hypnotix/player.py @@ -0,0 +1,153 @@ +import abc +import time + +class VideoPlayer(abc.ABC): + """Abstract Interface layer containing upstream-compliant event/property hooks.""" + @abc.abstractmethod + def set_window(self, xid): + pass + + @abc.abstractmethod + def play(self, url, user_agent=None, referrer=None): + pass + + @abc.abstractmethod + def stop(self): + pass + + @abc.abstractmethod + def set_volume(self, value): + pass + + @abc.abstractmethod + def is_playing(self) -> bool: + pass + + @abc.abstractmethod + def wait_until_playing(self): + pass + + @abc.abstractmethod + def observe_property(self, name, callback): + pass + + @abc.abstractmethod + def register_event_cb(self, callback): + pass + + +class MpvEngine(VideoPlayer): + def __init__(self, options=None, osc=True): + try: + from . import mpv as hypnotix_mpv + except ImportError: + import mpv as hypnotix_mpv + + mpv_options = options if options is not None else {} + + self.player = hypnotix_mpv.MPV( + **mpv_options, + script_opts="osc-layout=box,osc-seekbarstyle=bar,osc-deadzonesize=0,osc-minmousemove=3", + input_default_bindings=True, + input_vo_keyboard=True, + osc=osc, + ytdl=True + ) + + def set_window(self, xid): + self.player.wid = str(xid) + + def play(self, url, user_agent=None, referrer=None): + if user_agent: + self.player["user-agent"] = user_agent + if referrer: + self.player["referrer"] = referrer + self.player.play(url) + + def stop(self): + try: + self.player.stop() + except Exception: + pass + + def set_volume(self, value): + self.player.volume = value + + def is_playing(self) -> bool: + return getattr(self.player, "core_idle", True) is False + + def wait_until_playing(self): + # Direct proxy to the original hypnotix mpv.py wait_until_playing implementation + self.player.wait_until_playing() + + def observe_property(self, name, callback): + self.player.observe_property(name, callback) + + def register_event_cb(self, callback): + self.player.register_event_cb(callback) + + def __setitem__(self, key, value): + self.player[key] = value + +class VlcEngine(VideoPlayer): + def __init__(self): + import vlc + self.instance = vlc.Instance("--no-xlib --quiet --no-video-title-show") + self.player = self.instance.media_player_new() + + # Instruct the video surface wrapper to ignore inputs, + # allowing clicks to hit Hypnotix's native UI buttons. + self.player.video_set_mouse_input(False) + self.player.video_set_key_input(False) + + self._user_agent = "Mozilla/5.0" + self._referrer = "" + + def set_window(self, xid): + self.player.set_xwindow(int(xid)) + + def play(self, url, user_agent=None, referrer=None): + opts = [] + ua = user_agent or self._user_agent + ref = referrer or self._referrer + + if ua: + opts.append(f":http-user-agent={ua}") + if ref: + opts.append(f":http-referrer={ref}") + + media = self.instance.media_new(url, *opts) + self.player.set_media(media) + self.player.play() + + def stop(self): + self.player.stop() + + def set_volume(self, value): + self.player.audio_set_volume(int(value)) + + def is_playing(self) -> bool: + import vlc + return self.player.get_state() in [vlc.State.Playing, vlc.State.Buffering] + + def wait_until_playing(self): + # Safe thread-blocking implementation mimicking the MPV behaviour + # Prevents Hypnotix from closing its loading spinner overlay too early + timeout = 10.0 # seconds to wait before bailing out + start_time = time.time() + while not self.is_playing(): + time.sleep(0.1) + if time.time() - start_time > timeout: + break + + def observe_property(self, name, callback): + pass + + def register_event_cb(self, callback): + pass + + def __setitem__(self, key, value): + if key == "user-agent": + self._user_agent = value + elif key == "referrer": + self._referrer = value diff --git a/usr/share/glib-2.0/schemas/org.x.hypnotix.gschema.xml b/usr/share/glib-2.0/schemas/org.x.hypnotix.gschema.xml index 0ffd7b3d..1ef63098 100644 --- a/usr/share/glib-2.0/schemas/org.x.hypnotix.gschema.xml +++ b/usr/share/glib-2.0/schemas/org.x.hypnotix.gschema.xml @@ -31,5 +31,10 @@ + + 'mpv' + Media player engine backend + Choose between 'mpv' and 'vlc' to render streams. + From 3224518b7dd944ca0ba342e6dba75e5a6ff9e262 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Fri, 7 Aug 2026 07:25:54 +0200 Subject: [PATCH 02/30] Play/Pause and Stop buttons --- usr/lib/hypnotix/hypnotix.py | 41 ++++++++++++++++++++++++++++++++++++ usr/lib/hypnotix/player.py | 20 ++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index b997e038..0775aab0 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -885,6 +885,14 @@ def play_async(self, channel): self.before_play(channel) self.reinit_mpv() self.mpv.play(channel.url) + + chosen_backend = self.settings.get_string("video-backend") + + if chosen_backend == "vlc": + # Vlc does not give any way to control the playback + # So we implement a trivial UI elements + GLib.idle_add(self.mpv_bottom_box.show_all) + self.mpv.wait_until_playing() self.after_play(channel) @@ -1651,6 +1659,28 @@ def reinit_mpv(self): if chosen_backend == "vlc": try: self.mpv = VlcEngine() + + if not hasattr(self, "vlc_control_layout"): + self.vlc_control_layout = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=15) + self.vlc_control_layout.set_halign(Gtk.Align.CENTER) + + ctx = self.vlc_control_layout.get_style_context() + ctx.add_class("osd") + + # THE DYNAMIC PLAY/PAUSE TOGGLE BUTTON + self.btn_toggle = Gtk.Button.new_from_icon_name("media-playback-pause-symbolic", Gtk.IconSize.BUTTON) + self.btn_toggle.connect("clicked", lambda w: self.on_vlc_toggle_clicked()) + self.vlc_control_layout.pack_start(self.btn_toggle, False, False, 5) + + # THE SIMPLE STOP BUTTON + btn_stop = Gtk.Button.new_from_icon_name("media-playback-stop-symbolic", Gtk.IconSize.BUTTON) + btn_stop.connect("clicked", lambda w: self.on_stop_button(None)) + self.vlc_control_layout.pack_start(btn_stop, False, False, 5) + + self.mpv_bottom_box.pack_start(self.vlc_control_layout, True, True, 5) + + self.mpv_bottom_box.show_all() + except ImportError: print("VLC Python bindings missing! Falling back to default MPV.") chosen_backend = "mpv" @@ -1662,6 +1692,17 @@ def reinit_mpv(self): self.mpv.volume = self.volume self.mpv.observe_property("volume", self.on_volume_prop) + def on_vlc_toggle_clicked(self): + if not self.mpv: + return + + if getattr(self.mpv, 'is_paused', lambda: False)(): + self.mpv.set_engine_resume() + self.btn_toggle.set_image(Gtk.Image.new_from_icon_name("media-playback-pause-symbolic", Gtk.IconSize.BUTTON)) + else: + self.mpv.set_engine_pause() + self.btn_toggle.set_image(Gtk.Image.new_from_icon_name("media-playback-start-symbolic", Gtk.IconSize.BUTTON)) + def on_mpv_drawing_area_draw(self, widget, cr): cr.set_source_rgb(0.0, 0.0, 0.0) cr.paint() diff --git a/usr/lib/hypnotix/player.py b/usr/lib/hypnotix/player.py index 817f13e0..692ebe4c 100644 --- a/usr/lib/hypnotix/player.py +++ b/usr/lib/hypnotix/player.py @@ -89,6 +89,15 @@ def register_event_cb(self, callback): def __setitem__(self, key, value): self.player[key] = value + def set_engine_pause(self): + self.player.pause = True + + def set_engine_resume(self): + self.player.pause = False + + def is_paused(self) -> bool: + return getattr(self.player, "pause", False) + class VlcEngine(VideoPlayer): def __init__(self): import vlc @@ -151,3 +160,14 @@ def __setitem__(self, key, value): self._user_agent = value elif key == "referrer": self._referrer = value + + def set_engine_pause(self): + self.player.set_pause(True) + self.player.set_rate(0.0) # Freezes the live video container frame solid + + def set_engine_resume(self): + self.player.set_rate(1.0) # Restores full video processing speed + self.player.set_pause(False) + + def is_paused(self) -> bool: + return self.player.get_rate() == 0.0 From 8408943d5da91c5486eb77c13c5bcf9d962add93 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Fri, 7 Aug 2026 07:29:47 +0200 Subject: [PATCH 03/30] A sandwich menu to be able to change video/audio/subtitle streams --- usr/lib/hypnotix/hypnotix.py | 53 ++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index 0775aab0..bcb68830 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -1677,6 +1677,15 @@ def reinit_mpv(self): btn_stop.connect("clicked", lambda w: self.on_stop_button(None)) self.vlc_control_layout.pack_start(btn_stop, False, False, 5) + # THE SANDWICH MENU BUTTON (Audio, Video, Subtitle streams) + btn_menu = Gtk.MenuButton() + btn_menu.set_image(Gtk.Image.new_from_icon_name("open-menu-symbolic", Gtk.IconSize.BUTTON)) + btn_menu.set_direction(Gtk.ArrowType.UP) + self.vlc_stream_menu = Gtk.Menu() + btn_menu.set_popup(self.vlc_stream_menu) + btn_menu.connect("toggled", lambda w: self.on_vlc_menu_toggled(btn_menu)) + self.vlc_control_layout.pack_start(btn_menu, False, False, 5) + self.mpv_bottom_box.pack_start(self.vlc_control_layout, True, True, 5) self.mpv_bottom_box.show_all() @@ -1703,6 +1712,50 @@ def on_vlc_toggle_clicked(self): self.mpv.set_engine_pause() self.btn_toggle.set_image(Gtk.Image.new_from_icon_name("media-playback-start-symbolic", Gtk.IconSize.BUTTON)) + def on_vlc_menu_toggled(self, menu_button): + if not menu_button.get_active() or not self.mpv or not hasattr(self.mpv, "player"): + return + + for child in self.vlc_stream_menu.get_children(): + self.vlc_stream_menu.remove(child) + + player = self.mpv.player + + stream_categories = [ + ("Audio", player.audio_get_track_description, player.audio_get_track, player.audio_set_track), + ("Video", player.video_get_track_description, player.video_get_track, player.video_set_track), + ("Subtitles", player.video_get_spu_description, player.video_get_spu, player.video_set_spu), + ] + + for label, get_desc, get_curr, set_track in stream_categories: + root_item = Gtk.MenuItem(label=label) + submenu = Gtk.Menu() + root_item.set_submenu(submenu) + self.vlc_stream_menu.append(root_item) + + tracks = get_desc() or [] + current_track_id = get_curr() + + # Ensure an option to disable the stream is always available + if not any(tid == -1 for tid, _ in tracks): + tracks.insert(0, (-1, b"Disable")) + + group = None + for track_id, track_name in tracks: + name_str = track_name.decode("utf-8", "ignore") if isinstance(track_name, bytes) else str(track_name) + + item = Gtk.RadioMenuItem(group=group, label=name_str) + if group is None: + group = item + + if track_id == current_track_id: + item.set_active(True) + + item.connect("activate", lambda w, fn=set_track, tid=track_id: fn(tid) if w.get_active() else None) + submenu.append(item) + + self.vlc_stream_menu.show_all() + def on_mpv_drawing_area_draw(self, widget, cr): cr.set_source_rgb(0.0, 0.0, 0.0) cr.paint() From 85eb9f5b106b8f90d85bbb35b22fa1a9279ce5cd Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Fri, 7 Aug 2026 08:42:00 +0200 Subject: [PATCH 04/30] Extract the VLC gui logic into vlcgui.py not to clutter hypnotix.py --- usr/lib/hypnotix/hypnotix.py | 96 ++--------------------- usr/lib/hypnotix/vlcgui.py | 144 +++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 91 deletions(-) create mode 100644 usr/lib/hypnotix/vlcgui.py diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index bcb68830..d885e9cc 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -25,6 +25,7 @@ from gi.repository import Gtk, Gdk, Gio, XApp, GdkPixbuf, GLib, Pango from player import MpvEngine, VlcEngine +from vlcgui import VLCGUIController import requests import setproctitle from unidecode import unidecode @@ -885,14 +886,6 @@ def play_async(self, channel): self.before_play(channel) self.reinit_mpv() self.mpv.play(channel.url) - - chosen_backend = self.settings.get_string("video-backend") - - if chosen_backend == "vlc": - # Vlc does not give any way to control the playback - # So we implement a trivial UI elements - GLib.idle_add(self.mpv_bottom_box.show_all) - self.mpv.wait_until_playing() self.after_play(channel) @@ -1660,35 +1653,11 @@ def reinit_mpv(self): try: self.mpv = VlcEngine() - if not hasattr(self, "vlc_control_layout"): - self.vlc_control_layout = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=15) - self.vlc_control_layout.set_halign(Gtk.Align.CENTER) - - ctx = self.vlc_control_layout.get_style_context() - ctx.add_class("osd") - - # THE DYNAMIC PLAY/PAUSE TOGGLE BUTTON - self.btn_toggle = Gtk.Button.new_from_icon_name("media-playback-pause-symbolic", Gtk.IconSize.BUTTON) - self.btn_toggle.connect("clicked", lambda w: self.on_vlc_toggle_clicked()) - self.vlc_control_layout.pack_start(self.btn_toggle, False, False, 5) + if not hasattr(self, "vlc_gui"): + self.vlc_gui = VLCGUIController(self) + self.vlc_gui.setup_ui() - # THE SIMPLE STOP BUTTON - btn_stop = Gtk.Button.new_from_icon_name("media-playback-stop-symbolic", Gtk.IconSize.BUTTON) - btn_stop.connect("clicked", lambda w: self.on_stop_button(None)) - self.vlc_control_layout.pack_start(btn_stop, False, False, 5) - - # THE SANDWICH MENU BUTTON (Audio, Video, Subtitle streams) - btn_menu = Gtk.MenuButton() - btn_menu.set_image(Gtk.Image.new_from_icon_name("open-menu-symbolic", Gtk.IconSize.BUTTON)) - btn_menu.set_direction(Gtk.ArrowType.UP) - self.vlc_stream_menu = Gtk.Menu() - btn_menu.set_popup(self.vlc_stream_menu) - btn_menu.connect("toggled", lambda w: self.on_vlc_menu_toggled(btn_menu)) - self.vlc_control_layout.pack_start(btn_menu, False, False, 5) - - self.mpv_bottom_box.pack_start(self.vlc_control_layout, True, True, 5) - - self.mpv_bottom_box.show_all() + self.vlc_gui.show_controls() except ImportError: print("VLC Python bindings missing! Falling back to default MPV.") @@ -1701,61 +1670,6 @@ def reinit_mpv(self): self.mpv.volume = self.volume self.mpv.observe_property("volume", self.on_volume_prop) - def on_vlc_toggle_clicked(self): - if not self.mpv: - return - - if getattr(self.mpv, 'is_paused', lambda: False)(): - self.mpv.set_engine_resume() - self.btn_toggle.set_image(Gtk.Image.new_from_icon_name("media-playback-pause-symbolic", Gtk.IconSize.BUTTON)) - else: - self.mpv.set_engine_pause() - self.btn_toggle.set_image(Gtk.Image.new_from_icon_name("media-playback-start-symbolic", Gtk.IconSize.BUTTON)) - - def on_vlc_menu_toggled(self, menu_button): - if not menu_button.get_active() or not self.mpv or not hasattr(self.mpv, "player"): - return - - for child in self.vlc_stream_menu.get_children(): - self.vlc_stream_menu.remove(child) - - player = self.mpv.player - - stream_categories = [ - ("Audio", player.audio_get_track_description, player.audio_get_track, player.audio_set_track), - ("Video", player.video_get_track_description, player.video_get_track, player.video_set_track), - ("Subtitles", player.video_get_spu_description, player.video_get_spu, player.video_set_spu), - ] - - for label, get_desc, get_curr, set_track in stream_categories: - root_item = Gtk.MenuItem(label=label) - submenu = Gtk.Menu() - root_item.set_submenu(submenu) - self.vlc_stream_menu.append(root_item) - - tracks = get_desc() or [] - current_track_id = get_curr() - - # Ensure an option to disable the stream is always available - if not any(tid == -1 for tid, _ in tracks): - tracks.insert(0, (-1, b"Disable")) - - group = None - for track_id, track_name in tracks: - name_str = track_name.decode("utf-8", "ignore") if isinstance(track_name, bytes) else str(track_name) - - item = Gtk.RadioMenuItem(group=group, label=name_str) - if group is None: - group = item - - if track_id == current_track_id: - item.set_active(True) - - item.connect("activate", lambda w, fn=set_track, tid=track_id: fn(tid) if w.get_active() else None) - submenu.append(item) - - self.vlc_stream_menu.show_all() - def on_mpv_drawing_area_draw(self, widget, cr): cr.set_source_rgb(0.0, 0.0, 0.0) cr.paint() diff --git a/usr/lib/hypnotix/vlcgui.py b/usr/lib/hypnotix/vlcgui.py new file mode 100644 index 00000000..bb92d427 --- /dev/null +++ b/usr/lib/hypnotix/vlcgui.py @@ -0,0 +1,144 @@ +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + + +class VLCGUIController: + """Manages the OSD playback controls and stream selection menus when using the VLC backend.""" + + def __init__(self, main_window): + self.win = main_window + self.vlc_control_layout = None + self.btn_toggle = None + self.vlc_stream_menu = None + + def setup_ui(self): + if self.vlc_control_layout is not None: + return + + self.vlc_control_layout = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, spacing=15 + ) + self.vlc_control_layout.set_halign(Gtk.Align.CENTER) + + ctx = self.vlc_control_layout.get_style_context() + ctx.add_class("osd") + + # THE DYNAMIC PLAY/PAUSE TOGGLE BUTTON + self.btn_toggle = Gtk.Button.new_from_icon_name( + "media-playback-pause-symbolic", Gtk.IconSize.BUTTON + ) + self.btn_toggle.connect("clicked", lambda w: self.on_vlc_toggle_clicked()) + self.vlc_control_layout.pack_start(self.btn_toggle, False, False, 5) + + # THE SIMPLE STOP BUTTON + btn_stop = Gtk.Button.new_from_icon_name( + "media-playback-stop-symbolic", Gtk.IconSize.BUTTON + ) + btn_stop.connect("clicked", lambda w: self.win.on_stop_button(None)) + self.vlc_control_layout.pack_start(btn_stop, False, False, 5) + + # THE SANDWICH MENU BUTTON (Audio, Video, Subtitle streams) + btn_menu = Gtk.MenuButton() + btn_menu.set_image( + Gtk.Image.new_from_icon_name("open-menu-symbolic", Gtk.IconSize.BUTTON) + ) + btn_menu.set_direction(Gtk.ArrowType.UP) + self.vlc_stream_menu = Gtk.Menu() + btn_menu.set_popup(self.vlc_stream_menu) + self.vlc_stream_menu.connect("show", lambda w: self.on_vlc_menu_show()) + self.vlc_control_layout.pack_start(btn_menu, False, False, 5) + + self.win.mpv_bottom_box.pack_start(self.vlc_control_layout, True, True, 5) + + def show_controls(self): + from gi.repository import GLib + GLib.idle_add(self.win.mpv_bottom_box.show_all) + + def on_vlc_toggle_clicked(self): + if not self.win.mpv: + return + + if getattr(self.win.mpv, "is_paused", lambda: False)(): + self.win.mpv.set_engine_resume() + self.btn_toggle.set_image( + Gtk.Image.new_from_icon_name( + "media-playback-pause-symbolic", Gtk.IconSize.BUTTON + ) + ) + else: + self.win.mpv.set_engine_pause() + self.btn_toggle.set_image( + Gtk.Image.new_from_icon_name( + "media-playback-start-symbolic", Gtk.IconSize.BUTTON + ) + ) + + def on_vlc_menu_show(self): + if not self.win.mpv or not hasattr(self.win.mpv, "player"): + return + + for child in self.vlc_stream_menu.get_children(): + self.vlc_stream_menu.remove(child) + + player = self.win.mpv.player + + stream_categories = [ + ( + "Audio", + player.audio_get_track_description, + player.audio_get_track, + player.audio_set_track, + ), + ( + "Video", + player.video_get_track_description, + player.video_get_track, + player.video_set_track, + ), + ( + "Subtitles", + player.video_get_spu_description, + player.video_get_spu, + player.video_set_spu, + ), + ] + + for label, get_desc, get_curr, set_track in stream_categories: + root_item = Gtk.MenuItem(label=label) + submenu = Gtk.Menu() + root_item.set_submenu(submenu) + self.vlc_stream_menu.append(root_item) + + tracks = get_desc() or [] + current_track_id = get_curr() + + # Ensure an option to disable the stream is always available + if not any(tid == -1 for tid, _ in tracks): + tracks.insert(0, (-1, b"Disable")) + + group = None + for track_id, track_name in tracks: + name_str = ( + track_name.decode("utf-8", "ignore") + if isinstance(track_name, bytes) + else str(track_name) + ) + + item = Gtk.RadioMenuItem(group=group, label=name_str) + if group is None: + group = item + + if track_id == current_track_id: + item.set_active(True) + + item.connect( + "activate", + lambda w, fn=set_track, tid=track_id: ( + fn(tid) if w.get_active() else None + ), + ) + submenu.append(item) + + self.vlc_stream_menu.show_all() From b238231444a135041fc98c07f1450e52b6691298 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Fri, 7 Aug 2026 09:40:29 +0200 Subject: [PATCH 05/30] Choice of backends for video playback --- usr/lib/hypnotix/hypnotix.py | 29 +++++++++++++++++++++++++++++ usr/lib/hypnotix/player.py | 22 ++++++++++++++++++++++ usr/share/hypnotix/hypnotix.ui | 28 ++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index d885e9cc..ff1fdfcf 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -237,6 +237,7 @@ def __init__(self, application): "useragent_entry", "referer_entry", "mpv_entry", + "video_backend_combo", "mpv_link", "ytdlp_local_switch", "ytdlp_system_version_label", @@ -328,6 +329,22 @@ def __init__(self, application): self.bind_setting_widget("http-referer", self.referer_entry) self.bind_setting_widget("mpv-options", self.mpv_entry) + # Video Backend combo box (in preferences, alongside mpv-options) + backend_model = Gtk.ListStore(str, str) + backend_model.append(["mpv", _("MPV (Default)")]) + backend_model.append(["vlc", _("VLC Player")]) + self.video_backend_combo.set_model(backend_model) + renderer = Gtk.CellRendererText() + self.video_backend_combo.pack_start(renderer, True) + self.video_backend_combo.add_attribute(renderer, "text", 1) + + current_backend = self.settings.get_string("video-backend") + for i, row in enumerate(backend_model): + if row[0] == current_backend: + self.video_backend_combo.set_active(i) + break + + self.video_backend_combo.connect("changed", self.on_video_backend_combo_changed) # ytdlp self.ytdlp_local_switch.set_active(self.settings.get_boolean("use-local-ytdlp")) self.ytdlp_local_switch.connect("notify::active", self.on_ytdlp_local_switch_activated) @@ -641,6 +658,18 @@ def bind_setting_widget(self, key, widget): def on_entry_changed(self, widget, key): self.settings.set_string(key, widget.get_text()) + def on_video_backend_combo_changed(self, combo): + model = combo.get_model() + active_iter = combo.get_active_iter() + if active_iter: + backend_id = model[active_iter][0] + if backend_id != self.settings.get_string("video-backend"): + self.settings.set_string("video-backend", backend_id) + if self.mpv is not None: + self.on_stop_button(None) + self.mpv = None + self.mpv_bottom_box.hide() + def on_ytdlp_local_switch_activated(self, widget, data=None): self.settings.set_boolean("use-local-ytdlp", widget.get_active()) if widget.get_active(): diff --git a/usr/lib/hypnotix/player.py b/usr/lib/hypnotix/player.py index 692ebe4c..9942d0b7 100644 --- a/usr/lib/hypnotix/player.py +++ b/usr/lib/hypnotix/player.py @@ -35,6 +35,28 @@ def observe_property(self, name, callback): def register_event_cb(self, callback): pass + @abc.abstractmethod + def is_paused(self) -> bool: + pass + + @abc.abstractmethod + def set_engine_pause(self): + pass + + @abc.abstractmethod + def set_engine_resume(self): + pass + + @property + def pause(self) -> bool: + return self.is_paused() + + @pause.setter + def pause(self, value: bool): + if value: + self.set_engine_pause() + else: + self.set_engine_resume() class MpvEngine(VideoPlayer): def __init__(self, options=None, osc=True): diff --git a/usr/share/hypnotix/hypnotix.ui b/usr/share/hypnotix/hypnotix.ui index 54a2ab98..075906b7 100644 --- a/usr/share/hypnotix/hypnotix.ui +++ b/usr/share/hypnotix/hypnotix.ui @@ -810,6 +810,34 @@ 1 + + + True + False + start + center + Playback Engine + + + + + + 0 + 2 + + + + + True + False + center + True + + + 1 + 2 + + From a696761bf4ff97b9992a1c99d62602c33936b0e2 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Fri, 7 Aug 2026 20:43:28 +0200 Subject: [PATCH 06/30] Don't list in the chose Vlc backend if its dependencies are not satisfied --- usr/lib/hypnotix/hypnotix.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index ff1fdfcf..b1e65e2c 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -332,19 +332,26 @@ def __init__(self, application): # Video Backend combo box (in preferences, alongside mpv-options) backend_model = Gtk.ListStore(str, str) backend_model.append(["mpv", _("MPV (Default)")]) - backend_model.append(["vlc", _("VLC Player")]) + try: + import vlc + backend_model.append(["vlc", _("VLC Player")]) + except ImportError: + pass self.video_backend_combo.set_model(backend_model) renderer = Gtk.CellRendererText() self.video_backend_combo.pack_start(renderer, True) self.video_backend_combo.add_attribute(renderer, "text", 1) current_backend = self.settings.get_string("video-backend") + active_index = 0 for i, row in enumerate(backend_model): if row[0] == current_backend: - self.video_backend_combo.set_active(i) + active_index = i break + self.video_backend_combo.set_active(active_index) self.video_backend_combo.connect("changed", self.on_video_backend_combo_changed) + # ytdlp self.ytdlp_local_switch.set_active(self.settings.get_boolean("use-local-ytdlp")) self.ytdlp_local_switch.connect("notify::active", self.on_ytdlp_local_switch_activated) From de30f3cb88daadfe9fa1ad8809c7f5ca89cddace Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Fri, 7 Aug 2026 12:38:37 +0200 Subject: [PATCH 07/30] Remove some duplications and cut rought edges of the vlc gui --- usr/lib/hypnotix/hypnotix.py | 4 +- usr/lib/hypnotix/player.py | 87 ++++++++++++++++++++++-------------- usr/lib/hypnotix/vlcgui.py | 54 ++++++++++++---------- 3 files changed, 87 insertions(+), 58 deletions(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index b1e65e2c..af40df4a 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -674,6 +674,7 @@ def on_video_backend_combo_changed(self, combo): self.settings.set_string("video-backend", backend_id) if self.mpv is not None: self.on_stop_button(None) + self.mpv.terminate() self.mpv = None self.mpv_bottom_box.hide() @@ -1687,12 +1688,11 @@ def reinit_mpv(self): if chosen_backend == "vlc": try: - self.mpv = VlcEngine() - if not hasattr(self, "vlc_gui"): self.vlc_gui = VLCGUIController(self) self.vlc_gui.setup_ui() + self.mpv = VlcEngine(gui=self.vlc_gui) self.vlc_gui.show_controls() except ImportError: diff --git a/usr/lib/hypnotix/player.py b/usr/lib/hypnotix/player.py index 9942d0b7..a3382a62 100644 --- a/usr/lib/hypnotix/player.py +++ b/usr/lib/hypnotix/player.py @@ -2,7 +2,8 @@ import time class VideoPlayer(abc.ABC): - """Abstract Interface layer containing upstream-compliant event/property hooks.""" + """Abstract base class defining the media player engine interface.""" + @abc.abstractmethod def set_window(self, xid): pass @@ -15,6 +16,10 @@ def play(self, url, user_agent=None, referrer=None): def stop(self): pass + @abc.abstractmethod + def terminate(self): + pass + @abc.abstractmethod def set_volume(self, value): pass @@ -35,28 +40,16 @@ def observe_property(self, name, callback): def register_event_cb(self, callback): pass + @property @abc.abstractmethod - def is_paused(self) -> bool: - pass - - @abc.abstractmethod - def set_engine_pause(self): + def pause(self) -> bool: pass + @pause.setter @abc.abstractmethod - def set_engine_resume(self): + def pause(self, value: bool): pass - @property - def pause(self) -> bool: - return self.is_paused() - - @pause.setter - def pause(self, value: bool): - if value: - self.set_engine_pause() - else: - self.set_engine_resume() class MpvEngine(VideoPlayer): def __init__(self, options=None, osc=True): @@ -92,6 +85,13 @@ def stop(self): except Exception: pass + def terminate(self): + self.stop() + try: + self.player.terminate() + except Exception: + pass + def set_volume(self, value): self.player.volume = value @@ -111,18 +111,20 @@ def register_event_cb(self, callback): def __setitem__(self, key, value): self.player[key] = value - def set_engine_pause(self): - self.player.pause = True + @property + def pause(self) -> bool: + return getattr(self.player, "pause", False) - def set_engine_resume(self): - self.player.pause = False + @pause.setter + def pause(self, value: bool): + self.player.pause = bool(value) - def is_paused(self) -> bool: - return getattr(self.player, "pause", False) class VlcEngine(VideoPlayer): - def __init__(self): + def __init__(self, gui=None): import vlc + self.gui = gui + self.instance = vlc.Instance("--no-xlib --quiet --no-video-title-show") self.player = self.instance.media_player_new() @@ -138,6 +140,7 @@ def set_window(self, xid): self.player.set_xwindow(int(xid)) def play(self, url, user_agent=None, referrer=None): + self._stopped = False opts = [] ua = user_agent or self._user_agent ref = referrer or self._referrer @@ -150,15 +153,29 @@ def play(self, url, user_agent=None, referrer=None): media = self.instance.media_new(url, *opts) self.player.set_media(media) self.player.play() + if self.gui: + self.gui.set_controls_sensitive(True) def stop(self): + self._stopped = True self.player.stop() + if self.gui: + self.gui.set_controls_sensitive(False) + + def terminate(self): + self.stop() + try: + self.player.release() + self.instance.release() + except Exception: + pass def set_volume(self, value): self.player.audio_set_volume(int(value)) def is_playing(self) -> bool: import vlc + return self.player.get_state() in [vlc.State.Playing, vlc.State.Buffering] def wait_until_playing(self): @@ -170,6 +187,8 @@ def wait_until_playing(self): time.sleep(0.1) if time.time() - start_time > timeout: break + if self.gui and not getattr(self, "_stopped", False): + self.gui.set_controls_sensitive(True) def observe_property(self, name, callback): pass @@ -183,13 +202,15 @@ def __setitem__(self, key, value): elif key == "referrer": self._referrer = value - def set_engine_pause(self): - self.player.set_pause(True) - self.player.set_rate(0.0) # Freezes the live video container frame solid - - def set_engine_resume(self): - self.player.set_rate(1.0) # Restores full video processing speed - self.player.set_pause(False) - - def is_paused(self) -> bool: + @property + def pause(self) -> bool: return self.player.get_rate() == 0.0 + + @pause.setter + def pause(self, value: bool): + if value: + self.player.set_pause(True) + self.player.set_rate(0.0) # Freezes the live video container frame solid + else: + self.player.set_rate(1.0) # Restores full video processing speed + self.player.set_pause(False) diff --git a/usr/lib/hypnotix/vlcgui.py b/usr/lib/hypnotix/vlcgui.py index bb92d427..6805eeed 100644 --- a/usr/lib/hypnotix/vlcgui.py +++ b/usr/lib/hypnotix/vlcgui.py @@ -1,7 +1,7 @@ import gi gi.require_version("Gtk", "3.0") -from gi.repository import Gtk +from gi.repository import GLib, Gtk class VLCGUIController: @@ -11,6 +11,8 @@ def __init__(self, main_window): self.win = main_window self.vlc_control_layout = None self.btn_toggle = None + self.btn_stop = None + self.btn_menu = None self.vlc_stream_menu = None def setup_ui(self): @@ -33,47 +35,53 @@ def setup_ui(self): self.vlc_control_layout.pack_start(self.btn_toggle, False, False, 5) # THE SIMPLE STOP BUTTON - btn_stop = Gtk.Button.new_from_icon_name( + self.btn_stop = Gtk.Button.new_from_icon_name( "media-playback-stop-symbolic", Gtk.IconSize.BUTTON ) - btn_stop.connect("clicked", lambda w: self.win.on_stop_button(None)) - self.vlc_control_layout.pack_start(btn_stop, False, False, 5) + self.btn_stop.connect("clicked", lambda w: self.win.on_stop_button(None)) + self.vlc_control_layout.pack_start(self.btn_stop, False, False, 5) # THE SANDWICH MENU BUTTON (Audio, Video, Subtitle streams) - btn_menu = Gtk.MenuButton() - btn_menu.set_image( + self.btn_menu = Gtk.MenuButton() + self.btn_menu.set_image( Gtk.Image.new_from_icon_name("open-menu-symbolic", Gtk.IconSize.BUTTON) ) - btn_menu.set_direction(Gtk.ArrowType.UP) + self.btn_menu.set_direction(Gtk.ArrowType.UP) self.vlc_stream_menu = Gtk.Menu() - btn_menu.set_popup(self.vlc_stream_menu) + self.btn_menu.set_popup(self.vlc_stream_menu) self.vlc_stream_menu.connect("show", lambda w: self.on_vlc_menu_show()) - self.vlc_control_layout.pack_start(btn_menu, False, False, 5) + self.vlc_control_layout.pack_start(self.btn_menu, False, False, 5) self.win.mpv_bottom_box.pack_start(self.vlc_control_layout, True, True, 5) + self.set_controls_sensitive(False) def show_controls(self): - from gi.repository import GLib GLib.idle_add(self.win.mpv_bottom_box.show_all) def on_vlc_toggle_clicked(self): if not self.win.mpv: return - if getattr(self.win.mpv, "is_paused", lambda: False)(): - self.win.mpv.set_engine_resume() - self.btn_toggle.set_image( - Gtk.Image.new_from_icon_name( - "media-playback-pause-symbolic", Gtk.IconSize.BUTTON + self.win.mpv.pause = not self.win.mpv.pause + icon = "media-playback-start-symbolic" if self.win.mpv.pause else "media-playback-pause-symbolic" + self.btn_toggle.set_image(Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.BUTTON)) + + def set_controls_sensitive(self, sensitive: bool): + def _update(): + for btn in (self.btn_toggle, self.btn_stop, self.btn_menu): + if btn: + btn.set_sensitive(sensitive) + + if self.btn_toggle: + icon = "media-playback-pause-symbolic" if sensitive else "media-playback-start-symbolic" + self.btn_toggle.set_image( + Gtk.Image.new_from_icon_name( + icon, Gtk.IconSize.BUTTON + ) ) - ) - else: - self.win.mpv.set_engine_pause() - self.btn_toggle.set_image( - Gtk.Image.new_from_icon_name( - "media-playback-start-symbolic", Gtk.IconSize.BUTTON - ) - ) + return False + + GLib.idle_add(_update) def on_vlc_menu_show(self): if not self.win.mpv or not hasattr(self.win.mpv, "player"): From 3705dd86c13d44a605f88c3616b1d79a5ad26d70 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Fri, 7 Aug 2026 13:57:25 +0200 Subject: [PATCH 08/30] Toggle the pause_button's icon in playback_bar according to the state --- usr/lib/hypnotix/hypnotix.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index af40df4a..497e509c 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -955,6 +955,7 @@ def before_play(self, channel): self.favorite_button.set_active(False) self.favorite_button_image.set_from_icon_name("xsi-non-starred-symbolic", Gtk.IconSize.BUTTON) self.favorite_button.set_tooltip_text(_("Add to favorites")) + self.update_pause_button(False) self.page_is_loading = False @idle_function @@ -1081,8 +1082,15 @@ def on_stop_button(self, widget): self.info_menu_item.set_sensitive(False) self.playback_bar.hide() + def update_pause_button(self, is_paused: bool): + icon = "media-playback-start-symbolic" if is_paused else "media-playback-pause-symbolic" + tooltip = _("Play") if is_paused else _("Pause") + self.pause_button.set_image(Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.BUTTON)) + self.pause_button.set_tooltip_text(tooltip) + def on_pause_button(self, widget): self.mpv.pause = not self.mpv.pause + self.update_pause_button(self.mpv.pause) def on_show_button(self, widget): self.navigate_to("channels_page") From 8f109efa103ba47fefee93cd1f55897e642ba22c Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Fri, 7 Aug 2026 14:16:37 +0200 Subject: [PATCH 09/30] Reuse the pause button icon toggling --- usr/lib/hypnotix/common.py | 10 +++++++++- usr/lib/hypnotix/hypnotix.py | 12 +++--------- usr/lib/hypnotix/vlcgui.py | 13 ++++--------- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/usr/lib/hypnotix/common.py b/usr/lib/hypnotix/common.py index 0e60b5f0..1a4245df 100755 --- a/usr/lib/hypnotix/common.py +++ b/usr/lib/hypnotix/common.py @@ -4,7 +4,9 @@ import threading import requests -from gi.repository import GLib, GObject +from gi.repository import GLib, GObject, Gtk +import gettext +_ = gettext.gettext # M3U parsing regex PARAMS = re.compile(r'(\S+)="(.*?)"') @@ -35,6 +37,12 @@ def wrapper(*args): return wrapper +def set_playback_button_state(button: Gtk.Button, is_paused: bool): + """Updates a button's icon and tooltip based on the paused state.""" + icon = "media-playback-start-symbolic" if is_paused else "media-playback-pause-symbolic" + tooltip = _("Play") if is_paused else _("Pause") + button.set_image(Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.BUTTON)) + button.set_tooltip_text(tooltip) def slugify(string): """ diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index 497e509c..3b7358fb 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -31,7 +31,7 @@ from unidecode import unidecode from common import Manager, Provider, Channel, MOVIES_GROUP, PROVIDERS_PATH, SERIES_GROUP, TV_GROUP,\ - async_function, idle_function + async_function, idle_function, set_playback_button_state setproctitle.setproctitle("hypnotix") @@ -955,7 +955,7 @@ def before_play(self, channel): self.favorite_button.set_active(False) self.favorite_button_image.set_from_icon_name("xsi-non-starred-symbolic", Gtk.IconSize.BUTTON) self.favorite_button.set_tooltip_text(_("Add to favorites")) - self.update_pause_button(False) + set_playback_button_state(self.pause_button, False) self.page_is_loading = False @idle_function @@ -1082,15 +1082,9 @@ def on_stop_button(self, widget): self.info_menu_item.set_sensitive(False) self.playback_bar.hide() - def update_pause_button(self, is_paused: bool): - icon = "media-playback-start-symbolic" if is_paused else "media-playback-pause-symbolic" - tooltip = _("Play") if is_paused else _("Pause") - self.pause_button.set_image(Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.BUTTON)) - self.pause_button.set_tooltip_text(tooltip) - def on_pause_button(self, widget): self.mpv.pause = not self.mpv.pause - self.update_pause_button(self.mpv.pause) + set_playback_button_state(self.pause_button, self.mpv.pause) def on_show_button(self, widget): self.navigate_to("channels_page") diff --git a/usr/lib/hypnotix/vlcgui.py b/usr/lib/hypnotix/vlcgui.py index 6805eeed..7422fe38 100644 --- a/usr/lib/hypnotix/vlcgui.py +++ b/usr/lib/hypnotix/vlcgui.py @@ -2,7 +2,7 @@ gi.require_version("Gtk", "3.0") from gi.repository import GLib, Gtk - +from common import set_playback_button_state class VLCGUIController: """Manages the OSD playback controls and stream selection menus when using the VLC backend.""" @@ -63,8 +63,7 @@ def on_vlc_toggle_clicked(self): return self.win.mpv.pause = not self.win.mpv.pause - icon = "media-playback-start-symbolic" if self.win.mpv.pause else "media-playback-pause-symbolic" - self.btn_toggle.set_image(Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.BUTTON)) + set_playback_button_state(self.btn_toggle, self.win.mpv.pause) def set_controls_sensitive(self, sensitive: bool): def _update(): @@ -73,12 +72,8 @@ def _update(): btn.set_sensitive(sensitive) if self.btn_toggle: - icon = "media-playback-pause-symbolic" if sensitive else "media-playback-start-symbolic" - self.btn_toggle.set_image( - Gtk.Image.new_from_icon_name( - icon, Gtk.IconSize.BUTTON - ) - ) + set_playback_button_state(self.btn_toggle, not sensitive) + return False GLib.idle_add(_update) From d3bc19eef05b9bc120b6addedf224a208897b192 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Fri, 7 Aug 2026 14:36:10 +0200 Subject: [PATCH 10/30] Add the tooltips and make our vlcgui.py localisable --- usr/lib/hypnotix/vlcgui.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/usr/lib/hypnotix/vlcgui.py b/usr/lib/hypnotix/vlcgui.py index 7422fe38..96ca357f 100644 --- a/usr/lib/hypnotix/vlcgui.py +++ b/usr/lib/hypnotix/vlcgui.py @@ -3,6 +3,8 @@ gi.require_version("Gtk", "3.0") from gi.repository import GLib, Gtk from common import set_playback_button_state +import gettext +_ = gettext.gettext class VLCGUIController: """Manages the OSD playback controls and stream selection menus when using the VLC backend.""" @@ -24,13 +26,10 @@ def setup_ui(self): ) self.vlc_control_layout.set_halign(Gtk.Align.CENTER) - ctx = self.vlc_control_layout.get_style_context() - ctx.add_class("osd") - # THE DYNAMIC PLAY/PAUSE TOGGLE BUTTON - self.btn_toggle = Gtk.Button.new_from_icon_name( - "media-playback-pause-symbolic", Gtk.IconSize.BUTTON - ) + self.btn_toggle = Gtk.Button() + self.btn_toggle.set_relief(Gtk.ReliefStyle.NONE) + set_playback_button_state(self.btn_toggle, False) self.btn_toggle.connect("clicked", lambda w: self.on_vlc_toggle_clicked()) self.vlc_control_layout.pack_start(self.btn_toggle, False, False, 5) @@ -38,6 +37,8 @@ def setup_ui(self): self.btn_stop = Gtk.Button.new_from_icon_name( "media-playback-stop-symbolic", Gtk.IconSize.BUTTON ) + self.btn_stop.set_relief(Gtk.ReliefStyle.NONE) + self.btn_stop.set_tooltip_text(_("Stop")) self.btn_stop.connect("clicked", lambda w: self.win.on_stop_button(None)) self.vlc_control_layout.pack_start(self.btn_stop, False, False, 5) @@ -46,6 +47,8 @@ def setup_ui(self): self.btn_menu.set_image( Gtk.Image.new_from_icon_name("open-menu-symbolic", Gtk.IconSize.BUTTON) ) + self.btn_menu.set_relief(Gtk.ReliefStyle.NONE) + self.btn_menu.set_tooltip_text(_("Streams")) self.btn_menu.set_direction(Gtk.ArrowType.UP) self.vlc_stream_menu = Gtk.Menu() self.btn_menu.set_popup(self.vlc_stream_menu) @@ -89,19 +92,19 @@ def on_vlc_menu_show(self): stream_categories = [ ( - "Audio", + _("Audio"), player.audio_get_track_description, player.audio_get_track, player.audio_set_track, ), ( - "Video", + _("Video"), player.video_get_track_description, player.video_get_track, player.video_set_track, ), ( - "Subtitles", + _("Subtitles"), player.video_get_spu_description, player.video_get_spu, player.video_set_spu, @@ -119,7 +122,7 @@ def on_vlc_menu_show(self): # Ensure an option to disable the stream is always available if not any(tid == -1 for tid, _ in tracks): - tracks.insert(0, (-1, b"Disable")) + tracks.insert(0, (-1, _("Disable").encode("utf-8"))) group = None for track_id, track_name in tracks: From 9c52230138d72c7b906251d0ca6a3e9ebcf3595a Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Sun, 9 Aug 2026 07:59:29 +0200 Subject: [PATCH 11/30] Restore the vlc gui when back from full screen mode --- usr/lib/hypnotix/hypnotix.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index 3b7358fb..d4f96c2a 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -1716,7 +1716,10 @@ def normal_mode(self): self.window.get_window().set_cursor(None) self.window.unfullscreen() self.mpv_top_box.show() - self.mpv_bottom_box.hide() + if self.settings.get_string("video-backend") == "vlc": + self.mpv_bottom_box.show() + else: + self.mpv_bottom_box.hide() if self.content_type == TV_GROUP: self.sidebar.show() self.headerbar.show() From 14f3caf11506406527ac7d2b2684ead91e4f9c49 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Sun, 9 Aug 2026 09:13:01 +0200 Subject: [PATCH 12/30] Determine vlc backend availability only once --- usr/lib/hypnotix/hypnotix.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index d4f96c2a..f1f5542e 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -329,14 +329,24 @@ def __init__(self, application): self.bind_setting_widget("http-referer", self.referer_entry) self.bind_setting_widget("mpv-options", self.mpv_entry) + try: + import vlc + self.is_vlc_available = True + except ImportError: + self.is_vlc_available = False + # Video Backend combo box (in preferences, alongside mpv-options) backend_model = Gtk.ListStore(str, str) backend_model.append(["mpv", _("MPV (Default)")]) - try: - import vlc + if self.is_vlc_available: backend_model.append(["vlc", _("VLC Player")]) - except ImportError: - pass + + # Setup the UI overlays only if VLC is installed + self.vlc_gui = VLCGUIController(self) + self.vlc_gui.setup_ui() + else: + self.vlc_gui = None + self.video_backend_combo.set_model(backend_model) renderer = Gtk.CellRendererText() self.video_backend_combo.pack_start(renderer, True) @@ -1689,15 +1699,10 @@ def reinit_mpv(self): xid = str(self.mpv_drawing_area.get_window().get_xid()) if chosen_backend == "vlc": - try: - if not hasattr(self, "vlc_gui"): - self.vlc_gui = VLCGUIController(self) - self.vlc_gui.setup_ui() - + if getattr(self, "is_vlc_available", False) and self.vlc_gui is not None: self.mpv = VlcEngine(gui=self.vlc_gui) self.vlc_gui.show_controls() - - except ImportError: + else: print("VLC Python bindings missing! Falling back to default MPV.") chosen_backend = "mpv" From 6f49df7448ec0bec99836e46c59e39aa51c7d65f Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Sun, 9 Aug 2026 09:29:13 +0200 Subject: [PATCH 13/30] Fix backend initialization logic and volume control --- usr/lib/hypnotix/hypnotix.py | 40 ++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index f1f5542e..f4099df8 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -1671,29 +1671,10 @@ def on_mpv_drawing_area_realize(self, widget): def reinit_mpv(self): if self.mpv is not None: self.mpv.stop() - options = {} - try: - mpv_options = self.settings.get_string("mpv-options") - if ("=") in mpv_options: - pairs = mpv_options.split() - for pair in pairs: - key, value = pair.split("=", 1) - options[key] = value - except Exception as e: - print("Could not parse MPV options!") - print(e) - - options["user_agent"] = self.settings.get_string("user-agent") - options["referrer"] = self.settings.get_string("http-referer") while not self.mpv_drawing_area.get_window() and not Gtk.events_pending(): time.sleep(0.1) - osc = True - if "osc" in options: - # To prevent 'multiple values for keyword argument'! - osc = options.pop("osc") != "no" - if self.mpv is None: chosen_backend = self.settings.get_string("video-backend") xid = str(self.mpv_drawing_area.get_window().get_xid()) @@ -1707,10 +1688,29 @@ def reinit_mpv(self): chosen_backend = "mpv" if chosen_backend != "vlc": + options = {} + try: + mpv_options = self.settings.get_string("mpv-options") + if ("=") in mpv_options: + pairs = mpv_options.split() + for pair in pairs: + key, value = pair.split("=", 1) + options[key] = value + except Exception as e: + print("Could not parse MPV options!") + print(e) + + osc = True + if "osc" in options: + # To prevent 'multiple values for keyword argument'! + osc = options.pop("osc") != "no" + self.mpv = MpvEngine(options=options, osc=osc) self.mpv.set_window(xid) - self.mpv.volume = self.volume + self.mpv["user-agent"] = self.settings.get_string("user-agent") + self.mpv["referrer"] = self.settings.get_string("http-referer") + self.mpv.set_volume(self.volume) self.mpv.observe_property("volume", self.on_volume_prop) def on_mpv_drawing_area_draw(self, widget, cr): From e49754832c8b45ddd66c9b960d928b368a18e346 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Sun, 9 Aug 2026 13:00:41 +0200 Subject: [PATCH 14/30] Make the vlc ui float over the video show/hide it in full-screen too --- usr/lib/hypnotix/hypnotix.py | 18 ++++--- usr/lib/hypnotix/vlcgui.py | 97 ++++++++++++++++++++++++++++++++---- 2 files changed, 98 insertions(+), 17 deletions(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index f4099df8..91d3c22c 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -1672,10 +1672,13 @@ def reinit_mpv(self): if self.mpv is not None: self.mpv.stop() - while not self.mpv_drawing_area.get_window() and not Gtk.events_pending(): - time.sleep(0.1) - if self.mpv is None: + # Map the player page if not realized yet + if not self.mpv_drawing_area.get_window(): + self.mpv_stack.set_visible_child_name("player_page") + while not self.mpv_drawing_area.get_window(): + time.sleep(0.05) + chosen_backend = self.settings.get_string("video-backend") xid = str(self.mpv_drawing_area.get_window().get_xid()) @@ -1719,12 +1722,11 @@ def on_mpv_drawing_area_draw(self, widget, cr): def normal_mode(self): self.window.get_window().set_cursor(None) + if getattr(self, "vlc_gui", None) is not None: + self.vlc_gui.mouse_cursor_visible = True self.window.unfullscreen() self.mpv_top_box.show() - if self.settings.get_string("video-backend") == "vlc": - self.mpv_bottom_box.show() - else: - self.mpv_bottom_box.hide() + self.mpv_bottom_box.hide() if self.content_type == TV_GROUP: self.sidebar.show() self.headerbar.show() @@ -1760,6 +1762,8 @@ def full_screen_mode(self): self.fullscreen = not self.fullscreen if self.fullscreen: self.window.get_window().set_cursor(Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "none")) + if getattr(self, "vlc_gui", None) is not None: + self.vlc_gui.mouse_cursor_visible = False # Fullscreen mode self.window.fullscreen() self.mpv_top_box.hide() diff --git a/usr/lib/hypnotix/vlcgui.py b/usr/lib/hypnotix/vlcgui.py index 96ca357f..f49febe8 100644 --- a/usr/lib/hypnotix/vlcgui.py +++ b/usr/lib/hypnotix/vlcgui.py @@ -1,7 +1,7 @@ import gi gi.require_version("Gtk", "3.0") -from gi.repository import GLib, Gtk +from gi.repository import GLib, Gtk, Gdk from common import set_playback_button_state import gettext _ = gettext.gettext @@ -16,6 +16,11 @@ def __init__(self, main_window): self.btn_stop = None self.btn_menu = None self.vlc_stream_menu = None + self.hide_timer_id = 0 + self.mouse_cursor_visible = True + + # Cache the hidden cursor to prevent recreating it dynamically on every timeout + self.hidden_cursor = Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "none") def setup_ui(self): if self.vlc_control_layout is not None: @@ -30,7 +35,7 @@ def setup_ui(self): self.btn_toggle = Gtk.Button() self.btn_toggle.set_relief(Gtk.ReliefStyle.NONE) set_playback_button_state(self.btn_toggle, False) - self.btn_toggle.connect("clicked", lambda w: self.on_vlc_toggle_clicked()) + self.btn_toggle.connect("clicked", self.on_vlc_toggle_clicked) self.vlc_control_layout.pack_start(self.btn_toggle, False, False, 5) # THE SIMPLE STOP BUTTON @@ -39,7 +44,7 @@ def setup_ui(self): ) self.btn_stop.set_relief(Gtk.ReliefStyle.NONE) self.btn_stop.set_tooltip_text(_("Stop")) - self.btn_stop.connect("clicked", lambda w: self.win.on_stop_button(None)) + self.btn_stop.connect("clicked", self.win.on_stop_button) self.vlc_control_layout.pack_start(self.btn_stop, False, False, 5) # THE SANDWICH MENU BUTTON (Audio, Video, Subtitle streams) @@ -52,16 +57,89 @@ def setup_ui(self): self.btn_menu.set_direction(Gtk.ArrowType.UP) self.vlc_stream_menu = Gtk.Menu() self.btn_menu.set_popup(self.vlc_stream_menu) - self.vlc_stream_menu.connect("show", lambda w: self.on_vlc_menu_show()) + self.vlc_stream_menu.connect("show", self.on_vlc_menu_show) self.vlc_control_layout.pack_start(self.btn_menu, False, False, 5) - self.win.mpv_bottom_box.pack_start(self.vlc_control_layout, True, True, 5) + css = b""" + #vlc-osd-box { + background-color: transparent; + border-radius: 12px; + padding: 2px 10px; + } + """ + provider = Gtk.CssProvider() + provider.load_from_data(css) + Gtk.StyleContext.add_provider_for_screen( + Gdk.Screen.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + self.vlc_control_layout.set_name("vlc-osd-box") + + # Replace Revealer with an EventBox to completely bypass GTK animation bugs over X11 + self.control_wrapper = Gtk.EventBox() + self.control_wrapper.add(self.vlc_control_layout) + self.control_wrapper.set_valign(Gtk.Align.END) + self.control_wrapper.set_halign(Gtk.Align.CENTER) + self.control_wrapper.set_margin_bottom(40) + + # Reparent mpv_drawing_area to support the overlay + self.overlay = Gtk.Overlay() + + parent_stack = self.win.mpv_drawing_area.get_parent() + parent_stack.remove(self.win.mpv_drawing_area) + + self.overlay.add(self.win.mpv_drawing_area) + self.overlay.add_overlay(self.control_wrapper) + + # Track mouse only in video drawing area + self.win.mpv_drawing_area.add_events(Gdk.EventMask.POINTER_MOTION_MASK) + self.win.mpv_drawing_area.connect("motion-notify-event", self.on_mouse_motion) + + parent_stack.add_named(self.overlay, "player_page") + parent_stack.set_visible_child_name("player_page") + parent_stack.show_all() + self.set_controls_sensitive(False) def show_controls(self): - GLib.idle_add(self.win.mpv_bottom_box.show_all) + GLib.idle_add(self.on_mouse_motion, None, None) + + def on_mouse_motion(self, widget, event): + if self.win.settings.get_string("video-backend") != "vlc": + return False + + if not self.control_wrapper.get_visible(): + self.control_wrapper.show() + + # Restore the cursor dynamically while the mouse is moving in fullscreen + if self.win.fullscreen and not self.mouse_cursor_visible: + gdk_win = self.win.window.get_window() + if gdk_win: + gdk_win.set_cursor(None) + self.mouse_cursor_visible = True + + if self.hide_timer_id > 0: + GLib.source_remove(self.hide_timer_id) + self.hide_timer_id = GLib.timeout_add(2000, self.hide_controls) + return False + + def hide_controls(self): + # Prevent hiding if the streams menu is currently open + if self.btn_menu.get_active(): + return True + + self.control_wrapper.hide() + self.hide_timer_id = 0 + + # Hide the cursor entirely if we are in fullscreen mode + if self.win.fullscreen: + gdk_win = self.win.window.get_window() + if gdk_win: + gdk_win.set_cursor(self.hidden_cursor) + self.mouse_cursor_visible = False + + return False - def on_vlc_toggle_clicked(self): + def on_vlc_toggle_clicked(self, *args): if not self.win.mpv: return @@ -81,7 +159,7 @@ def _update(): GLib.idle_add(_update) - def on_vlc_menu_show(self): + def on_vlc_menu_show(self, *args): if not self.win.mpv or not hasattr(self.win.mpv, "player"): return @@ -136,8 +214,7 @@ def on_vlc_menu_show(self): if group is None: group = item - if track_id == current_track_id: - item.set_active(True) + item.set_active(track_id == current_track_id) item.connect( "activate", From 09922132c7bee5eb0b72a4040362d6080400610f Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Sun, 9 Aug 2026 21:36:47 +0200 Subject: [PATCH 15/30] Don't draw vlc interface if mpv backed is used --- usr/lib/hypnotix/vlcgui.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/usr/lib/hypnotix/vlcgui.py b/usr/lib/hypnotix/vlcgui.py index f49febe8..b21144c7 100644 --- a/usr/lib/hypnotix/vlcgui.py +++ b/usr/lib/hypnotix/vlcgui.py @@ -99,12 +99,15 @@ def setup_ui(self): parent_stack.show_all() self.set_controls_sensitive(False) + self.control_wrapper.hide() def show_controls(self): GLib.idle_add(self.on_mouse_motion, None, None) def on_mouse_motion(self, widget, event): if self.win.settings.get_string("video-backend") != "vlc": + if self.control_wrapper.get_visible(): + self.control_wrapper.hide() return False if not self.control_wrapper.get_visible(): From 4a6840d705df42fc33b39854544e2c75fb04cb3e Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Sun, 9 Aug 2026 21:39:08 +0200 Subject: [PATCH 16/30] A trace if no channel is currently selected --- usr/lib/hypnotix/hypnotix.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index 91d3c22c..4c2e3206 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -893,8 +893,9 @@ def open_keyboard_shortcuts(self, widget): window.show() def on_favorite_button_toggled(self, widget): - if self.page_is_loading: + if self.page_is_loading or self.active_channel is None: return + name = self.active_channel.name data = f"{self.active_channel.info}:::{self.active_channel.url}" if widget.get_active() and data not in self.favorite_data: From d3fc0faee28e83a4f85d1f2a9c0d97f036db11f9 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Sat, 8 Aug 2026 16:23:17 +0200 Subject: [PATCH 17/30] Fix the switch button for yt-dlp --- usr/lib/hypnotix/hypnotix.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index 4c2e3206..bd44dbed 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -365,6 +365,8 @@ def __init__(self, application): # ytdlp self.ytdlp_local_switch.set_active(self.settings.get_boolean("use-local-ytdlp")) self.ytdlp_local_switch.connect("notify::active", self.on_ytdlp_local_switch_activated) + self.ytdlp_local_switch.set_valign(Gtk.Align.CENTER) + self.ytdlp_local_switch.set_halign(Gtk.Align.START) self.ytdlp_system_version_label.set_text(subprocess.getoutput("/usr/bin/yt-dlp --version")) if os.path.exists(os.path.expanduser("~/.cache/hypnotix/yt-dlp/yt-dlp")): self.ytdlp_local_version_label.set_text(subprocess.getoutput("~/.cache/hypnotix/yt-dlp/yt-dlp --version")) @@ -693,6 +695,7 @@ def on_ytdlp_local_switch_activated(self, widget, data=None): if widget.get_active(): self.update_ytdlp() + @async_function def update_ytdlp(self, widget=None): path = os.path.expanduser("~/.cache/hypnotix/yt-dlp") os.chdir(path) @@ -701,7 +704,8 @@ def update_ytdlp(self, widget=None): else: subprocess.getoutput("wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp") subprocess.getoutput("chmod a+rx ./yt-dlp") - self.ytdlp_local_version_label.set_text(subprocess.getoutput("~/.cache/hypnotix/yt-dlp/yt-dlp --version")) + new_version = subprocess.getoutput("~/.cache/hypnotix/yt-dlp/yt-dlp --version") + GLib.idle_add(self.ytdlp_local_version_label.set_text, new_version) @async_function def download_channel_logos(self, logos_to_refresh): From fe1f531c913c3529f3f5719a70f15416a3829b57 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Sun, 9 Aug 2026 22:29:34 +0200 Subject: [PATCH 18/30] Initial implementation of OSD for vlc backend --- usr/lib/hypnotix/hypnotix.py | 2 +- usr/lib/hypnotix/player.py | 42 +++++++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index bd44dbed..b47cc16c 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -1543,7 +1543,7 @@ def on_key_press_event(self, widget, event): return True elif not event.keyval in [Gdk.KEY_F1, Gdk.KEY_F2]: try: - self.mpv.command("keypress", Gdk.keyval_name(event.keyval)) + self.mpv.send_keypress(Gdk.keyval_name(event.keyval)) except: pass return True diff --git a/usr/lib/hypnotix/player.py b/usr/lib/hypnotix/player.py index a3382a62..c02e84c8 100644 --- a/usr/lib/hypnotix/player.py +++ b/usr/lib/hypnotix/player.py @@ -40,6 +40,14 @@ def observe_property(self, name, callback): def register_event_cb(self, callback): pass + @abc.abstractmethod + def show_osd_text(self, text: str, duration_ms: int = 6000): + pass + + @abc.abstractmethod + def send_keypress(self, key_name: str): + pass + @property @abc.abstractmethod def pause(self) -> bool: @@ -108,6 +116,12 @@ def observe_property(self, name, callback): def register_event_cb(self, callback): self.player.register_event_cb(callback) + def show_osd_text(self, text: str, duration_ms: int = 6000): + self.player.command("show-text", text, duration_ms) + + def send_keypress(self, key_name: str): + self.player.command("keypress", key_name) + def __setitem__(self, key, value): self.player[key] = value @@ -125,7 +139,8 @@ def __init__(self, gui=None): import vlc self.gui = gui - self.instance = vlc.Instance("--no-xlib --quiet --no-video-title-show") + # Enable marquee and configure its text renderer (freetype) to match MPV's default styling + self.instance = vlc.Instance("--no-xlib --quiet --no-video-title-show --sub-source=marq --freetype-font=sans-serif --freetype-outline-thickness=2") self.player = self.instance.media_player_new() # Instruct the video surface wrapper to ignore inputs, @@ -196,6 +211,31 @@ def observe_property(self, name, callback): def register_event_cb(self, callback): pass + def show_osd_text(self, text: str, duration_ms: int = 6000): + import vlc + if text: + self.player.video_set_marquee_int(vlc.VideoMarqueeOption.Enable, 1) + self.player.video_set_marquee_string(vlc.VideoMarqueeOption.Text, text) + self.player.video_set_marquee_int(vlc.VideoMarqueeOption.Timeout, duration_ms) + self.player.video_set_marquee_int(vlc.VideoMarqueeOption.Position, 5) # 5 = Top-Left (1=Left + 4=Top) + self.player.video_set_marquee_int(vlc.VideoMarqueeOption.Size, 50) # Match MPV's default 50px OSD size + else: + self.player.video_set_marquee_int(vlc.VideoMarqueeOption.Enable, 0) + + def send_keypress(self, key_name: str): + # Basic VLC key mapping since it lacks a native keypress injector + key = key_name.lower() + if key == "space": + self.pause = not self.pause + elif key == "right": + # Seek forward 10 seconds + self.player.set_time(self.player.get_time() + 10000) + elif key == "left": + # Seek backward 10 seconds + self.player.set_time(max(0, self.player.get_time() - 10000)) + elif key == "m": + self.player.audio_toggle_mute() + def __setitem__(self, key, value): if key == "user-agent": self._user_agent = value From d2da28a972dde125f2c8b2503e99e63574417617 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Sun, 9 Aug 2026 21:24:57 +0200 Subject: [PATCH 19/30] Get the OSD text size according to the stream size --- usr/lib/hypnotix/player.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/usr/lib/hypnotix/player.py b/usr/lib/hypnotix/player.py index c02e84c8..65d6867c 100644 --- a/usr/lib/hypnotix/player.py +++ b/usr/lib/hypnotix/player.py @@ -212,13 +212,26 @@ def register_event_cb(self, callback): pass def show_osd_text(self, text: str, duration_ms: int = 6000): + if not self.player: + return import vlc if text: self.player.video_set_marquee_int(vlc.VideoMarqueeOption.Enable, 1) - self.player.video_set_marquee_string(vlc.VideoMarqueeOption.Text, text) + + # Get the native video height and scale the text to ~5% of the screen + height = self.player.video_get_height() + font_size = int(height * 0.05) if height > 0 else 50 + + # Set a minimum floor so it never gets unreadable on tiny streams + font_size = max(24, font_size) + + # VLC's Linux text renderer fails to calculate line widths properly + # if the string only contains standard Unix newline characters + formatted_text = text.replace('\n', '\r\n') + self.player.video_set_marquee_string(vlc.VideoMarqueeOption.Text, formatted_text) self.player.video_set_marquee_int(vlc.VideoMarqueeOption.Timeout, duration_ms) self.player.video_set_marquee_int(vlc.VideoMarqueeOption.Position, 5) # 5 = Top-Left (1=Left + 4=Top) - self.player.video_set_marquee_int(vlc.VideoMarqueeOption.Size, 50) # Match MPV's default 50px OSD size + self.player.video_set_marquee_int(vlc.VideoMarqueeOption.Size, font_size) else: self.player.video_set_marquee_int(vlc.VideoMarqueeOption.Enable, 0) From 5718224b3ae188365312beeee61694ebc82e7f9b Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Sun, 9 Aug 2026 22:36:16 +0200 Subject: [PATCH 20/30] Fix race condition --- usr/lib/hypnotix/hypnotix.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index b47cc16c..4b6208a5 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -927,18 +927,29 @@ def on_next_channel(self): @async_function def play_async(self, channel): - if self.mpv is not None: - self.mpv.stop() - self.mpv.pause = False + try: + if self.mpv is not None: + self.mpv.show_osd_text("", 1) + self.mpv.stop() + self.mpv.pause = False + except Exception: + pass + print("CHANNEL: '%s' (%s)" % (channel.name, channel.url)) + 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) self.before_play(channel) - self.reinit_mpv() - self.mpv.play(channel.url) - self.mpv.wait_until_playing() + + try: + self.reinit_mpv() + self.mpv.play(channel.url) + self.mpv.wait_until_playing() + except Exception as e: + # Silently catch ShutdownError if the user clicked a new channel + # or closed the app before this thread finished loading. + print(f"Playback interrupted or stopped: {e}") + return + self.after_play(channel) @idle_function @@ -971,6 +982,7 @@ def before_play(self, channel): self.favorite_button_image.set_from_icon_name("xsi-non-starred-symbolic", Gtk.IconSize.BUTTON) self.favorite_button.set_tooltip_text(_("Add to favorites")) set_playback_button_state(self.pause_button, False) + self.info_menu_item.set_sensitive(False) self.page_is_loading = False @idle_function From 19f74900f2277717e13a5bf17578972eb68d5305 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Sun, 9 Aug 2026 22:03:26 +0200 Subject: [PATCH 21/30] Fix the Add to/Remove from favourites icon --- usr/lib/hypnotix/hypnotix.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index 4b6208a5..cbf78104 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -897,7 +897,7 @@ def open_keyboard_shortcuts(self, widget): window.show() def on_favorite_button_toggled(self, widget): - if self.page_is_loading or self.active_channel is None: + if self.page_is_loading or getattr(self, "active_channel", None) is None: return name = self.active_channel.name @@ -905,10 +905,18 @@ def on_favorite_button_toggled(self, widget): if widget.get_active() and data not in self.favorite_data: print (f"Adding {name} to favorites") self.favorite_data.append(data) + + # Dynamically update the tooltip + self.favorite_button.set_tooltip_text(_("Remove from favorites")) + elif widget.get_active() == False and data in self.favorite_data: print (f"Removing {name} from favorites") self.favorite_data.remove(data) - self.favorite_button_image.set_from_icon_name("xsi-starred-symbolic" if widget.get_active() else "non-xsi-starred-symbolic", Gtk.IconSize.BUTTON) + + # Dynamically update the tooltip + self.favorite_button.set_tooltip_text(_("Add to favorites")) + + self.favorite_button_image.set_from_icon_name("xsi-starred-symbolic" if widget.get_active() else "xsi-non-starred-symbolic", Gtk.IconSize.BUTTON) self.manager.save_favorites(self.favorite_data) def on_channel_activated(self, box, widget): @@ -972,8 +980,12 @@ def before_play(self, channel): self.label_channel_url.set_text(channel.url) self.page_is_loading = True - data = f"{channel.info}:::{channel.url}" - if data in self.favorite_data: + + # Use prefix matching to ignore appended EPG/XMLTV metadata + data_prefix = f"{channel.info}:::{channel.url}" + existing_match = next((fav for fav in self.favorite_data if fav == data_prefix or fav.startswith(data_prefix + ":::")), None) + + if existing_match: self.favorite_button.set_active(True) self.favorite_button_image.set_from_icon_name("xsi-starred-symbolic", Gtk.IconSize.BUTTON) self.favorite_button.set_tooltip_text(_("Remove from favorites")) From 1d29384d8a339c4047a9867e59d04d1a65e2233f Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Mon, 10 Aug 2026 11:10:43 +0200 Subject: [PATCH 22/30] Clear the play page when channel is stopped --- usr/lib/hypnotix/hypnotix.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index cbf78104..a7b106ff 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -574,6 +574,13 @@ def show_channels(self, channels, favorites=False): self.channels_listbox.add(ChannelWidget(channel, image)) self.channels_listbox.show_all() + + if self.active_channel is None: + self.channel_stack.set_visible_child_name("empty_page") + self.label_channel_name.set_text("") + self.label_channel_url.set_text("") + + self.update_epg_labels() self.visible_search_results = len(self.channels_listbox.get_children()) if len(logos_to_refresh) > 0: self.download_channel_logos(logos_to_refresh) @@ -1115,11 +1122,15 @@ def on_audio_codec(self, property, codec): self.audio_properties[_("General")][_("Codec")] = codec.split()[0] def on_stop_button(self, widget): - self.mpv.stop() + if self.mpv is not None: + self.mpv.stop() # self.mpv_drawing_area.hide() self.active_channel = None self.info_menu_item.set_sensitive(False) self.playback_bar.hide() + self.label_channel_name.set_text("") + self.label_channel_url.set_text("") + self.channel_stack.set_visible_child_name("empty_page") def on_pause_button(self, widget): self.mpv.pause = not self.mpv.pause From 927724010895d3764c8ced1800e424fa649834a7 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Mon, 10 Aug 2026 11:23:27 +0200 Subject: [PATCH 23/30] Try to match the OSD sizes between mpv and vlc --- usr/lib/hypnotix/player.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/usr/lib/hypnotix/player.py b/usr/lib/hypnotix/player.py index 65d6867c..4a8e185e 100644 --- a/usr/lib/hypnotix/player.py +++ b/usr/lib/hypnotix/player.py @@ -218,12 +218,13 @@ def show_osd_text(self, text: str, duration_ms: int = 6000): if text: self.player.video_set_marquee_int(vlc.VideoMarqueeOption.Enable, 1) - # Get the native video height and scale the text to ~5% of the screen + # Scale font size against a 720p base. Base 42 matches MPV's libass + # point size rendering visually in VLC freetype. height = self.player.video_get_height() - font_size = int(height * 0.05) if height > 0 else 50 + font_size = int((height / 720.0) * 42) if height > 0 else 42 # Set a minimum floor so it never gets unreadable on tiny streams - font_size = max(24, font_size) + font_size = max(16, font_size) # VLC's Linux text renderer fails to calculate line widths properly # if the string only contains standard Unix newline characters From 66b3b86fb830b1a523f63e3def91ce7e4a78b6c5 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Mon, 10 Aug 2026 15:21:37 +0200 Subject: [PATCH 24/30] Fix UI playback state when stopping a paused stream in MPV --- usr/lib/hypnotix/hypnotix.py | 56 ++++++++++++++++++++++++------------ usr/lib/hypnotix/player.py | 27 +++++++++++++++++ 2 files changed, 64 insertions(+), 19 deletions(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index a7b106ff..bb566e4f 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -140,6 +140,7 @@ def __init__(self, application): self.latest_search_bar_text = None self.visible_search_results = 0 self.mpv = None + self.is_changing_channel = False self.page_is_loading = False # used to ignore signals while we set widget states self.video_properties = {} @@ -942,30 +943,34 @@ def on_next_channel(self): @async_function def play_async(self, channel): + self.is_changing_channel = True try: - if self.mpv is not None: - self.mpv.show_osd_text("", 1) - self.mpv.stop() - self.mpv.pause = False - except Exception: - pass + try: + if self.mpv is not None: + self.mpv.show_osd_text("", 1) + self.mpv.stop() + self.mpv.pause = False + except Exception: + pass - print("CHANNEL: '%s' (%s)" % (channel.name, channel.url)) + print("CHANNEL: '%s' (%s)" % (channel.name, channel.url)) - if channel is not None and channel.url is not None: - self.before_play(channel) + if channel is not None and channel.url is not None: + self.before_play(channel) - try: - self.reinit_mpv() - self.mpv.play(channel.url) - self.mpv.wait_until_playing() - except Exception as e: - # Silently catch ShutdownError if the user clicked a new channel - # or closed the app before this thread finished loading. - print(f"Playback interrupted or stopped: {e}") - return + try: + self.reinit_mpv() + self.mpv.play(channel.url) + self.mpv.wait_until_playing() + except Exception as e: + # Silently catch ShutdownError if the user clicked a new channel + # or closed the app before this thread finished loading. + print(f"Playback interrupted or stopped: {e}") + return - self.after_play(channel) + self.after_play(channel) + finally: + self.is_changing_channel = False @idle_function def before_play(self, channel): @@ -1021,6 +1026,7 @@ def monitor_playback(self): self.mpv.unobserve_property("video-bitrate", self.on_bitrate) self.mpv.unobserve_property("audio-bitrate", self.on_bitrate) self.mpv.unobserve_property("core-idle", self.on_playback_changed) + self.mpv.unobserve_property("idle-active", self.on_idle_active) except: pass self.mpv.observe_property("video-params", self.on_video_params) @@ -1030,6 +1036,13 @@ def monitor_playback(self): self.mpv.observe_property("video-bitrate", self.on_bitrate) self.mpv.observe_property("audio-bitrate", self.on_bitrate) self.mpv.observe_property("core-idle", self.on_playback_changed) + self.mpv.observe_property("idle-active", self.on_idle_active) + + @idle_function + def on_idle_active(self, prop, active): + if active and not self.is_changing_channel and self.active_channel is not None: + # Catch stops that occur while paused (core-idle is already True) + self.on_stop_button(None) @idle_function def on_playback_changed(self, prop, idle): @@ -1037,6 +1050,11 @@ def on_playback_changed(self, prop, idle): if self.inhibit_id != 0: self.application.uninhibit(self.inhibit_id) self.inhibit_id = 0 + + # Handle MPV fully stopping (e.g., from OSD or EOF) versus pausing + if getattr(self.mpv, 'idle_active', False): + if not self.is_changing_channel and self.active_channel is not None: + self.on_stop_button(None) else: if self.inhibit_id == 0: self.inhibit_id = self.application.inhibit( diff --git a/usr/lib/hypnotix/player.py b/usr/lib/hypnotix/player.py index 4a8e185e..98990891 100644 --- a/usr/lib/hypnotix/player.py +++ b/usr/lib/hypnotix/player.py @@ -36,6 +36,10 @@ def wait_until_playing(self): def observe_property(self, name, callback): pass + @abc.abstractmethod + def unobserve_property(self, name, callback): + pass + @abc.abstractmethod def register_event_cb(self, callback): pass @@ -53,6 +57,11 @@ def send_keypress(self, key_name: str): def pause(self) -> bool: pass + @property + @abc.abstractmethod + def idle_active(self) -> bool: + pass + @pause.setter @abc.abstractmethod def pause(self, value: bool): @@ -113,6 +122,12 @@ def wait_until_playing(self): def observe_property(self, name, callback): self.player.observe_property(name, callback) + def unobserve_property(self, name, callback): + try: + self.player.unobserve_property(name, callback) + except Exception: + pass + def register_event_cb(self, callback): self.player.register_event_cb(callback) @@ -129,6 +144,10 @@ def __setitem__(self, key, value): def pause(self) -> bool: return getattr(self.player, "pause", False) + @property + def idle_active(self) -> bool: + return getattr(self.player, "idle_active", False) + @pause.setter def pause(self, value: bool): self.player.pause = bool(value) @@ -208,6 +227,9 @@ def wait_until_playing(self): def observe_property(self, name, callback): pass + def unobserve_property(self, name, callback): + pass + def register_event_cb(self, callback): pass @@ -260,6 +282,11 @@ def __setitem__(self, key, value): def pause(self) -> bool: return self.player.get_rate() == 0.0 + @property + def idle_active(self) -> bool: + # VLC doesn't use the MPV-style idle property mechanism for its UI logic + return False + @pause.setter def pause(self, value: bool): if value: From bd4552cbb5f8e3de73189c3a38b072bd93efad77 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Tue, 11 Aug 2026 13:29:29 +0200 Subject: [PATCH 25/30] prevent infinite hang during MPV initialization --- usr/lib/hypnotix/hypnotix.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index bb566e4f..f5102d7f 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -1733,9 +1733,11 @@ def reinit_mpv(self): if self.mpv is None: # Map the player page if not realized yet if not self.mpv_drawing_area.get_window(): - self.mpv_stack.set_visible_child_name("player_page") - while not self.mpv_drawing_area.get_window(): + GLib.idle_add(self.mpv_drawing_area.realize) + timeout = 100 + while not self.mpv_drawing_area.get_window() and timeout > 0: time.sleep(0.05) + timeout -= 1 chosen_backend = self.settings.get_string("video-backend") xid = str(self.mpv_drawing_area.get_window().get_xid()) From 1580e89f456c51b94ad1afab66985c8653428797 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Tue, 11 Aug 2026 22:25:44 +0200 Subject: [PATCH 26/30] Implement hardware polling for reliable fullscreen cursor auto-hide --- usr/lib/hypnotix/hypnotix.py | 82 ++++++++++++++++++++++++++++++++++-- usr/lib/hypnotix/vlcgui.py | 18 -------- 2 files changed, 79 insertions(+), 21 deletions(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index f5102d7f..db04a665 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -143,6 +143,9 @@ def __init__(self, application): self.is_changing_channel = False self.page_is_loading = False # used to ignore signals while we set widget states + self.cursor_hide_timer_id = 0 + self.mouse_poll_timer_id = 0 + self.video_properties = {} self.audio_properties = {} self.volume = 100 @@ -975,6 +978,11 @@ def play_async(self, channel): @idle_function def before_play(self, channel): self.channel_stack.set_visible_child_name("channel_page") + if self.fullscreen: + self.sidebar.hide() + if getattr(self, "cursor_hide_timer_id", 0) > 0: + GLib.source_remove(self.cursor_hide_timer_id) + self.cursor_hide_timer_id = GLib.timeout_add(2000, self.hide_cursor) self.mpv_stack.set_visible_child_name("spinner_page") self.video_properties.clear() self.video_properties[_("General")] = {} @@ -1149,6 +1157,17 @@ def on_stop_button(self, widget): self.label_channel_name.set_text("") self.label_channel_url.set_text("") self.channel_stack.set_visible_child_name("empty_page") + # if self.fullscreen and self.content_type == TV_GROUP: + # self.sidebar.show() + if self.fullscreen: + gdk_win = self.window.get_window() + if gdk_win: + gdk_win.set_cursor(None) + if getattr(self, "cursor_hide_timer_id", 0) > 0: + GLib.source_remove(self.cursor_hide_timer_id) + self.cursor_hide_timer_id = 0 + if self.content_type == TV_GROUP: + self.sidebar.show() def on_pause_button(self, widget): self.mpv.pause = not self.mpv.pause @@ -1782,6 +1801,12 @@ def on_mpv_drawing_area_draw(self, widget, cr): def normal_mode(self): self.window.get_window().set_cursor(None) + if getattr(self, "cursor_hide_timer_id", 0) > 0: + GLib.source_remove(self.cursor_hide_timer_id) + self.cursor_hide_timer_id = 0 + if getattr(self, "mouse_poll_timer_id", 0) > 0: + GLib.source_remove(self.mouse_poll_timer_id) + self.mouse_poll_timer_id = 0 if getattr(self, "vlc_gui", None) is not None: self.vlc_gui.mouse_cursor_visible = True self.window.unfullscreen() @@ -1821,14 +1846,18 @@ def full_screen_mode(self): if self.stack.get_visible_child_name() == "channels_page": self.fullscreen = not self.fullscreen if self.fullscreen: - self.window.get_window().set_cursor(Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "none")) - if getattr(self, "vlc_gui", None) is not None: - self.vlc_gui.mouse_cursor_visible = False # Fullscreen mode self.window.fullscreen() self.mpv_top_box.hide() self.mpv_bottom_box.hide() + if not getattr(self, "mouse_poll_timer_id", 0): + self.last_mouse_pos = None + self.mouse_poll_timer_id = GLib.timeout_add(200, self.poll_mouse_position) self.sidebar.hide() + if self.active_channel is not None: + if getattr(self, "cursor_hide_timer_id", 0) > 0: + GLib.source_remove(self.cursor_hide_timer_id) + self.cursor_hide_timer_id = GLib.timeout_add(2000, self.hide_cursor) self.headerbar.hide() self.status_label.hide() self.channels_box.set_border_width(0) @@ -1844,6 +1873,53 @@ def on_close_info_window_button_clicked(self, widget): def on_volume_prop(self, name, value ): self.volume = value + def poll_mouse_position(self): + if not self.fullscreen: + self.mouse_poll_timer_id = 0 + return False + + if getattr(self, "active_channel", None) is not None: + display = Gdk.Display.get_default() + seat = display.get_default_seat() + if seat: + pointer = seat.get_pointer() + screen, x, y = pointer.get_position() + if getattr(self, "last_mouse_pos", None) != (x, y): + self.last_mouse_pos = (x, y) + + # Wake up cursor for the main window + gdk_win = self.window.get_window() + if gdk_win: + gdk_win.set_cursor(None) + + # Wake up cursor for the MPV drawing area + draw_win = self.mpv_drawing_area.get_window() + if draw_win: + draw_win.set_cursor(None) + + # Reset the hide countdown + if getattr(self, "cursor_hide_timer_id", 0) > 0: + GLib.source_remove(self.cursor_hide_timer_id) + self.cursor_hide_timer_id = GLib.timeout_add(2000, self.hide_cursor) + return True + + def hide_cursor(self): + if self.fullscreen and getattr(self, "active_channel", None) is not None: + hidden_cursor = Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "none") + + # Hide cursor for the main window + gdk_win = self.window.get_window() + if gdk_win: + gdk_win.set_cursor(hidden_cursor) + + # Hide cursor for the MPV drawing area + draw_win = self.mpv_drawing_area.get_window() + if draw_win: + draw_win.set_cursor(hidden_cursor) + + self.cursor_hide_timer_id = 0 + return False + if __name__ == "__main__": application = MyApplication("org.x.hypnotix", Gio.ApplicationFlags.FLAGS_NONE) application.run() diff --git a/usr/lib/hypnotix/vlcgui.py b/usr/lib/hypnotix/vlcgui.py index b21144c7..ee1b4636 100644 --- a/usr/lib/hypnotix/vlcgui.py +++ b/usr/lib/hypnotix/vlcgui.py @@ -17,10 +17,6 @@ def __init__(self, main_window): self.btn_menu = None self.vlc_stream_menu = None self.hide_timer_id = 0 - self.mouse_cursor_visible = True - - # Cache the hidden cursor to prevent recreating it dynamically on every timeout - self.hidden_cursor = Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "none") def setup_ui(self): if self.vlc_control_layout is not None: @@ -113,13 +109,6 @@ def on_mouse_motion(self, widget, event): if not self.control_wrapper.get_visible(): self.control_wrapper.show() - # Restore the cursor dynamically while the mouse is moving in fullscreen - if self.win.fullscreen and not self.mouse_cursor_visible: - gdk_win = self.win.window.get_window() - if gdk_win: - gdk_win.set_cursor(None) - self.mouse_cursor_visible = True - if self.hide_timer_id > 0: GLib.source_remove(self.hide_timer_id) self.hide_timer_id = GLib.timeout_add(2000, self.hide_controls) @@ -133,13 +122,6 @@ def hide_controls(self): self.control_wrapper.hide() self.hide_timer_id = 0 - # Hide the cursor entirely if we are in fullscreen mode - if self.win.fullscreen: - gdk_win = self.win.window.get_window() - if gdk_win: - gdk_win.set_cursor(self.hidden_cursor) - self.mouse_cursor_visible = False - return False def on_vlc_toggle_clicked(self, *args): From b5ec94f1adf611d30017bf13430b48dd76a93857 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Wed, 12 Aug 2026 15:37:53 +0200 Subject: [PATCH 27/30] Make mpv backend optional too --- usr/lib/hypnotix/hypnotix.py | 49 ++++++++++++++++++++++++------------ usr/lib/hypnotix/player.py | 24 +++++++++++------- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index db04a665..9b6a1029 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -24,8 +24,6 @@ gi.require_version("XApp", "1.0") from gi.repository import Gtk, Gdk, Gio, XApp, GdkPixbuf, GLib, Pango -from player import MpvEngine, VlcEngine -from vlcgui import VLCGUIController import requests import setproctitle from unidecode import unidecode @@ -333,15 +331,21 @@ def __init__(self, application): self.bind_setting_widget("http-referer", self.referer_entry) self.bind_setting_widget("mpv-options", self.mpv_entry) - try: - import vlc - self.is_vlc_available = True - except ImportError: - self.is_vlc_available = False + import player + self.is_mpv_available = player.mpv is not None + self.is_vlc_available = player.vlc is not None + + if not self.is_mpv_available and not self.is_vlc_available: + print("Error: Neither MPV nor VLC backend is available. Please install at least one.", file=sys.stderr) + sys.exit(1) + + if self.is_vlc_available: + from vlcgui import VLCGUIController # Video Backend combo box (in preferences, alongside mpv-options) backend_model = Gtk.ListStore(str, str) - backend_model.append(["mpv", _("MPV (Default)")]) + if self.is_mpv_available: + backend_model.append(["mpv", _("MPV (Default)")]) if self.is_vlc_available: backend_model.append(["vlc", _("VLC Player")]) @@ -1759,17 +1763,29 @@ def reinit_mpv(self): timeout -= 1 chosen_backend = self.settings.get_string("video-backend") - xid = str(self.mpv_drawing_area.get_window().get_xid()) - if chosen_backend == "vlc": - if getattr(self, "is_vlc_available", False) and self.vlc_gui is not None: - self.mpv = VlcEngine(gui=self.vlc_gui) - self.vlc_gui.show_controls() - else: - print("VLC Python bindings missing! Falling back to default MPV.") + if self.is_mpv_available and self.is_vlc_available: + if chosen_backend not in ["mpv", "vlc"]: chosen_backend = "mpv" + elif self.is_mpv_available: + if chosen_backend == "vlc": + print("VLC backend is not available! Falling back to MPV.") + chosen_backend = "mpv" + elif self.is_vlc_available: + if chosen_backend == "mpv": + print("MPV backend is not available! Falling back to VLC.") + chosen_backend = "vlc" + else: + print("Error: No media backend is available!", file=sys.stderr) + sys.exit(1) + + xid = str(self.mpv_drawing_area.get_window().get_xid()) - if chosen_backend != "vlc": + if chosen_backend == "vlc": + from player import VlcEngine + self.mpv = VlcEngine(gui=self.vlc_gui) + self.vlc_gui.show_controls() + else: options = {} try: mpv_options = self.settings.get_string("mpv-options") @@ -1787,6 +1803,7 @@ def reinit_mpv(self): # To prevent 'multiple values for keyword argument'! osc = options.pop("osc") != "no" + from player import MpvEngine self.mpv = MpvEngine(options=options, osc=osc) self.mpv.set_window(xid) diff --git a/usr/lib/hypnotix/player.py b/usr/lib/hypnotix/player.py index 98990891..6a881ae6 100644 --- a/usr/lib/hypnotix/player.py +++ b/usr/lib/hypnotix/player.py @@ -1,6 +1,18 @@ import abc import time +vlc = None +try: + import vlc +except (ImportError, OSError): + pass + +mpv = None +try: + import mpv +except (ImportError, OSError): + pass + class VideoPlayer(abc.ABC): """Abstract base class defining the media player engine interface.""" @@ -70,14 +82,12 @@ def pause(self, value: bool): class MpvEngine(VideoPlayer): def __init__(self, options=None, osc=True): - try: - from . import mpv as hypnotix_mpv - except ImportError: - import mpv as hypnotix_mpv + if mpv is None: + raise ImportError("mpv library not found") mpv_options = options if options is not None else {} - self.player = hypnotix_mpv.MPV( + self.player = mpv.MPV( **mpv_options, script_opts="osc-layout=box,osc-seekbarstyle=bar,osc-deadzonesize=0,osc-minmousemove=3", input_default_bindings=True, @@ -155,7 +165,6 @@ def pause(self, value: bool): class VlcEngine(VideoPlayer): def __init__(self, gui=None): - import vlc self.gui = gui # Enable marquee and configure its text renderer (freetype) to match MPV's default styling @@ -208,8 +217,6 @@ def set_volume(self, value): self.player.audio_set_volume(int(value)) def is_playing(self) -> bool: - import vlc - return self.player.get_state() in [vlc.State.Playing, vlc.State.Buffering] def wait_until_playing(self): @@ -236,7 +243,6 @@ def register_event_cb(self, callback): def show_osd_text(self, text: str, duration_ms: int = 6000): if not self.player: return - import vlc if text: self.player.video_set_marquee_int(vlc.VideoMarqueeOption.Enable, 1) From 05c8a985b0a55fb1078554c7447a124a6a6a1749 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Wed, 12 Aug 2026 16:33:18 +0200 Subject: [PATCH 28/30] Use dynamic resource paths relative to the script location --- usr/lib/hypnotix/hypnotix.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index 9b6a1029..f2b235de 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -34,9 +34,14 @@ setproctitle.setproctitle("hypnotix") +# Setup dynamic paths based on the script's location +SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__)) +PREFIX = os.path.abspath(os.path.join(SCRIPT_DIR, "../../")) +PKG_DATA_DIR = os.path.join(PREFIX, "share", "hypnotix") +LOCALE_DIR = os.path.join(PREFIX, "share", "locale") + # i18n APP = "hypnotix" -LOCALE_DIR = "/usr/share/locale" locale.bindtextdomain(APP, LOCALE_DIR) gettext.bindtextdomain(APP, LOCALE_DIR) gettext.textdomain(APP) @@ -70,7 +75,7 @@ } COUNTRY_CODES = {} -with open("/usr/share/hypnotix/countries.list") as f: +with open(os.path.join(PKG_DATA_DIR, "countries.list")) as f: for line in f: line = line.strip() code, name = line.split(":") @@ -151,7 +156,7 @@ def __init__(self, application): # Used for redownloading timer self.reload_timeout_sec = 60 * 5 self._timerid = -1 - gladefile = "/usr/share/hypnotix/hypnotix.ui" + gladefile = os.path.join(PKG_DATA_DIR, "hypnotix.ui") self.builder = Gtk.Builder() self.builder.set_translation_domain(APP) self.builder.add_from_file(gladefile) @@ -163,7 +168,7 @@ def __init__(self, application): self.info_window = self.builder.get_object("stream_info_window") provider = Gtk.CssProvider() - provider.load_from_path("/usr/share/hypnotix/hypnotix.css") + provider.load_from_path(os.path.join(PKG_DATA_DIR, "hypnotix.css")) screen = Gdk.Display.get_default_screen(Gdk.Display.get_default()) # I was unable to found instrospected version of this Gtk.StyleContext.add_provider_for_screen( @@ -437,9 +442,9 @@ def __init__(self, application): self.provider_type_combo.set_active(0) # Select 1st type self.provider_type_combo.connect("changed", self.on_provider_type_combo_changed) - self.tv_logo.set_from_surface(self.get_surface_for_file("/usr/share/hypnotix/pictures/tv.svg", 258, 258)) - self.movies_logo.set_from_surface(self.get_surface_for_file("/usr/share/hypnotix/pictures/movies.svg", 258, 258)) - self.series_logo.set_from_surface(self.get_surface_for_file("/usr/share/hypnotix/pictures/series.svg", 258, 258)) + self.tv_logo.set_from_surface(self.get_surface_for_file(os.path.join(PKG_DATA_DIR, "pictures/tv.svg"), 258, 258)) + self.movies_logo.set_from_surface(self.get_surface_for_file(os.path.join(PKG_DATA_DIR, "pictures/movies.svg"), 258, 258)) + self.series_logo.set_from_surface(self.get_surface_for_file(os.path.join(PKG_DATA_DIR, "pictures/series.svg"), 258, 258)) self.reload(page="landing_page") @@ -485,7 +490,7 @@ def add_flag(self, code, box): def add_badge(self, word, box, added_words): if word not in added_words: for extension in ["svg", "png"]: - path = "/usr/share/hypnotix/pictures/badges/%s.%s" % (word, extension) + path = os.path.join(PKG_DATA_DIR, "pictures", "badges", "%s.%s" % (word, extension)) if os.path.exists(path): try: image = self.get_surf_based_image(path, -1, 32) @@ -756,7 +761,7 @@ def get_channel_surface(self, path): else: surface = self.get_surface_for_file(path, 200, 200) except Exception: - surface = self.get_surface_for_file("/usr/share/hypnotix/generic_tv_logo.png", 22, 22) + surface = self.get_surface_for_file(os.path.join(PKG_DATA_DIR, "generic_tv_logo.png"), 22, 22) return surface def on_go_back_button(self, widget=None): @@ -903,7 +908,7 @@ def navigate_to(self, page, name="", favorites=False): self.headerbar.set_subtitle(_("Reset providers")) def open_keyboard_shortcuts(self, widget): - gladefile = "/usr/share/hypnotix/shortcuts.ui" + gladefile = os.path.join(PKG_DATA_DIR, "shortcuts.ui") builder = Gtk.Builder() builder.set_translation_domain(APP) builder.add_from_file(gladefile) From 0d39f70ccd2e794993fc8a27346abc8c7d4882d2 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Thu, 13 Aug 2026 15:40:06 +0200 Subject: [PATCH 29/30] hypnotix.py: cleanup of unused attributes and variables --- usr/lib/hypnotix/hypnotix.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index f2b235de..9806e011 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -127,7 +127,6 @@ def __init__(self, application): self.application = application self.settings = Gio.Settings(schema_id="org.x.hypnotix") - self.icon_theme = Gtk.IconTheme.get_default() self.manager = Manager(self.settings) self.providers = [] self.favorite_data = [] @@ -177,7 +176,6 @@ def __init__(self, application): ) # Prefs variables - self.selected_pref_provider = None self.edit_mode = False # Create variables to quickly access dynamic widgets @@ -525,10 +523,8 @@ def show_groups(self, widget, content_type): name = group.name.lower().replace("(", " ").replace(")", " ") added_words = [] - found_flag = False for country_name in COUNTRY_CODES.keys(): if country_name.lower() == group.name.lower(): - found_flag = True self.add_flag(COUNTRY_CODES[country_name], box) break @@ -1593,9 +1589,8 @@ def on_key_press_event(self, widget, event): # Determine the actively pressed modifier modifier = event.get_state() & persistant_modifiers - # Bool of Control or Shift modifier states + # Bool of Control modifier state ctrl = modifier == Gdk.ModifierType.CONTROL_MASK - shift = modifier == Gdk.ModifierType.SHIFT_MASK if ctrl and event.keyval == Gdk.KEY_r: self.reload(page=None, refresh=True) @@ -1829,8 +1824,6 @@ def normal_mode(self): if getattr(self, "mouse_poll_timer_id", 0) > 0: GLib.source_remove(self.mouse_poll_timer_id) self.mouse_poll_timer_id = 0 - if getattr(self, "vlc_gui", None) is not None: - self.vlc_gui.mouse_cursor_visible = True self.window.unfullscreen() self.mpv_top_box.show() self.mpv_bottom_box.hide() From 6fcfd342eb54ec9ef2b4b09a6c275b99899a3c81 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Fri, 14 Aug 2026 12:53:00 +0200 Subject: [PATCH 30/30] Support yt-dlp resolution in VLC backend and allow local M3U favorites --- usr/lib/hypnotix/hypnotix.py | 5 ++--- usr/lib/hypnotix/player.py | 32 +++++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index 9806e011..c3e58f45 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -1465,9 +1465,8 @@ def toggle_new_ok_sensitivity(self, widget=None): self.new_ok_button.set_sensitive(True) if self.new_name_entry.get_text() == "": self.new_ok_button.set_sensitive(False) - for widget in (self.new_url_entry, self.new_logo_entry): - if "://" not in widget.get_text(): - self.new_ok_button.set_sensitive(False) + if self.new_url_entry.get_text() == "": + self.new_ok_button.set_sensitive(False) def get_url(self): type_id = self.provider_type_combo.get_model()[self.provider_type_combo.get_active()][PROVIDER_TYPE_ID] diff --git a/usr/lib/hypnotix/player.py b/usr/lib/hypnotix/player.py index 6a881ae6..d2344760 100644 --- a/usr/lib/hypnotix/player.py +++ b/usr/lib/hypnotix/player.py @@ -163,6 +163,9 @@ def pause(self, value: bool): self.player.pause = bool(value) +import subprocess +import os + class VlcEngine(VideoPlayer): def __init__(self, gui=None): self.gui = gui @@ -182,6 +185,28 @@ def __init__(self, gui=None): def set_window(self, xid): self.player.set_xwindow(int(xid)) + def _resolve_ytdlp(self, url): + local_path = os.path.expanduser("~/.cache/hypnotix/yt-dlp/yt-dlp") + ytdlp_path = local_path if os.path.exists(local_path) else "/usr/bin/yt-dlp" + + # Fast check if it is a Youtube url, if not, do a dry-run check + if not ("youtube.com" in url or "youtu.be" in url): + try: + subprocess.run([ytdlp_path, "--dump-json", "--no-download", url], capture_output=True, check=True) + except (subprocess.CalledProcessError, FileNotFoundError): + return url, None + + try: + result = subprocess.run([ytdlp_path, "-f", "bestvideo+bestaudio/best", "-g", url], capture_output=True, text=True, check=True) + lines = result.stdout.strip().split('\n') + if len(lines) == 2: + return lines[0], lines[1] + elif len(lines) == 1: + return lines[0], None + except (subprocess.CalledProcessError, FileNotFoundError): + pass + return url, None + def play(self, url, user_agent=None, referrer=None): self._stopped = False opts = [] @@ -193,7 +218,12 @@ def play(self, url, user_agent=None, referrer=None): if ref: opts.append(f":http-referrer={ref}") - media = self.instance.media_new(url, *opts) + video_url, audio_url = self._resolve_ytdlp(url) + + if audio_url: + opts.append(f":input-slave={audio_url}") + + media = self.instance.media_new(video_url, *opts) self.player.set_media(media) self.player.play() if self.gui: