diff --git a/.gitignore b/.gitignore index c4a6e20..4b93134 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,9 @@ debian/*debhelper* debian/files debian/hypnotix.substvars usr/share/locale -usr/share/hypnotix/hypnotix.ui~ \ No newline at end of file +usr/share/hypnotix/hypnotix.ui~ +.venv +.vscode +usr/share/data +usr/lib/hypnotix/__pycache__ +usr/share/glib-2.0/schemas/*.compiled \ No newline at end of file diff --git a/README.md b/README.md index 156076d..281fce4 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,110 @@ -# Hypnotix -![build](https://github.com/linuxmint/hypnotix/actions/workflows/build.yml/badge.svg) +# Hypnotix for Windows -Hypnotix is an IPTV streaming application with support for live TV, movies and series. +Hypnotix for Windows is an IPTV streaming application with support for live TV. -![shadow](https://user-images.githubusercontent.com/1138515/99553152-b8bac780-29b5-11eb-9d75-8756ed7581b6.png) +It is a fork of [hypnotix](https://github.com/linuxmint/hypnotix) which is a Linux only app built and maintained for Linux Mint. It is built with GTK3 and my aim is to port this over to Windows with as minimal changes as required and future compatibility in mind. -It can support multiple IPTV providers of the following types: +I have only an initial build running in the crudest way possible and I have plans to bring this up to spec and also make it package/ship ready in future. For now, you will need to build the app on your own for now with the instructions below. -- M3U URL -- Xtream API -- Local M3U playlist +**Known Issues:** +- Live TV video overlay controls are not available. This is potentially a libmpv issue. See [this issue in python-mpv](https://github.com/jaseg/python-mpv/issues/103) for details. +- Movie and Series modules are not tested and are not a priority. +- There could be other issues + +![shadow](https://github.com/user-attachments/assets/9735d7a2-7867-48c4-aa80-8b024c8488d8) # License - Code: GPLv3 -- Flags: https://github.com/linuxmint/flags - Icons on the landing page: CC BY-ND 2.0 -# Requirements +# Development Requirements + +- [MSYS2](https://github.com/msys2) (tested with UCRT64 profile) +- MSYS2 packages + + > Note 1: Not all packages might be required and there might be duplicates. I need to test this in a fresh VM to find only the essential ones later. + + > Note 2: Install the packages as ```pacman -S ``` in MSYS2 + + GTK + - mingw-w64-x86_64-gtk3 + - mingw-w64-x86_64-glib2 + - mingw-w64-x86_64-glade + - mingw-w64-x86_64-gstreamer + + MPV + - mingw-w64-x86_64-mpv + - ucrt64/mingw-w64-ucrt-x86_64-mpv + + GCC and Build + - mingw-w64-x86_64-gcc + - mingw-w64-x86_64-make + - mingw-w64-x86_64-pkg-config + + Python + - mingw-w64-x86_64-python3 + - mingw-w64-x86_64-python3-gobject + - mingw-w64-x86_64-python-pip + + XML + - mingw-w64-x86_64-libxml2 + - mingw-w64-x86_64-libxslt + + Adwaita Theme for icons + - mingw64/mingw-w64-x86_64-adwaita-icon-theme + - ucrt64/mingw-w64-ucrt-x86_64-adwaita-icon-theme + + +# Run Steps: + +1) Install MSYS2 and git clone repo. + > **Note:** Use Windows Terminal (with MSYS2/UCRT64 shell profile) or MSYS2 app directly or whichever terminal app you are comfortable with for shell access. + ```git clone git@github.com:lakshminarayananb/hypnotix-windows.git``` + +2) Install the above mentioned development packages. + ``` + pacman -S mingw-w64-x86_64-gtk3 + pacman -S mingw-w64-x86_64-glade + pacman -S mingw-w64-x86_64-glib2 + pacman -S mingw-w64-x86_64-gstreamer + + pacman -S mingw-w64-x86_64-mpv + pacman -S ucrt64/mingw-w64-ucrt-x86_64-mpv + + pacman -S mingw-w64-x86_64-gcc + pacman -S mingw-w64-x86_64-make + pacman -S mingw-w64-x86_64-pkg-config + + pacman -S mingw-w64-x86_64-python3 + pacman -S mingw-w64-x86_64-python3-gobject + pacman -S mingw-w64-x86_64-python-pip + + pacman -S mingw-w64-x86_64-libxml2 + pacman -S mingw-w64-x86_64-libxslt + + pacman -S ucrt64/mingw-w64-ucrt-x86_64-adwaita-icon-theme + pacman -S mingw64/mingw-w64-x86_64-adwaita-icon-theme + ``` + +3) Install python dependencies (you may try with venv) + ``` + pip install mpv + pip install requests + pip install setproctitle + pip install unidecode + ``` + > **Note:** ```pip install IMDbPY``` is removed for now due to build issues and related features are commented out. + +4) Now running the hypnotix.py should launch the app + ```python3 usr/lib/hypnotix/hypnotix.py``` -- libxapp 2.6+ -- libmpv -- python3-imdbpy (for Older Mint and Debian releases get it from https://packages.ubuntu.com/focal/all/python3-imdbpy/download) -- circle-flags (https://github.com/linuxmint/circle-flags) # TV Channels and media content -Hypnotix does not provide content or TV channels, it is a player application which streams from IPTV providers. +Hypnotix for Windows or Hypnotix does not provide content or TV channels, it is a player application which streams from IPTV providers. -By default, Hypnotix is configured with one IPTV provider called Free-TV: https://github.com/Free-TV/IPTV. +By default, Hypnotix for Windows is configured with an IPTV provider called Free-TV: https://github.com/Free-TV/IPTV. This provider was chosen because it satisfied the following criterias: @@ -38,14 +114,4 @@ This provider was chosen because it satisfied the following criterias: Issues relating to TV channels and media content should be addressed directly to the relevant provider. -Note: Feel free to remove Free-TV from Hypnotix if you don't use it, or add any other provider you may have access to or local M3U playlists. - -# Wayland compatibility - -If you're using Wayland go the Hypnotix preferences and add the following to the list of MPV options: - -`vo=x11` - -Run Hypnotix with: - -`GDK_BACKEND=x11 hypnotix` +Note: Feel free to remove Free-TV from Hypnotix if you don't use it, or add any other provider you may have access to or local M3U playlists. \ No newline at end of file diff --git a/usr/lib/hypnotix/common.py b/usr/lib/hypnotix/common.py index 0e60b5f..b0ac1f9 100755 --- a/usr/lib/hypnotix/common.py +++ b/usr/lib/hypnotix/common.py @@ -11,11 +11,13 @@ EXTINF = re.compile(r'^#EXTINF:(?P-?\d+?) ?(?P.*),(?P.*?)$') SERIES = re.compile(r"(?P<series>.*?) S(?P<season>.\d{1,2}).*E(?P<episode>.\d{1,2}.*)$", re.IGNORECASE) -PROVIDERS_PATH = os.path.join(GLib.get_user_cache_dir(), "hypnotix", "providers") +PROVIDERS_PATH = os.path.normpath(os.path.join(GLib.get_user_cache_dir(), "hypnotix", "providers")) TV_GROUP, MOVIES_GROUP, SERIES_GROUP = range(3) +# print("PROVIDERS_PATH:", PROVIDERS_PATH) -FAVORITES_PATH = os.path.join(GLib.get_user_cache_dir(), "hypnotix", "favorites", "list") +FAVORITES_PATH = os.path.normpath(os.path.join(GLib.get_user_cache_dir(), "hypnotix", "favorites", "list")) +#print("FAVORITES_PATH:", FAVORITES_PATH) # Used as a decorator to run things in the background def async_function(func): @@ -50,7 +52,7 @@ def __init__(self, name, provider_info): self.name, self.type_id, self.url, self.username, self.password, self.epg = provider_info.split(":::") else: self.name = name - self.path = os.path.join(PROVIDERS_PATH, slugify(self.name)) + self.path = os.path.normpath(os.path.join(PROVIDERS_PATH, slugify(self.name))) self.groups = [] self.channels = [] self.movies = [] @@ -129,11 +131,13 @@ def __init__(self, provider, info): provider_name = "favorites" else: provider_name = provider.name - self.logo_path = os.path.join(PROVIDERS_PATH, "%s-%s%s" % (slugify(provider_name), slugify(self.name), ext)) + self.logo_path = os.path.normpath(os.path.join(PROVIDERS_PATH, "%s-%s%s" % (slugify(provider_name), slugify(self.name), ext))) class Manager: def __init__(self, settings): - os.system("mkdir -p '%s'" % PROVIDERS_PATH) + #os.system("mkdir -p '%s'" % PROVIDERS_PATH) + os.makedirs(PROVIDERS_PATH, exist_ok=True) #Windows - create providers directory if not exists + os.makedirs(os.path.dirname(FAVORITES_PATH), exist_ok=True) #Windows - create favorites directory if not exist self.verbose = False self.settings = settings @@ -295,9 +299,15 @@ def load_channels(self, provider): def load_favorites(self): favorites = [] - with open(FAVORITES_PATH, 'r', encoding="utf-8", errors="ignore") as f: - for line in f: - favorites.append(line.strip()) + try: + with open(FAVORITES_PATH, 'r', encoding="utf-8", errors="ignore") as f: + for line in f: + favorites.append(line.strip()) + except FileNotFoundError: + # Create favorites directory and new listfile if not exists + os.makedirs(os.path.dirname(FAVORITES_PATH), exist_ok=True) + with open(FAVORITES_PATH, 'w', encoding="utf-8") as f: + pass # Create empty file return favorites def save_favorites(self, favorites): diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index 4885ac5..a10252d 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -1,4 +1,5 @@ #!/usr/bin/python3 +import ctypes import gettext import locale import os @@ -18,15 +19,23 @@ # Suppress GTK deprecation warnings warnings.filterwarnings("ignore") +import platform +IS_WINDOWS = platform.system() == "Windows" + import gi gi.require_version("Gtk", "3.0") -gi.require_version("XApp", "1.0") -from gi.repository import Gtk, Gdk, Gio, XApp, GdkPixbuf, GLib, Pango +# Conditionally import XApp based on platform +if not IS_WINDOWS: + gi.require_version("XApp", "1.0") + from gi.repository import Gtk, Gdk, Gio, XApp, GdkPixbuf, GLib, Pango +else: + from gi.repository import Gtk, Gdk, Gio, GdkPixbuf, GLib, Pango import mpv import requests import setproctitle +#from imdb import Cinemagoer from unidecode import unidecode from common import Manager, Provider, Channel, MOVIES_GROUP, PROVIDERS_PATH, SERIES_GROUP, TV_GROUP,\ @@ -38,9 +47,16 @@ # i18n APP = "hypnotix" LOCALE_DIR = "/usr/share/locale" -locale.bindtextdomain(APP, LOCALE_DIR) -gettext.bindtextdomain(APP, LOCALE_DIR) -gettext.textdomain(APP) + +if not IS_WINDOWS: + locale.bindtextdomain(APP, LOCALE_DIR) + gettext.bindtextdomain(APP, LOCALE_DIR) + gettext.textdomain(APP) +else: + # Windows doesn't support bindtextdomain through locale + gettext.bindtextdomain(APP, LOCALE_DIR) + gettext.textdomain(APP) + _ = gettext.gettext @@ -71,7 +87,7 @@ } COUNTRY_CODES = {} -with open("/usr/share/hypnotix/countries.list") as f: +with open("usr/share/hypnotix/countries.list") as f: for line in f: line = line.strip() code, name = line.split(":") @@ -139,8 +155,8 @@ def __init__(self, application): self.latest_search_bar_text = None self.visible_search_results = 0 self.mpv = None + #self.ia = IMDb() self.page_is_loading = False # used to ignore signals while we set widget states - self.video_properties = {} self.audio_properties = {} self.volume = 100 @@ -148,7 +164,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 = "usr/share/hypnotix/hypnotix.ui" self.builder = Gtk.Builder() self.builder.set_translation_domain(APP) self.builder.add_from_file(gladefile) @@ -160,7 +176,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("usr/share/hypnotix/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( @@ -337,10 +353,11 @@ def __init__(self, application): # Dark mode manager # keep a reference to it (otherwise it gets randomly garbage collected) - try: - self.dark_mode_manager = XApp.DarkModeManager.new(prefer_dark_mode=True) - except Exception: - pass + if not IS_WINDOWS: + try: + self.dark_mode_manager = XApp.DarkModeManager.new(prefer_dark_mode=True) + except Exception: + pass # Menubar accel_group = Gtk.AccelGroup() @@ -392,9 +409,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("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.reload(page="landing_page") @@ -426,7 +443,7 @@ def get_surf_based_image(self, filename, width, height): return Gtk.Image.new_from_surface(surf) def add_flag(self, code, box): - path = f"/usr/share/circle-flags-svg/{code.lower()}.svg" + path = f"usr/share/circle-flags-svg/{code.lower()}.svg" if os.path.exists(path): try: image = self.get_surf_based_image(path, -1, 32) @@ -440,7 +457,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 = "usr/share/hypnotix/pictures/badges/%s.%s" % (word, extension) if os.path.exists(path): try: image = self.get_surf_based_image(path, -1, 32) @@ -479,7 +496,8 @@ def show_groups(self, widget, content_type): 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) + # commenting out as circle-flags is not added + # self.add_flag(COUNTRY_CODES[country_name], box) break for word in name.split(): @@ -689,7 +707,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("usr/share/hypnotix/generic_tv_logo.png", 22, 22) return surface def on_go_back_button(self, widget=None): @@ -836,7 +854,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 = "usr/share/hypnotix/shortcuts.ui" builder = Gtk.Builder() builder.set_translation_domain(APP) builder.add_from_file(gladefile) @@ -1423,7 +1441,7 @@ def open_about(self, widget): dlg.set_program_name(_("Hypnotix")) dlg.set_comments(_("Watch TV")) try: - h = open("/usr/share/common-licenses/GPL", encoding="utf-8") + h = open("usr/share/common-licenses/GPL", encoding="utf-8") s = h.readlines() gpl = "" for line in s: @@ -1481,6 +1499,11 @@ def on_key_press_event(self, widget, event): self.on_prev_channel() elif event.keyval == Gdk.KEY_Right: self.on_next_channel() + #sidebar toggle + elif event.keyval == Gdk.KEY_s: + self.toggle_sidebar_visibility() + elif event.keyval == Gdk.KEY_h: + self.toggle_header_visibility() # elif event.keyval == Gdk.KEY_Up: # # Up of in the list # pass @@ -1502,7 +1525,6 @@ def reload(self, page=None, refresh=False): for provider_info in self.settings.get_strv("providers"): try: provider = Provider(name=None, provider_info=provider_info) - # Add provider to list. This must be done so that it shows up in the # list of providers for editing. self.providers.append(provider) @@ -1628,6 +1650,26 @@ def reinit_mpv(self): while not self.mpv_drawing_area.get_window() and not Gtk.events_pending(): time.sleep(0.1) + # Get the window handle of the drawing area + gdk_window = self.mpv_drawing_area.get_window() + if gdk_window is not None: + if IS_WINDOWS: + # Windows-specific handling + if not gdk_window.ensure_native(): + print("Error - video playback requires a native window") + ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p + ctypes.pythonapi.PyCapsule_GetPointer.argtypes = [ctypes.py_object] + drawingarea_gpointer = ctypes.cast(ctypes.pythonapi.PyCapsule_GetPointer(gdk_window.__gpointer__, None), ctypes.c_void_p) + gdkdll = ctypes.CDLL("libgdk-3-0.dll") + wid = gdkdll.gdk_win32_window_get_handle(drawingarea_gpointer) + else: + # Linux-specific handling + wid = gdk_window.get_xid() + else: + raise RuntimeError("Failed to get window handle") + + options["wid"] = str(wid) # Set the window ID for MPV + osc = True if "osc" in options: # To prevent 'multiple values for keyword argument'! @@ -1640,8 +1682,8 @@ def reinit_mpv(self): input_default_bindings=True, input_vo_keyboard=True, osc=osc, - ytdl=True, - wid=str(self.mpv_drawing_area.get_window().get_xid()) + ytdl=True + #wid=str(wid_tmp) ) self.mpv.volume = self.volume @@ -1709,6 +1751,44 @@ def on_close_info_window_button_clicked(self, widget): def on_volume_prop(self, name, value ): self.volume = value + #sidebar toggle + def toggle_sidebar_visibility(self): + self.sidebar_visible = not self.sidebar_visible + if not self.sidebar_visible: + self.sidebar.hide() + else: + self.sidebar.show() + + def toggle_header_visibility(self): + self.header_visible = not self.header_visible + if not self.header_visible: + self.headerbar.hide() + self.mpv_top_box.hide() + else: + self.headerbar.show() + self.mpv_top_box.show() + +def compile_gsettings_schema(schema_dir): + # Compile the GSettings schemas + try: + subprocess.run(['glib-compile-schemas', schema_dir], check=True) + #print("GSettings schemas compiled successfully.") + except subprocess.CalledProcessError as e: + print(f"Error compiling GSettings schemas: {e}") + +def set_gsettings_schema_dir(schema_dir): + # Set the GSETTINGS_SCHEMA_DIR environment variable + os.environ['GSETTINGS_SCHEMA_DIR'] = schema_dir + #print(f"GSETTINGS_SCHEMA_DIR set to: {schema_dir}") + if __name__ == "__main__": + schema_directory = "usr/share/glib-2.0/schemas/" + + # Compile the schemas. Added for Windows in specific. + compile_gsettings_schema(schema_directory) + + # Set the environment variable. Added for Windows in specific. + set_gsettings_schema_dir(schema_directory) + application = MyApplication("org.x.hypnotix", Gio.ApplicationFlags.FLAGS_NONE) application.run() diff --git a/usr/lib/hypnotix/mpv.py b/usr/lib/hypnotix/mpv.py index 4817a13..9fa89d8 100644 --- a/usr/lib/hypnotix/mpv.py +++ b/usr/lib/hypnotix/mpv.py @@ -29,12 +29,20 @@ import traceback if os.name == 'nt': - dll = ctypes.util.find_library('mpv-1.dll') + import locale + lc, enc = locale.getlocale(locale.LC_NUMERIC) + # libmpv requires LC_NUMERIC to be set to "C". Since messing with global variables everyone else relies upon is + # still better than segfaulting, we are setting LC_NUMERIC to "C". + locale.setlocale(locale.LC_NUMERIC, 'C') + + dll = ctypes.util.find_library('libmpv-2.dll') # Try MSYS2 DLL name first + if dll is None: + dll = ctypes.util.find_library('mpv-1.dll') # Fall back to original name if dll is None: - raise OSError('Cannot find mpv-1.dll in your system %PATH%. One way to deal with this is to ship mpv-1.dll ' + raise OSError('Cannot find libmpv-2.dll or mpv-1.dll in your system %PATH%. One way to deal with this is to ship the DLL ' 'with your script and put the directory your script is in into %PATH% before "import mpv": ' 'os.environ["PATH"] = os.path.dirname(__file__) + os.pathsep + os.environ["PATH"] ' - 'If mpv-1.dll is located elsewhere, you can add that path to os.environ["PATH"].') + 'If the DLL is located elsewhere, you can add that path to os.environ["PATH"].') backend = CDLL(dll) fs_enc = 'utf-8' else: diff --git a/usr/share/hypnotix/hypnotix.ui b/usr/share/hypnotix/hypnotix.ui index c43b5c0..4b7ec6f 100644 --- a/usr/share/hypnotix/hypnotix.ui +++ b/usr/share/hypnotix/hypnotix.ui @@ -2,7 +2,7 @@ <!-- Generated with glade 3.40.0 --> <interface> <requires lib="gtk+" version="3.20"/> - <requires lib="xapp" version="0.0"/> + <!-- <requires lib="xapp" version="0.0"/> --> <object class="GtkMenu" id="main_menu"> <property name="visible">True</property> <property name="can-focus">False</property> @@ -142,7 +142,8 @@ <object class="GtkImage"> <property name="visible">True</property> <property name="can-focus">False</property> - <property name="icon-name">xapp-prefs-behavior-symbolic</property> + <!-- <property name="icon-name">xapp-prefs-behavior-symbolic</property> --> + <property name="icon-name">preferences-system-symbolic</property> <property name="icon_size">3</property> </object> </child> @@ -740,7 +741,8 @@ <property name="can-focus">False</property> <property name="spacing">6</property> <child> - <object class="XAppStackSidebar"> + <!-- <object class="XAppStackSidebar"> Does not work on Windows --> + <object class="GtkStackSidebar"> <property name="visible">True</property> <property name="can-focus">False</property> <property name="stack">pref_stack</property>