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 a6be99fe..c3e58f45 100755
--- a/usr/lib/hypnotix/hypnotix.py
+++ b/usr/lib/hypnotix/hypnotix.py
@@ -24,20 +24,24 @@
gi.require_version("XApp", "1.0")
from gi.repository import Gtk, Gdk, Gio, XApp, GdkPixbuf, GLib, Pango
-import mpv
import requests
import setproctitle
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")
+# 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)
@@ -71,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(":")
@@ -123,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 = []
@@ -139,8 +142,12 @@ 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.cursor_hide_timer_id = 0
+ self.mouse_poll_timer_id = 0
+
self.video_properties = {}
self.audio_properties = {}
self.volume = 100
@@ -148,7 +155,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)
@@ -160,7 +167,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(
@@ -169,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
@@ -236,6 +242,7 @@ def __init__(self, application):
"useragent_entry",
"referer_entry",
"mpv_entry",
+ "video_backend_combo",
"mpv_link",
"ytdlp_local_switch",
"ytdlp_system_version_label",
@@ -327,9 +334,50 @@ def __init__(self, application):
self.bind_setting_widget("http-referer", self.referer_entry)
self.bind_setting_widget("mpv-options", self.mpv_entry)
+ 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)
+ if self.is_mpv_available:
+ backend_model.append(["mpv", _("MPV (Default)")])
+ if self.is_vlc_available:
+ backend_model.append(["vlc", _("VLC Player")])
+
+ # 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)
+ 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:
+ 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)
+ 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"))
@@ -392,9 +440,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")
@@ -440,7 +488,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)
@@ -475,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
@@ -537,6 +583,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)
@@ -640,11 +693,25 @@ 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.terminate()
+ 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():
self.update_ytdlp()
+ @async_function
def update_ytdlp(self, widget=None):
path = os.path.expanduser("~/.cache/hypnotix/yt-dlp")
os.chdir(path)
@@ -653,7 +720,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):
@@ -689,7 +757,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):
@@ -836,7 +904,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)
@@ -845,17 +913,26 @@ 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 getattr(self, "active_channel", None) 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:
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):
@@ -874,23 +951,43 @@ 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
- 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()
- self.after_play(channel)
+ self.is_changing_channel = True
+ try:
+ 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:
+ 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
+
+ self.after_play(channel)
+ finally:
+ self.is_changing_channel = False
@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")] = {}
@@ -908,8 +1005,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"))
@@ -917,6 +1018,8 @@ 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"))
+ set_playback_button_state(self.pause_button, False)
+ self.info_menu_item.set_sensitive(False)
self.page_is_loading = False
@idle_function
@@ -936,6 +1039,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)
@@ -945,6 +1049,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):
@@ -952,6 +1063,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(
@@ -1037,14 +1153,30 @@ 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")
+ # 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
+ set_playback_button_state(self.pause_button, self.mpv.pause)
def on_show_button(self, widget):
self.navigate_to("channels_page")
@@ -1333,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]
@@ -1457,9 +1588,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)
@@ -1488,7 +1618,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
@@ -1621,41 +1751,64 @@ 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)
+ if self.mpv is None:
+ # Map the player page if not realized yet
+ if 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")
+
+ 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)
- osc = True
- if "osc" in options:
- # To prevent 'multiple values for keyword argument'!
- osc = options.pop("osc") != "no"
+ xid = str(self.mpv_drawing_area.get_window().get_xid())
- 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())
- )
-
- self.mpv.volume = self.volume
+ 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")
+ 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"
+
+ from player import MpvEngine
+ self.mpv = MpvEngine(options=options, osc=osc)
+ self.mpv.set_window(xid)
+
+ 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):
@@ -1664,6 +1817,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
self.window.unfullscreen()
self.mpv_top_box.show()
self.mpv_bottom_box.hide()
@@ -1701,12 +1860,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"))
# 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)
@@ -1722,6 +1887,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/player.py b/usr/lib/hypnotix/player.py
new file mode 100644
index 00000000..d2344760
--- /dev/null
+++ b/usr/lib/hypnotix/player.py
@@ -0,0 +1,333 @@
+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."""
+
+ @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 terminate(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 unobserve_property(self, name, callback):
+ pass
+
+ @abc.abstractmethod
+ 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:
+ pass
+
+ @property
+ @abc.abstractmethod
+ def idle_active(self) -> bool:
+ pass
+
+ @pause.setter
+ @abc.abstractmethod
+ def pause(self, value: bool):
+ pass
+
+
+class MpvEngine(VideoPlayer):
+ def __init__(self, options=None, osc=True):
+ if mpv is None:
+ raise ImportError("mpv library not found")
+
+ mpv_options = options if options is not None else {}
+
+ self.player = 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 terminate(self):
+ self.stop()
+ try:
+ self.player.terminate()
+ 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 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)
+
+ 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
+
+ @property
+ 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)
+
+
+import subprocess
+import os
+
+class VlcEngine(VideoPlayer):
+ def __init__(self, gui=None):
+ self.gui = gui
+
+ # 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,
+ # 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 _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 = []
+ 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}")
+
+ 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:
+ 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:
+ 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
+ if self.gui and not getattr(self, "_stopped", False):
+ self.gui.set_controls_sensitive(True)
+
+ def observe_property(self, name, callback):
+ pass
+
+ def unobserve_property(self, name, callback):
+ pass
+
+ def register_event_cb(self, callback):
+ pass
+
+ def show_osd_text(self, text: str, duration_ms: int = 6000):
+ if not self.player:
+ return
+ if text:
+ self.player.video_set_marquee_int(vlc.VideoMarqueeOption.Enable, 1)
+
+ # 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 / 720.0) * 42) if height > 0 else 42
+
+ # Set a minimum floor so it never gets unreadable on tiny streams
+ 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
+ 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, font_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
+ elif key == "referrer":
+ self._referrer = value
+
+ @property
+ 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:
+ 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
new file mode 100644
index 00000000..ee1b4636
--- /dev/null
+++ b/usr/lib/hypnotix/vlcgui.py
@@ -0,0 +1,212 @@
+import gi
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import GLib, Gtk, Gdk
+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."""
+
+ 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
+ self.hide_timer_id = 0
+
+ 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)
+
+ # THE DYNAMIC PLAY/PAUSE TOGGLE 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", self.on_vlc_toggle_clicked)
+ self.vlc_control_layout.pack_start(self.btn_toggle, False, False, 5)
+
+ # THE SIMPLE STOP BUTTON
+ 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", 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)
+ self.btn_menu = Gtk.MenuButton()
+ 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)
+ self.vlc_stream_menu.connect("show", self.on_vlc_menu_show)
+ self.vlc_control_layout.pack_start(self.btn_menu, False, False, 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)
+ 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():
+ self.control_wrapper.show()
+
+ 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
+
+ return False
+
+ def on_vlc_toggle_clicked(self, *args):
+ if not self.win.mpv:
+ return
+
+ self.win.mpv.pause = not self.win.mpv.pause
+ set_playback_button_state(self.btn_toggle, self.win.mpv.pause)
+
+ 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:
+ set_playback_button_state(self.btn_toggle, not sensitive)
+
+ return False
+
+ GLib.idle_add(_update)
+
+ def on_vlc_menu_show(self, *args):
+ 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, _("Disable").encode("utf-8")))
+
+ 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
+
+ item.set_active(track_id == current_track_id)
+
+ 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()
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.
+
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
+
+
+
+ 0
+ 2
+
+
+
+
+
+ 1
+ 2
+
+