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 + + + True + False + start + center + Playback Engine + + + + + + 0 + 2 + + + + + True + False + center + True + + + 1 + 2 + +