From c0d65e1755cb17e2e08fa08592e91197247dd314 Mon Sep 17 00:00:00 2001 From: Clement Lefebvre Date: Mon, 26 Oct 2020 17:13:03 +0000 Subject: [PATCH] Initial commit --- Makefile | 13 + README.md | 3 + debian/changelog | 6 + debian/compat | 1 + debian/control | 17 + debian/copyright | 20 + debian/install | 1 + debian/postinst | 20 + debian/rules | 13 + debian/source/format | 1 + generate_desktop_files | 24 + makepot | 5 + po/hypnotix-fr.po | 0 test | 5 + usr/bin/hypnotix | 2 + usr/lib/hypnotix/common.py | 132 ++ usr/lib/hypnotix/hypnotix.py | 288 +++ usr/lib/hypnotix/mpv.py | 1866 +++++++++++++++++ usr/share/applications/hypnotix.desktop | 10 + .../schemas/org.x.hypnotix.gschema.xml | 10 + usr/share/hypnotix/hypnotix.ui | 224 ++ usr/share/hypnotix/shortcuts.ui | 27 + .../icons/hicolor/scalable/apps/hypnotix.svg | 512 +++++ 23 files changed, 3200 insertions(+) create mode 100644 Makefile create mode 100644 README.md create mode 100644 debian/changelog create mode 100644 debian/compat create mode 100644 debian/control create mode 100644 debian/copyright create mode 100644 debian/install create mode 100644 debian/postinst create mode 100755 debian/rules create mode 100644 debian/source/format create mode 100755 generate_desktop_files create mode 100755 makepot create mode 100644 po/hypnotix-fr.po create mode 100755 test create mode 100755 usr/bin/hypnotix create mode 100755 usr/lib/hypnotix/common.py create mode 100755 usr/lib/hypnotix/hypnotix.py create mode 100644 usr/lib/hypnotix/mpv.py create mode 100644 usr/share/applications/hypnotix.desktop create mode 100644 usr/share/glib-2.0/schemas/org.x.hypnotix.gschema.xml create mode 100644 usr/share/hypnotix/hypnotix.ui create mode 100644 usr/share/hypnotix/shortcuts.ui create mode 100644 usr/share/icons/hicolor/scalable/apps/hypnotix.svg diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b21e8a5 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +all: buildmo + +buildmo: + @echo "Building the mo files" + # WARNING: the second sed below will only works correctly with the languages that don't contain "-" + for file in `ls po/*.po`; do \ + lang=`echo $$file | sed 's@po/@@' | sed 's/\.po//' | sed 's/hypnotix-//'`; \ + install -d usr/share/locale/$$lang/LC_MESSAGES/; \ + msgfmt -o usr/share/locale/$$lang/LC_MESSAGES/hypnotix.mo $$file; \ + done \ + +clean: + rm -rf usr/share/locale diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef3e5df --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Hypnotix + +An IPTV app. diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..6768e37 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,6 @@ +hypnotix (1.0.0) ulyana; urgency=low + + * Initial release + + -- Clement Lefebvre Tue, 26 Oct 2020 21:38:00 +0000 + diff --git a/debian/compat b/debian/compat new file mode 100644 index 0000000..ec63514 --- /dev/null +++ b/debian/compat @@ -0,0 +1 @@ +9 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..6f14dc6 --- /dev/null +++ b/debian/control @@ -0,0 +1,17 @@ +Source: hypnotix +Section: admin +Priority: optional +Maintainer: Linux Mint +Build-Depends: debhelper (>= 9) +Standards-Version: 3.9.5 + +Package: hypnotix +Architecture: all +Depends: gir1.2-xapp-1.0, + python3, + python3-gi, + python3-setproctitle, + xapps-common, + ${misc:Depends}, +Description: IPTV Player + Watch TV by streaming from M3U sources. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..b150464 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,20 @@ +Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: hypnotix +Upstream-Contact: Linux Mint +Source: https://github.com/linuxmint/hypnotix + +Files: * +Copyright: 2020 Linux Mint +License: GPL-3+ + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 3 of the License, or + (at your option) any later version. + . + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + . + On Debian systems, the complete text of the GNU General + Public License can be found in `/usr/share/common-licenses/GPL' diff --git a/debian/install b/debian/install new file mode 100644 index 0000000..73752c9 --- /dev/null +++ b/debian/install @@ -0,0 +1 @@ +usr diff --git a/debian/postinst b/debian/postinst new file mode 100644 index 0000000..af3b796 --- /dev/null +++ b/debian/postinst @@ -0,0 +1,20 @@ +#!/bin/sh +set -e + +case "$1" in + configure) + if which glib-compile-schemas >/dev/null 2>&1 + then + glib-compile-schemas /usr/share/glib-2.0/schemas + fi + ;; + + abort-upgrade|abort-remove|abort-deconfigure) + + ;; + + *) + echo "postinst called with unknown argument \`$1'" >&2 + exit 1 + ;; +esac diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..097372c --- /dev/null +++ b/debian/rules @@ -0,0 +1,13 @@ +#!/usr/bin/make -f + +DEB_VERSION := $(shell dpkg-parsechangelog | egrep '^Version:' | cut -f 2 -d ' ') + +%: + dh ${@} + +# Inject version number in the code +override_dh_installdeb: + dh_installdeb + for pkg in $$(dh_listpackages -i); do \ + find debian/$$pkg -type f -exec sed -i -e s/__DEB_VERSION__/$(DEB_VERSION)/g {} +; \ + done diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..89ae9db --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (native) diff --git a/generate_desktop_files b/generate_desktop_files new file mode 100755 index 0000000..a78db64 --- /dev/null +++ b/generate_desktop_files @@ -0,0 +1,24 @@ +#!/usr/bin/python3 + +DOMAIN = "hypnotix" +PATH = "/usr/share/locale" + +import os +import gettext +from mintcommon import additionalfiles + +os.environ['LANGUAGE'] = "en_US.UTF-8" +gettext.install(DOMAIN, PATH) + +prefix = "[Desktop Entry]\n" + +suffix = """Exec=hypnotix +Icon=hypnotix +Terminal=false +Type=Application +Encoding=UTF-8 +Categories=AudioVideo;Video;Player;TV; +StartupNotify=false +""" + +additionalfiles.generate(DOMAIN, PATH, "usr/share/applications/hypnotix.desktop", prefix, _("Hypnotix"), _("Watch TV"), suffix) diff --git a/makepot b/makepot new file mode 100755 index 0000000..4110e8e --- /dev/null +++ b/makepot @@ -0,0 +1,5 @@ +#!/bin/bash +intltool-extract --type=gettext/glade usr/share/hypnotix/hypnotix.ui +intltool-extract --type=gettext/glade usr/share/hypnotix/shortcuts.ui +xgettext --language=Python --keyword=_ --keyword=N_ --output=hypnotix.pot usr/lib/hypnotix/*.py generate_desktop_files usr/share/hypnotix/hypnotix.ui.h usr/share/hypnotix/shortcuts.ui.h +rm -f usr/share/hypnotix/*.ui.h diff --git a/po/hypnotix-fr.po b/po/hypnotix-fr.po new file mode 100644 index 0000000..e69de29 diff --git a/test b/test new file mode 100755 index 0000000..a162395 --- /dev/null +++ b/test @@ -0,0 +1,5 @@ +#!/bin/bash +sudo rm -rf /usr/lib/hypnotix +sudo rm -rf /usr/share/hypnotix +sudo cp -R usr / +hypnotix diff --git a/usr/bin/hypnotix b/usr/bin/hypnotix new file mode 100755 index 0000000..f6fdae4 --- /dev/null +++ b/usr/bin/hypnotix @@ -0,0 +1,2 @@ +#!/bin/sh +/usr/lib/hypnotix/hypnotix.py & diff --git a/usr/lib/hypnotix/common.py b/usr/lib/hypnotix/common.py new file mode 100755 index 0000000..1bcffa4 --- /dev/null +++ b/usr/lib/hypnotix/common.py @@ -0,0 +1,132 @@ +#!/usr/bin/python3 +import configparser +import gi +import os +import requests +import shutil +import string +import threading +import re +from gi.repository import GObject +from random import choice + +# M3U parsing regex +PARAMS = re.compile(r'(\S+)="(.*?)"') +EXTINF = re.compile(r'^#EXTINF:(?P-?\d+?) ?(?P.*),(?P.*?)$') +PROVIDERS_PATH = os.path.expanduser("~/.hypnotix/providers") + +# Used as a decorator to run things in the background +def _async(func): + def wrapper(*args, **kwargs): + thread = threading.Thread(target=func, args=args, kwargs=kwargs) + thread.daemon = True + thread.start() + return thread + return wrapper + +# Used as a decorator to run things in the main loop, from another thread +def idle(func): + def wrapper(*args): + GObject.idle_add(func, *args) + return wrapper + +def slugify(string): + """ + Normalizes string, converts to lowercase, removes non-alpha characters, + and converts spaces to hyphens. + """ + return "".join(x.lower() for x in string if x.isalnum()) + +class Provider(): + def __init__(self, name, url): + self.name = name + self.path = os.path.join(PROVIDERS_PATH, slugify(name)) + self.url = url + self.groups = [] + self.channels = [] + +class Group(): + def __init__(self, name): + self.name = name + self.channels = [] + +class Channel(): + def __init__(self, info): + self.info = info + self.id = None + self.name = None + self.logo = None + self.group_title = None + self.title = None + self.url = None + match = EXTINF.fullmatch(info) + if match != None: + res = match.groupdict() + if 'params' in res: + params = dict(PARAMS.findall(res['params'])) + if "tvg-name" in params and params['tvg-name'].strip() != "": + self.name = params['tvg-name'].strip() + if "tvg-logo" in params and params['tvg-logo'].strip() != "": + self.logo = params['tvg-logo'].strip() + if "group-title" in params and params['group-title'].strip() != "": + self.group_title = params['group-title'].strip() + if 'title' in res: + self.title = res['title'] + +class Manager(): + + def __init__(self): + os.system("mkdir -p '%s'" % PROVIDERS_PATH) + + def download_playlist(self, provider): + success = False + try: + response = requests.get(provider.url, timeout=10) + if response.status_code == 200: + print("Download success") + try: + source = response.content.decode("UTF-8") + except UnicodeDecodeError as e: + source = response.content.decode("latin1") + if (source.count("#EXTM3U") > 0 and source.count("#EXTINF") > 0): + print("Content looks legit") + with open(provider.path, "w") as file: + file.write(source) + success = True + except Exception as e: + print(e) + finally: + return success + + def load_channels(self, provider): + with open(provider.path, "r") as file: + channel = None + group = None + groups = {} + for line in file: + line = line.strip() + if line.startswith("#EXTM3U"): + continue + if line.startswith("#EXTINF"): + channel = Channel(line) + continue + if "://" in line: + if channel == None: + continue + if channel.url != None: + # We already found the URL, skip the line + continue + if channel.name == None or "***" in channel.name: + continue + channel.url = line + provider.channels.append(channel) + if channel.group_title != None and channel.group_title.strip() != "": + if group == None or group.name != channel.group_title: + if channel.group_title in groups.keys(): + group = groups[channel.group_title] + else: + group = Group(channel.group_title) + provider.groups.append(group) + groups[channel.group_title] = group + group.channels.append(channel) + diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py new file mode 100755 index 0000000..a2a7cdf --- /dev/null +++ b/usr/lib/hypnotix/hypnotix.py @@ -0,0 +1,288 @@ +#!/usr/bin/python3 +import gettext +import gi +import locale +import os +import re +import setproctitle +import shutil +import subprocess +import warnings + +# Suppress GTK deprecation warnings +warnings.filterwarnings("ignore") + +gi.require_version("Gtk", "3.0") +gi.require_version('XApp', '1.0') +from gi.repository import Gtk, Gdk, Gio, XApp, GdkPixbuf, GLib + +from common import * + +import mpv + +setproctitle.setproctitle("hypnotix") + +# i18n +APP = 'hypnotix' +LOCALE_DIR = "/usr/share/locale" +locale.bindtextdomain(APP, LOCALE_DIR) +gettext.bindtextdomain(APP, LOCALE_DIR) +gettext.textdomain(APP) +_ = gettext.gettext + +PROVIDER_OBJ, PROVIDER_NAME = range(2) +GROUP_OBJ, GROUP_NAME = range(2) +CHANNEL_OBJ, CHANNEL_NAME, CHANNEL_LOGO = range(3) + +class MyApplication(Gtk.Application): + # Main initialization routine + def __init__(self, application_id, flags): + Gtk.Application.__init__(self, application_id=application_id, flags=flags) + self.connect("activate", self.activate) + + def activate(self, application): + windows = self.get_windows() + if (len(windows) > 0): + window = windows[0] + window.present() + window.show() + else: + window = MainWindow(self) + self.add_window(window.window) + window.window.show() + +class MainWindow(): + + 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.providers = [] + self.loading = False + + # Set the Glade file + gladefile = "/usr/share/hypnotix/hypnotix.ui" + self.builder = Gtk.Builder() + self.builder.set_translation_domain(APP) + self.builder.add_from_file(gladefile) + self.window = self.builder.get_object("main_window") + self.window.set_title(_("Hypnotix")) + self.window.set_icon_name("hypnotix") + + # Create variables to quickly access dynamic widgets + self.headerbar = self.builder.get_object("headerbar") + self.status_label = self.builder.get_object("status_label") + self.provider_combo = self.builder.get_object("provider_combo") + self.group_treeview = self.builder.get_object("group_treeview") + self.channel_treeview = self.builder.get_object("channel_treeview") + self.generic_channel_pixbuf = self.icon_theme.load_icon("tv-symbolic", 22 * self.window.get_scale_factor(), 0) + self.mpv_drawing_area = self.builder.get_object("mpv_drawing_area") + self.stack = self.builder.get_object("stack") + + # Widget signals + self.window.connect("key-press-event",self.on_key_press_event) + self.mpv_drawing_area.connect("realize", self.on_mpv_drawing_area_realize) + + # Menubar + accel_group = Gtk.AccelGroup() + self.window.add_accel_group(accel_group) + menu = self.builder.get_object("main_menu") + item = Gtk.ImageMenuItem() + item.set_image(Gtk.Image.new_from_icon_name("preferences-desktop-keyboard-shortcuts-symbolic", Gtk.IconSize.MENU)) + item.set_label(_("Keyboard Shortcuts")) + item.connect("activate", self.open_keyboard_shortcuts) + key, mod = Gtk.accelerator_parse("<Control>K") + item.add_accelerator("activate", accel_group, key, mod, Gtk.AccelFlags.VISIBLE) + menu.append(item) + item = Gtk.ImageMenuItem() + item.set_image(Gtk.Image.new_from_icon_name("help-about-symbolic", Gtk.IconSize.MENU)) + item.set_label(_("About")) + item.connect("activate", self.open_about) + key, mod = Gtk.accelerator_parse("F1") + item.add_accelerator("activate", accel_group, key, mod, Gtk.AccelFlags.VISIBLE) + menu.append(item) + item = Gtk.ImageMenuItem(label=_("Quit")) + image = Gtk.Image.new_from_icon_name("application-exit-symbolic", Gtk.IconSize.MENU) + item.set_image(image) + item.connect('activate', self.on_menu_quit) + key, mod = Gtk.accelerator_parse("<Control>Q") + item.add_accelerator("activate", accel_group, key, mod, Gtk.AccelFlags.VISIBLE) + key, mod = Gtk.accelerator_parse("<Control>W") + item.add_accelerator("activate", accel_group, key, mod, Gtk.AccelFlags.VISIBLE) + menu.append(item) + menu.show_all() + + # Provider combox + model = Gtk.ListStore(object, str) # obj, name + model.append([None, _("All")]) + renderer = Gtk.CellRendererText() + self.provider_combo.pack_start(renderer, True) + self.provider_combo.add_attribute(renderer, "text", PROVIDER_NAME) + self.provider_combo.set_model(model) + self.provider_combo.set_active(0) # Select 1st + + # Group treeview + column = Gtk.TreeViewColumn("", Gtk.CellRendererText(), text=GROUP_NAME) + column.set_sort_column_id(GROUP_NAME) + column.set_resizable(True) + self.group_treeview.append_column(column) + self.group_treeview.show() + model = Gtk.ListStore(object, str) # object, name + model.set_sort_column_id(GROUP_NAME, Gtk.SortType.ASCENDING) + self.group_treeview.set_model(model) + self.group_treeview.get_selection().connect("changed", self.on_group_selected) + + # Channel treeview + renderer = Gtk.CellRendererPixbuf() + column = Gtk.TreeViewColumn("", renderer, pixbuf=CHANNEL_LOGO) + column.set_cell_data_func(renderer, self.data_func_surface) + column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) + self.channel_treeview.append_column(column) + column = Gtk.TreeViewColumn("", Gtk.CellRendererText(), text=CHANNEL_NAME) + column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) + self.channel_treeview.append_column(column) + self.channel_treeview.show() + model = Gtk.ListStore(object, str, GdkPixbuf.Pixbuf) # obj, name, logo + self.channel_treeview.set_model(model) + self.channel_treeview.get_selection().connect("changed", self.on_channel_selected) + + self.reload() + + self.provider_combo.connect("changed", self.on_provider_changed) + + self.window.show() + + def data_func_surface(self, column, cell, model, iter_, *args): + pixbuf = model.get_value(iter_, CHANNEL_LOGO) + surface = Gdk.cairo_surface_create_from_pixbuf(pixbuf, self.window.get_scale_factor()) + cell.set_property("surface", surface) + + def on_provider_changed(self, widget): + self.show_selected_provider() + + def on_group_selected(self, selection): + if self.loading: + return + model, iter = selection.get_selected() + if iter is not None: + group = model.get_value(iter, GROUP_OBJ) + if group == None: + self.show_selected_provider(reload_groups=False) + else: + print("GROUP: '%s'" % group.name) + self.load_channels(group.channels) + + def on_channel_selected(self, selection): + if self.loading: + return + model, iter = selection.get_selected() + if iter is not None: + channel = model.get_value(iter, CHANNEL_OBJ) + print ("CHANNEL: '%s' (%s)" % (channel.name, channel.url)) + if channel != None and channel.url != None: + #os.system("mpv --wid=%s %s &" % (self.wid, channel.url)) + self.stack.set_visible_child_name("page_player") + self.mpv.play(channel.url) + + def show_selected_provider(self, reload_groups=True): + combo = self.provider_combo + provider = combo.get_model()[combo.get_active()][PROVIDER_OBJ] + if provider == None: + # Show all providers + groups = [] + channels = [] + for provider in self.providers: + groups += provider.groups + channels += provider.channels + else: + groups = provider.groups + channels = provider.channels + + if reload_groups: + self.load_groups(groups) + self.load_channels(channels) + + def load_groups(self, groups): + self.loading = True + model = self.group_treeview.get_model() + model.clear() + model.append([None, _("All")]) + for group in groups: + iter = model.insert_before(None, None) + model.set_value(iter, GROUP_OBJ, group) + model.set_value(iter, GROUP_NAME, group.name) + self.loading = False + + def load_channels(self, channels): + self.loading = True + model = self.channel_treeview.get_model() + model.clear() + for channel in channels: + pixbuf = self.generic_channel_pixbuf + if channel.name != None: + model.insert_with_valuesv(-1, range(3), [channel, channel.name, pixbuf]) + self.loading = False + + def open_keyboard_shortcuts(self, widget): + gladefile = "/usr/share/hypnotix/shortcuts.ui" + builder = Gtk.Builder() + builder.set_translation_domain(APP) + builder.add_from_file(gladefile) + window = builder.get_object("shortcuts") + window.set_title(_("Hypnotix")) + window.show() + + def open_about(self, widget): + dlg = Gtk.AboutDialog() + dlg.set_transient_for(self.window) + dlg.set_title(_("About")) + dlg.set_program_name(_("Hypnotix")) + dlg.set_comments(_("Watch TV")) + try: + h = open('/usr/share/common-licenses/GPL', encoding="utf-8") + s = h.readlines() + gpl = "" + for line in s: + gpl += line + h.close() + dlg.set_license(gpl) + except Exception as e: + print (e) + + dlg.set_version("__DEB_VERSION__") + dlg.set_icon_name("hypnotix") + dlg.set_logo_icon_name("hypnotix") + dlg.set_website("https://www.github.com/linuxmint/hypnotix") + def close(w, res): + if res == Gtk.ResponseType.CANCEL or res == Gtk.ResponseType.DELETE_EVENT: + w.destroy() + dlg.connect("response", close) + dlg.show() + + def on_menu_quit(self, widget): + self.application.quit() + + def on_key_press_event(self, widget, event): + ctrl = (event.state & Gdk.ModifierType.CONTROL_MASK) + if ctrl and event.keyval == Gdk.KEY_r: + self.reload() + + def reload(self): + for provider in self.settings.get_strv("providers"): + (name, url) = provider.split(":::") + provider = Provider(name, url) + self.providers.append(provider) + self.provider_combo.get_model().append([provider, provider.name]) + self.manager.download_playlist(provider) + self.manager.load_channels(provider) + self.show_selected_provider() + + def on_mpv_drawing_area_realize(self, widget): + self.wid = str(widget.get_window().get_xid()) + self.mpv = mpv.MPV(ytdl=True, wid=str(widget.get_window().get_xid())) + +if __name__ == "__main__": + 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 new file mode 100644 index 0000000..f42a3be --- /dev/null +++ b/usr/lib/hypnotix/mpv.py @@ -0,0 +1,1866 @@ +# -*- coding: utf-8 -*- +# vim: ts=4 sw=4 et +# +# Python MPV library module +# Copyright (C) 2017-2020 Sebastian Götte <code@jaseg.net> +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# <http://www.gnu.org/licenses/>. +# + +from ctypes import * +import ctypes.util +import threading +import os +import sys +from warnings import warn +from functools import partial, wraps +from contextlib import contextmanager +import collections +import re +import traceback + +if os.name == 'nt': + dll = ctypes.util.find_library('mpv-1.dll') + 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 ' + '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"].') + backend = CDLL(dll) + fs_enc = 'utf-8' +else: + 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') + + sofile = ctypes.util.find_library('mpv') + if sofile is None: + raise OSError("Cannot find libmpv in the usual places. Depending on your distro, you may try installing an " + "mpv-devel or mpv-libs package. If you have libmpv around but this script can't find it, consult " + "the documentation for ctypes.util.find_library which this script uses to look up the library " + "filename.") + backend = CDLL(sofile) + fs_enc = sys.getfilesystemencoding() + + +class ShutdownError(SystemError): + pass + +class MpvHandle(c_void_p): + pass + +class MpvRenderCtxHandle(c_void_p): + pass + +class MpvOpenGLCbContext(c_void_p): + pass + + +class PropertyUnavailableError(AttributeError): + pass + +class ErrorCode(object): + """For documentation on these, see mpv's libmpv/client.h.""" + SUCCESS = 0 + EVENT_QUEUE_FULL = -1 + NOMEM = -2 + UNINITIALIZED = -3 + INVALID_PARAMETER = -4 + OPTION_NOT_FOUND = -5 + OPTION_FORMAT = -6 + OPTION_ERROR = -7 + PROPERTY_NOT_FOUND = -8 + PROPERTY_FORMAT = -9 + PROPERTY_UNAVAILABLE = -10 + PROPERTY_ERROR = -11 + COMMAND = -12 + LOADING_FAILED = -13 + AO_INIT_FAILED = -14 + VO_INIT_FAILED = -15 + NOTHING_TO_PLAY = -16 + UNKNOWN_FORMAT = -17 + UNSUPPORTED = -18 + NOT_IMPLEMENTED = -19 + GENERIC = -20 + + EXCEPTION_DICT = { + 0: None, + -1: lambda *a: MemoryError('mpv event queue full', *a), + -2: lambda *a: MemoryError('mpv cannot allocate memory', *a), + -3: lambda *a: ValueError('Uninitialized mpv handle used', *a), + -4: lambda *a: ValueError('Invalid value for mpv parameter', *a), + -5: lambda *a: AttributeError('mpv option does not exist', *a), + -6: lambda *a: TypeError('Tried to set mpv option using wrong format', *a), + -7: lambda *a: ValueError('Invalid value for mpv option', *a), + -8: lambda *a: AttributeError('mpv property does not exist', *a), + # Currently (mpv 0.18.1) there is a bug causing a PROPERTY_FORMAT error to be returned instead of + # INVALID_PARAMETER when setting a property-mapped option to an invalid value. + -9: lambda *a: TypeError('Tried to get/set mpv property using wrong format, or passed invalid value', *a), + -10: lambda *a: PropertyUnavailableError('mpv property is not available', *a), + -11: lambda *a: RuntimeError('Generic error getting or setting mpv property', *a), + -12: lambda *a: SystemError('Error running mpv command', *a), + -14: lambda *a: RuntimeError('Initializing the audio output failed', *a), + -15: lambda *a: RuntimeError('Initializing the video output failed'), + -16: lambda *a: RuntimeError('There was no audio or video data to play. This also happens if the file ' + 'was recognized, but did not contain any audio or video streams, or no ' + 'streams were selected.'), + -17: lambda *a: RuntimeError('When trying to load the file, the file format could not be determined, ' + 'or the file was too broken to open it'), + -18: lambda *a: ValueError('Generic error for signaling that certain system requirements are not fulfilled'), + -19: lambda *a: NotImplementedError('The API function which was called is a stub only'), + -20: lambda *a: RuntimeError('Unspecified error') } + + @staticmethod + def default_error_handler(ec, *args): + return ValueError(_mpv_error_string(ec).decode('utf-8'), ec, *args) + + @classmethod + def raise_for_ec(kls, ec, func, *args): + ec = 0 if ec > 0 else ec + ex = kls.EXCEPTION_DICT.get(ec , kls.default_error_handler) + if ex: + raise ex(ec, *args) + +MpvGlGetProcAddressFn = CFUNCTYPE(c_void_p, c_void_p, c_char_p) +class MpvOpenGLInitParams(Structure): + _fields_ = [('get_proc_address', MpvGlGetProcAddressFn), + ('get_proc_address_ctx', c_void_p), + ('extra_exts', c_void_p)] + + def __init__(self, get_proc_address): + self.get_proc_address = get_proc_address + self.get_proc_address_ctx = None + self.extra_exts = None + +class MpvOpenGLFBO(Structure): + _fields_ = [('fbo', c_int), + ('w', c_int), + ('h', c_int), + ('internal_format', c_int)] + + def __init__(self, w, h, fbo=0, internal_format=0): + self.w, self.h = w, h + self.fbo = fbo + self.internal_format = internal_format + +class MpvRenderFrameInfo(Structure): + _fields_ = [('flags', c_int64), + ('target_time', c_int64)] + + def as_dict(self): + return {'flags': self.flags, + 'target_time': self.target_time} + +class MpvOpenGLDRMParams(Structure): + _fields_ = [('fd', c_int), + ('crtc_id', c_int), + ('connector_id', c_int), + ('atomic_request_ptr', c_void_p), + ('render_fd', c_int)] + +class MpvOpenGLDRMDrawSurfaceSize(Structure): + _fields_ = [('width', c_int), ('height', c_int)] + +class MpvOpenGLDRMParamsV2(Structure): + _fields_ = [('fd', c_int), + ('crtc_id', c_int), + ('connector_id', c_int), + ('atomic_request_ptr', c_void_p), + ('render_fd', c_int)] + + def __init__(self, crtc_id, connector_id, atomic_request_ptr, fd=-1, render_fd=-1): + self.crtc_id, self.connector_id = crtc_id, connector_id + self.atomic_request_ptr = atomic_request_ptr + self.fd, self.render_fd = fd, render_fd + + +class MpvRenderParam(Structure): + _fields_ = [('type_id', c_int), + ('data', c_void_p)] + + # maps human-readable type name to (type_id, argtype) tuple. + # The type IDs come from libmpv/render.h + TYPES = {"invalid" :(0, None), + "api_type" :(1, str), + "opengl_init_params" :(2, MpvOpenGLInitParams), + "opengl_fbo" :(3, MpvOpenGLFBO), + "flip_y" :(4, bool), + "depth" :(5, int), + "icc_profile" :(6, bytes), + "ambient_light" :(7, int), + "x11_display" :(8, c_void_p), + "wl_display" :(9, c_void_p), + "advanced_control" :(10, bool), + "next_frame_info" :(11, MpvRenderFrameInfo), + "block_for_target_time" :(12, bool), + "skip_rendering" :(13, bool), + "drm_display" :(14, MpvOpenGLDRMParams), + "drm_draw_surface_size" :(15, MpvOpenGLDRMDrawSurfaceSize), + "drm_display_v2" :(16, MpvOpenGLDRMParamsV2)} + + def __init__(self, name, value=None): + if name not in self.TYPES: + raise ValueError('unknown render param type "{}"'.format(name)) + self.type_id, cons = self.TYPES[name] + if cons is None: + self.value = None + self.data = c_void_p() + elif cons is str: + self.value = value + self.data = cast(c_char_p(value.encode('utf-8')), c_void_p) + elif cons is bytes: + self.value = MpvByteArray(value) + self.data = cast(pointer(self.value), c_void_p) + elif cons is bool: + self.value = c_int(int(bool(value))) + self.data = cast(pointer(self.value), c_void_p) + else: + self.value = cons(**value) + self.data = cast(pointer(self.value), c_void_p) + +def kwargs_to_render_param_array(kwargs): + t = MpvRenderParam * (len(kwargs)+1) + return t(*kwargs.items(), ('invalid', None)) + +class MpvFormat(c_int): + NONE = 0 + STRING = 1 + OSD_STRING = 2 + FLAG = 3 + INT64 = 4 + DOUBLE = 5 + NODE = 6 + NODE_ARRAY = 7 + NODE_MAP = 8 + BYTE_ARRAY = 9 + + def __eq__(self, other): + return self is other or self.value == other or self.value == int(other) + + def __repr__(self): + return ['NONE', 'STRING', 'OSD_STRING', 'FLAG', 'INT64', 'DOUBLE', 'NODE', 'NODE_ARRAY', 'NODE_MAP', + 'BYTE_ARRAY'][self.value] + + def __hash__(self): + return self.value + + +class MpvEventID(c_int): + NONE = 0 + SHUTDOWN = 1 + LOG_MESSAGE = 2 + GET_PROPERTY_REPLY = 3 + SET_PROPERTY_REPLY = 4 + COMMAND_REPLY = 5 + START_FILE = 6 + END_FILE = 7 + FILE_LOADED = 8 + TRACKS_CHANGED = 9 + TRACK_SWITCHED = 10 + IDLE = 11 + PAUSE = 12 + UNPAUSE = 13 + TICK = 14 + SCRIPT_INPUT_DISPATCH = 15 + CLIENT_MESSAGE = 16 + VIDEO_RECONFIG = 17 + AUDIO_RECONFIG = 18 + METADATA_UPDATE = 19 + SEEK = 20 + PLAYBACK_RESTART = 21 + PROPERTY_CHANGE = 22 + CHAPTER_CHANGE = 23 + + ANY = ( SHUTDOWN, LOG_MESSAGE, GET_PROPERTY_REPLY, SET_PROPERTY_REPLY, COMMAND_REPLY, START_FILE, END_FILE, + FILE_LOADED, TRACKS_CHANGED, TRACK_SWITCHED, IDLE, PAUSE, UNPAUSE, TICK, SCRIPT_INPUT_DISPATCH, + CLIENT_MESSAGE, VIDEO_RECONFIG, AUDIO_RECONFIG, METADATA_UPDATE, SEEK, PLAYBACK_RESTART, PROPERTY_CHANGE, + CHAPTER_CHANGE ) + + def __repr__(self): + return ['NONE', 'SHUTDOWN', 'LOG_MESSAGE', 'GET_PROPERTY_REPLY', 'SET_PROPERTY_REPLY', 'COMMAND_REPLY', + 'START_FILE', 'END_FILE', 'FILE_LOADED', 'TRACKS_CHANGED', 'TRACK_SWITCHED', 'IDLE', 'PAUSE', 'UNPAUSE', + 'TICK', 'SCRIPT_INPUT_DISPATCH', 'CLIENT_MESSAGE', 'VIDEO_RECONFIG', 'AUDIO_RECONFIG', + 'METADATA_UPDATE', 'SEEK', 'PLAYBACK_RESTART', 'PROPERTY_CHANGE', 'CHAPTER_CHANGE'][self.value] + + @classmethod + def from_str(kls, s): + return getattr(kls, s.upper().replace('-', '_')) + + +identity_decoder = lambda b: b +strict_decoder = lambda b: b.decode('utf-8') +def lazy_decoder(b): + try: + return b.decode('utf-8') + except UnicodeDecodeError: + return b + +class MpvNodeList(Structure): + def array_value(self, decoder=identity_decoder): + return [ self.values[i].node_value(decoder) for i in range(self.num) ] + + def dict_value(self, decoder=identity_decoder): + return { self.keys[i].decode('utf-8'): + self.values[i].node_value(decoder) for i in range(self.num) } + +class MpvByteArray(Structure): + _fields_ = [('data', c_void_p), + ('size', c_size_t)] + + def __init__(self, value): + self._value = value + self.data = cast(c_char_p(value), c_void_p) + self.size = len(value) + + def bytes_value(self): + return cast(self.data, POINTER(c_char))[:self.size] + +class MpvNode(Structure): + def node_value(self, decoder=identity_decoder): + return MpvNode.node_cast_value(self.val, self.format.value, decoder) + + @staticmethod + def node_cast_value(v, fmt=MpvFormat.NODE, decoder=identity_decoder): + if fmt == MpvFormat.NONE: + return None + elif fmt == MpvFormat.STRING: + return decoder(v.string) + elif fmt == MpvFormat.OSD_STRING: + return v.string.decode('utf-8') + elif fmt == MpvFormat.FLAG: + return bool(v.flag) + elif fmt == MpvFormat.INT64: + return v.int64 + elif fmt == MpvFormat.DOUBLE: + return v.double + else: + if not v.node: # Check for null pointer + return None + if fmt == MpvFormat.NODE: + return v.node.contents.node_value(decoder) + elif fmt == MpvFormat.NODE_ARRAY: + return v.list.contents.array_value(decoder) + elif fmt == MpvFormat.NODE_MAP: + return v.map.contents.dict_value(decoder) + elif fmt == MpvFormat.BYTE_ARRAY: + return v.byte_array.contents.bytes_value() + else: + raise TypeError('Unknown MPV node format {}. Please submit a bug report.'.format(fmt)) + +class MpvNodeUnion(Union): + _fields_ = [('string', c_char_p), + ('flag', c_int), + ('int64', c_int64), + ('double', c_double), + ('node', POINTER(MpvNode)), + ('list', POINTER(MpvNodeList)), + ('map', POINTER(MpvNodeList)), + ('byte_array', POINTER(MpvByteArray))] + +MpvNode._fields_ = [('val', MpvNodeUnion), + ('format', MpvFormat)] + +MpvNodeList._fields_ = [('num', c_int), + ('values', POINTER(MpvNode)), + ('keys', POINTER(c_char_p))] + +class MpvSubApi(c_int): + MPV_SUB_API_OPENGL_CB = 1 + +class MpvEvent(Structure): + _fields_ = [('event_id', MpvEventID), + ('error', c_int), + ('reply_userdata', c_ulonglong), + ('data', c_void_p)] + + def as_dict(self, decoder=identity_decoder): + dtype = {MpvEventID.END_FILE: MpvEventEndFile, + MpvEventID.PROPERTY_CHANGE: MpvEventProperty, + MpvEventID.GET_PROPERTY_REPLY: MpvEventProperty, + MpvEventID.LOG_MESSAGE: MpvEventLogMessage, + MpvEventID.SCRIPT_INPUT_DISPATCH: MpvEventScriptInputDispatch, + MpvEventID.CLIENT_MESSAGE: MpvEventClientMessage + }.get(self.event_id.value, None) + return {'event_id': self.event_id.value, + 'error': self.error, + 'reply_userdata': self.reply_userdata, + 'event': cast(self.data, POINTER(dtype)).contents.as_dict(decoder=decoder) if dtype else None} + +class MpvEventProperty(Structure): + _fields_ = [('name', c_char_p), + ('format', MpvFormat), + ('data', MpvNodeUnion)] + def as_dict(self, decoder=identity_decoder): + value = MpvNode.node_cast_value(self.data, self.format.value, decoder) + return {'name': self.name.decode('utf-8'), + 'format': self.format, + 'data': self.data, + 'value': value} + +class MpvEventLogMessage(Structure): + _fields_ = [('prefix', c_char_p), + ('level', c_char_p), + ('text', c_char_p)] + + def as_dict(self, decoder=identity_decoder): + return { 'prefix': self.prefix.decode('utf-8'), + 'level': self.level.decode('utf-8'), + 'text': decoder(self.text).rstrip() } + +class MpvEventEndFile(Structure): + _fields_ = [('reason', c_int), + ('error', c_int)] + + EOF = 0 + RESTARTED = 1 + ABORTED = 2 + QUIT = 3 + ERROR = 4 + REDIRECT = 5 + + # For backwards-compatibility + @property + def value(self): + return self.reason + + def as_dict(self, decoder=identity_decoder): + return {'reason': self.reason, 'error': self.error} + +class MpvEventScriptInputDispatch(Structure): + _fields_ = [('arg0', c_int), + ('type', c_char_p)] + + def as_dict(self, decoder=identity_decoder): + pass # TODO + +class MpvEventClientMessage(Structure): + _fields_ = [('num_args', c_int), + ('args', POINTER(c_char_p))] + + def as_dict(self, decoder=identity_decoder): + return { 'args': [ self.args[i].decode('utf-8') for i in range(self.num_args) ] } + +StreamReadFn = CFUNCTYPE(c_int64, c_void_p, POINTER(c_char), c_uint64) +StreamSeekFn = CFUNCTYPE(c_int64, c_void_p, c_int64) +StreamSizeFn = CFUNCTYPE(c_int64, c_void_p) +StreamCloseFn = CFUNCTYPE(None, c_void_p) +StreamCancelFn = CFUNCTYPE(None, c_void_p) + +class StreamCallbackInfo(Structure): + _fields_ = [('cookie', c_void_p), + ('read', StreamReadFn), + ('seek', StreamSeekFn), + ('size', StreamSizeFn), + ('close', StreamCloseFn), ] +# ('cancel', StreamCancelFn)] + +StreamOpenFn = CFUNCTYPE(c_int, c_void_p, c_char_p, POINTER(StreamCallbackInfo)) + +WakeupCallback = CFUNCTYPE(None, c_void_p) + +RenderUpdateFn = CFUNCTYPE(None, c_void_p) + +OpenGlCbUpdateFn = CFUNCTYPE(None, c_void_p) +OpenGlCbGetProcAddrFn = CFUNCTYPE(c_void_p, c_void_p, c_char_p) + +def _handle_func(name, args, restype, errcheck, ctx=MpvHandle, deprecated=False): + func = getattr(backend, name) + func.argtypes = [ctx] + args if ctx else args + if restype is not None: + func.restype = restype + if errcheck is not None: + func.errcheck = errcheck + if deprecated: + @wraps(func) + def wrapper(*args, **kwargs): + if not wrapper.warned: # Only warn on first invocation to prevent spamming + warn("Backend C api has been deprecated: " + name, DeprecationWarning, stacklevel=2) + wrapper.warned = True + return func(*args, **kwargs) + wrapper.warned = False + + globals()['_'+name] = wrapper + else: + globals()['_'+name] = func + +def bytes_free_errcheck(res, func, *args): + notnull_errcheck(res, func, *args) + rv = cast(res, c_void_p).value + _mpv_free(res) + return rv + +def notnull_errcheck(res, func, *args): + if res is None: + raise RuntimeError('Underspecified error in MPV when calling {} with args {!r}: NULL pointer returned.'\ + 'Please consult your local debugger.'.format(func.__name__, args)) + return res + +ec_errcheck = ErrorCode.raise_for_ec + +def _handle_gl_func(name, args=[], restype=None, deprecated=False): + _handle_func(name, args, restype, errcheck=None, ctx=MpvOpenGLCbContext, deprecated=deprecated) + +backend.mpv_client_api_version.restype = c_ulong +def _mpv_client_api_version(): + ver = backend.mpv_client_api_version() + return ver>>16, ver&0xFFFF + +backend.mpv_free.argtypes = [c_void_p] +_mpv_free = backend.mpv_free + +backend.mpv_free_node_contents.argtypes = [c_void_p] +_mpv_free_node_contents = backend.mpv_free_node_contents + +backend.mpv_create.restype = MpvHandle +_mpv_create = backend.mpv_create + +_handle_func('mpv_create_client', [c_char_p], MpvHandle, notnull_errcheck) +_handle_func('mpv_client_name', [], c_char_p, errcheck=None) +_handle_func('mpv_initialize', [], c_int, ec_errcheck) +_handle_func('mpv_detach_destroy', [], None, errcheck=None) +_handle_func('mpv_terminate_destroy', [], None, errcheck=None) +_handle_func('mpv_load_config_file', [c_char_p], c_int, ec_errcheck) +_handle_func('mpv_get_time_us', [], c_ulonglong, errcheck=None) + +_handle_func('mpv_set_option', [c_char_p, MpvFormat, c_void_p], c_int, ec_errcheck) +_handle_func('mpv_set_option_string', [c_char_p, c_char_p], c_int, ec_errcheck) + +_handle_func('mpv_command', [POINTER(c_char_p)], c_int, ec_errcheck) +_handle_func('mpv_command_string', [c_char_p, c_char_p], c_int, ec_errcheck) +_handle_func('mpv_command_async', [c_ulonglong, POINTER(c_char_p)], c_int, ec_errcheck) +_handle_func('mpv_command_node', [POINTER(MpvNode), POINTER(MpvNode)], c_int, ec_errcheck) +_handle_func('mpv_command_async', [c_ulonglong, POINTER(MpvNode)], c_int, ec_errcheck) + +_handle_func('mpv_set_property', [c_char_p, MpvFormat, c_void_p], c_int, ec_errcheck) +_handle_func('mpv_set_property_string', [c_char_p, c_char_p], c_int, ec_errcheck) +_handle_func('mpv_set_property_async', [c_ulonglong, c_char_p, MpvFormat,c_void_p],c_int, ec_errcheck) +_handle_func('mpv_get_property', [c_char_p, MpvFormat, c_void_p], c_int, ec_errcheck) +_handle_func('mpv_get_property_string', [c_char_p], c_void_p, bytes_free_errcheck) +_handle_func('mpv_get_property_osd_string', [c_char_p], c_void_p, bytes_free_errcheck) +_handle_func('mpv_get_property_async', [c_ulonglong, c_char_p, MpvFormat], c_int, ec_errcheck) +_handle_func('mpv_observe_property', [c_ulonglong, c_char_p, MpvFormat], c_int, ec_errcheck) +_handle_func('mpv_unobserve_property', [c_ulonglong], c_int, ec_errcheck) + +_handle_func('mpv_event_name', [c_int], c_char_p, errcheck=None, ctx=None) +_handle_func('mpv_error_string', [c_int], c_char_p, errcheck=None, ctx=None) + +_handle_func('mpv_request_event', [MpvEventID, c_int], c_int, ec_errcheck) +_handle_func('mpv_request_log_messages', [c_char_p], c_int, ec_errcheck) +_handle_func('mpv_wait_event', [c_double], POINTER(MpvEvent), errcheck=None) +_handle_func('mpv_wakeup', [], None, errcheck=None) +_handle_func('mpv_set_wakeup_callback', [WakeupCallback, c_void_p], None, errcheck=None) +_handle_func('mpv_get_wakeup_pipe', [], c_int, errcheck=None) + +_handle_func('mpv_stream_cb_add_ro', [c_char_p, c_void_p, StreamOpenFn], c_int, ec_errcheck) + +_handle_func('mpv_render_context_create', [MpvRenderCtxHandle, MpvHandle, POINTER(MpvRenderParam)], c_int, ec_errcheck, ctx=None) +_handle_func('mpv_render_context_set_parameter', [MpvRenderParam], c_int, ec_errcheck, ctx=MpvRenderCtxHandle) +_handle_func('mpv_render_context_get_info', [MpvRenderParam], c_int, ec_errcheck, ctx=MpvRenderCtxHandle) +_handle_func('mpv_render_context_set_update_callback', [RenderUpdateFn, c_void_p], None, errcheck=None, ctx=MpvRenderCtxHandle) +_handle_func('mpv_render_context_update', [], c_int64, errcheck=None, ctx=MpvRenderCtxHandle) +_handle_func('mpv_render_context_render', [POINTER(MpvRenderParam)], c_int, ec_errcheck, ctx=MpvRenderCtxHandle) +_handle_func('mpv_render_context_report_swap', [], None, errcheck=None, ctx=MpvRenderCtxHandle) +_handle_func('mpv_render_context_free', [], None, errcheck=None, ctx=MpvRenderCtxHandle) + + +# Deprecated in v0.29.0 and may disappear eventually +if hasattr(backend, 'mpv_get_sub_api'): + _handle_func('mpv_get_sub_api', [MpvSubApi], c_void_p, notnull_errcheck, deprecated=True) + + _handle_gl_func('mpv_opengl_cb_set_update_callback', [OpenGlCbUpdateFn, c_void_p], deprecated=True) + _handle_gl_func('mpv_opengl_cb_init_gl', [c_char_p, OpenGlCbGetProcAddrFn, c_void_p], c_int, deprecated=True) + _handle_gl_func('mpv_opengl_cb_draw', [c_int, c_int, c_int], c_int, deprecated=True) + _handle_gl_func('mpv_opengl_cb_render', [c_int, c_int], c_int, deprecated=True) + _handle_gl_func('mpv_opengl_cb_report_flip', [c_ulonglong], c_int, deprecated=True) + _handle_gl_func('mpv_opengl_cb_uninit_gl', [], c_int, deprecated=True) + + +def _mpv_coax_proptype(value, proptype=str): + """Intelligently coax the given python value into something that can be understood as a proptype property.""" + if type(value) is bytes: + return value; + elif type(value) is bool: + return b'yes' if value else b'no' + elif proptype in (str, int, float): + return str(proptype(value)).encode('utf-8') + else: + raise TypeError('Cannot coax value of type {} into property type {}'.format(type(value), proptype)) + +def _make_node_str_list(l): + """Take a list of python objects and make a MPV string node array from it. + + As an example, the python list ``l = [ "foo", 23, false ]`` will result in the following MPV node object:: + + struct mpv_node { + .format = MPV_NODE_ARRAY, + .u.list = *(struct mpv_node_array){ + .num = len(l), + .keys = NULL, + .values = struct mpv_node[len(l)] { + { .format = MPV_NODE_STRING, .u.string = l[0] }, + { .format = MPV_NODE_STRING, .u.string = l[1] }, + ... + } + } + } + """ + char_ps = [ c_char_p(_mpv_coax_proptype(e, str)) for e in l ] + node_list = MpvNodeList( + num=len(l), + keys=None, + values=( MpvNode * len(l))( *[ MpvNode( + format=MpvFormat.STRING, + val=MpvNodeUnion(string=p)) + for p in char_ps ])) + node = MpvNode( + format=MpvFormat.NODE_ARRAY, + val=MpvNodeUnion(list=pointer(node_list))) + return char_ps, node_list, node, cast(pointer(node), c_void_p) + + +def _event_generator(handle): + while True: + event = _mpv_wait_event(handle, -1).contents + if event.event_id.value == MpvEventID.NONE: + raise StopIteration() + yield event + + +_py_to_mpv = lambda name: name.replace('_', '-') +_mpv_to_py = lambda name: name.replace('-', '_') + +_drop_nones = lambda *args: [ arg for arg in args if arg is not None ] + +class _Proxy: + def __init__(self, mpv): + super().__setattr__('mpv', mpv) + +class _PropertyProxy(_Proxy): + def __dir__(self): + return super().__dir__() + [ name.replace('-', '_') for name in self.mpv.property_list ] + +class _FileLocalProxy(_Proxy): + def __getitem__(self, name): + return self.mpv.__getitem__(name, file_local=True) + + def __setitem__(self, name, value): + return self.mpv.__setitem__(name, value, file_local=True) + + def __iter__(self): + return iter(self.mpv) + +class _OSDPropertyProxy(_PropertyProxy): + def __getattr__(self, name): + return self.mpv._get_property(_py_to_mpv(name), fmt=MpvFormat.OSD_STRING) + + def __setattr__(self, _name, _value): + raise AttributeError('OSD properties are read-only. Please use the regular property API for writing.') + +class _DecoderPropertyProxy(_PropertyProxy): + def __init__(self, mpv, decoder): + super().__init__(mpv) + super().__setattr__('_decoder', decoder) + + def __getattr__(self, name): + return self.mpv._get_property(_py_to_mpv(name), decoder=self._decoder) + + def __setattr__(self, name, value): + setattr(self.mpv, _py_to_mpv(name), value) + +class GeneratorStream: + """Transform a python generator into an mpv-compatible stream object. This only supports size() and read(), and + does not support seek(), close() or cancel(). + """ + + def __init__(self, generator_fun, size=None): + self._generator_fun = generator_fun + self.size = size + + def seek(self, offset): + self._read_iter = iter(self._generator_fun()) + self._read_chunk = b'' + return 0 # We only support seeking to the first byte atm + # implementation in case seeking to arbitrary offsets would be necessary + # while offset > 0: + # offset -= len(self.read(offset)) + # return offset + + def read(self, size): + if not self._read_chunk: + try: + self._read_chunk += next(self._read_iter) + except StopIteration: + return b'' + rv, self._read_chunk = self._read_chunk[:size], self._read_chunk[size:] + return rv + + def close(self): + self._read_iter = iter([]) # make next read() call return EOF + + def cancel(self): + self._read_iter = iter([]) # make next read() call return EOF + # TODO? + + +class ImageOverlay: + def __init__(self, m, overlay_id, img=None, pos=(0, 0)): + self.m = m + self.overlay_id = overlay_id + self.pos = pos + self._size = None + if img is not None: + self.update(img) + + def update(self, img=None, pos=None): + from PIL import Image + if img is not None: + self.img = img + img = self.img + + w, h = img.size + stride = w*4 + + if pos is not None: + self.pos = pos + x, y = self.pos + + # Pre-multiply alpha channel + bg = Image.new('RGBA', (w, h), (0, 0, 0, 0)) + out = Image.alpha_composite(bg, img) + + # Copy image to ctypes buffer + if img.size != self._size: + self._buf = create_string_buffer(w*h*4) + self._size = img.size + + self._buf[:] = out.tobytes('raw', 'BGRA') + source = '&' + str(addressof(self._buf)) + + self.m.overlay_add(self.overlay_id, x, y, source, 0, 'bgra', w, h, stride) + + def remove(self): + self.m.remove_overlay(self.overlay_id) + + +class FileOverlay: + def __init__(self, m, overlay_id, filename=None, size=None, stride=None, pos=(0,0)): + self.m = m + self.overlay_id = overlay_id + self.pos = pos + self.size = size + self.stride = stride + if filename is not None: + self.update(filename) + + def update(self, filename=None, size=None, stride=None, pos=None): + if filename is not None: + self.filename = filename + + if pos is not None: + self.pos = pos + + if size is not None: + self.size = size + + if stride is not None: + self.stride = stride + + x, y = self.pos + w, h = self.size + stride = self.stride or 4*w + + self.m.overlay_add(self, self.overlay_id, x, y, self.filename, 0, 'bgra', w, h, stride) + + def remove(self): + self.m.remove_overlay(self.overlay_id) + + +class MPV(object): + """See man mpv(1) for the details of the implemented commands. All mpv properties can be accessed as + ``my_mpv.some_property`` and all mpv options can be accessed as ``my_mpv['some-option']``. + + By default, properties are returned as decoded ``str`` and an error is thrown if the value does not contain valid + utf-8. To get a decoded ``str`` if possibly but ``bytes`` instead of an error if not, use + ``my_mpv.lazy.some_property``. To always get raw ``bytes``, use ``my_mpv.raw.some_property``. To access a + property's decoded OSD value, use ``my_mpv.osd.some_property``. + + To get API information on an option, use ``my_mpv.option_info('option-name')``. To get API information on a + property, use ``my_mpv.properties['property-name']``. Take care to use mpv's dashed-names instead of the + underscore_names exposed on the python object. + + To make your program not barf hard the first time its used on a weird file system **always** access properties + containing file names or file tags through ``MPV.raw``. """ + def __init__(self, *extra_mpv_flags, log_handler=None, start_event_thread=True, loglevel=None, **extra_mpv_opts): + """Create an MPV instance. + + Extra arguments and extra keyword arguments will be passed to mpv as options. + """ + + self.handle = _mpv_create() + self._event_thread = None + self._core_shutdown = False + + _mpv_set_option_string(self.handle, b'audio-display', b'no') + istr = lambda o: ('yes' if o else 'no') if type(o) is bool else str(o) + try: + for flag in extra_mpv_flags: + _mpv_set_option_string(self.handle, flag.encode('utf-8'), b'') + for k,v in extra_mpv_opts.items(): + _mpv_set_option_string(self.handle, k.replace('_', '-').encode('utf-8'), istr(v).encode('utf-8')) + finally: + _mpv_initialize(self.handle) + + self.osd = _OSDPropertyProxy(self) + self.file_local = _FileLocalProxy(self) + self.raw = _DecoderPropertyProxy(self, identity_decoder) + self.strict = _DecoderPropertyProxy(self, strict_decoder) + self.lazy = _DecoderPropertyProxy(self, lazy_decoder) + + self._event_callbacks = [] + self._event_handler_lock = threading.Lock() + self._property_handlers = collections.defaultdict(lambda: []) + self._quit_handlers = set() + self._message_handlers = {} + self._key_binding_handlers = {} + self._event_handle = _mpv_create_client(self.handle, b'py_event_handler') + self._log_handler = log_handler + self._stream_protocol_cbs = {} + self._stream_protocol_frontends = collections.defaultdict(lambda: {}) + self.register_stream_protocol('python', self._python_stream_open) + self._python_streams = {} + self._python_stream_catchall = None + self.overlay_ids = set() + self.overlays = {} + if loglevel is not None or log_handler is not None: + self.set_loglevel(loglevel or 'terminal-default') + if start_event_thread: + self._event_thread = threading.Thread(target=self._loop, name='MPVEventHandlerThread') + self._event_thread.setDaemon(True) + self._event_thread.start() + else: + self._event_thread = None + + def _loop(self): + for event in _event_generator(self._event_handle): + try: + devent = event.as_dict(decoder=lazy_decoder) # copy data from ctypes + eid = devent['event_id'] + + with self._event_handler_lock: + if eid == MpvEventID.SHUTDOWN: + self._core_shutdown = True + + for callback in self._event_callbacks: + callback(devent) + + if eid == MpvEventID.PROPERTY_CHANGE: + pc = devent['event'] + name, value, _fmt = pc['name'], pc['value'], pc['format'] + for handler in self._property_handlers[name]: + handler(name, value) + + if eid == MpvEventID.LOG_MESSAGE and self._log_handler is not None: + ev = devent['event'] + self._log_handler(ev['level'], ev['prefix'], ev['text']) + + if eid == MpvEventID.CLIENT_MESSAGE: + # {'event': {'args': ['key-binding', 'foo', 'u-', 'g']}, 'reply_userdata': 0, 'error': 0, 'event_id': 16} + target, *args = devent['event']['args'] + if target in self._message_handlers: + self._message_handlers[target](*args) + + if eid == MpvEventID.SHUTDOWN: + _mpv_detach_destroy(self._event_handle) + return + + except Exception as e: + print('Exception inside python-mpv event loop:', file=sys.stderr) + traceback.print_exc() + + @property + def core_shutdown(self): + """Property indicating whether the core has been shut down. Possible causes for this are e.g. the `quit` command + or a user closing the mpv window.""" + return self._core_shutdown + + def check_core_alive(self): + """ This method can be used as a sanity check to tests whether the core is still alive at the time it is + called.""" + if self._core_shutdown: + raise ShutdownError('libmpv core has been shutdown') + + def wait_until_paused(self): + """Waits until playback of the current title is paused or done. Raises a ShutdownError if the core is shutdown while + waiting.""" + self.wait_for_property('core-idle') + + def wait_for_playback(self): + """Waits until playback of the current title is finished. Raises a ShutdownError if the core is shutdown while + waiting. + """ + self.wait_for_event('end_file') + + def wait_until_playing(self): + """Waits until playback of the current title has started. Raises a ShutdownError if the core is shutdown while + waiting.""" + self.wait_for_property('core-idle', lambda idle: not idle) + + def wait_for_property(self, name, cond=lambda val: val, level_sensitive=True): + """Waits until ``cond`` evaluates to a truthy value on the named property. This can be used to wait for + properties such as ``idle_active`` indicating the player is done with regular playback and just idling around. + Raises a ShutdownError when the core is shutdown while waiting. + """ + with self.prepare_and_wait_for_property(name, cond, level_sensitive): + pass + + def wait_for_shutdown(self): + '''Wait for core to shutdown (e.g. through quit() or terminate()).''' + sema = threading.Semaphore(value=0) + + @self.event_callback('shutdown') + def shutdown_handler(event): + sema.release() + + sema.acquire() + shutdown_handler.unregister_mpv_events() + + @contextmanager + def prepare_and_wait_for_property(self, name, cond=lambda val: val, level_sensitive=True): + """Context manager that waits until ``cond`` evaluates to a truthy value on the named property. See + prepare_and_wait_for_event for usage. + Raises a ShutdownError when the core is shutdown while waiting. + """ + sema = threading.Semaphore(value=0) + + def observer(name, val): + if cond(val): + sema.release() + self.observe_property(name, observer) + + @self.event_callback('shutdown') + def shutdown_handler(event): + sema.release() + + yield + if not level_sensitive or not cond(getattr(self, name.replace('-', '_'))): + sema.acquire() + + self.check_core_alive() + + shutdown_handler.unregister_mpv_events() + self.unobserve_property(name, observer) + + def wait_for_event(self, *event_types, cond=lambda evt: True): + """Waits for the indicated event(s). If cond is given, waits until cond(event) is true. Raises a ShutdownError + if the core is shutdown while waiting. This also happens when 'shutdown' is in event_types. + """ + with self.prepare_and_wait_for_event(*event_types, cond=cond): + pass + + @contextmanager + def prepare_and_wait_for_event(self, *event_types, cond=lambda evt: True): + """Context manager that waits for the indicated event(s) like wait_for_event after running. If cond is given, + waits until cond(event) is true. Raises a ShutdownError if the core is shutdown while waiting. This also happens + when 'shutdown' is in event_types. + + Compared to wait_for_event this handles the case where a thread waits for an event it itself causes in a + thread-safe way. An example from the testsuite is: + + with self.m.prepare_and_wait_for_event('client_message'): + self.m.keypress(key) + + Using just wait_for_event it would be impossible to ensure the event is caught since it may already have been + handled in the interval between keypress(...) running and a subsequent wait_for_event(...) call. + """ + sema = threading.Semaphore(value=0) + + @self.event_callback('shutdown') + def shutdown_handler(event): + sema.release() + + @self.event_callback(*event_types) + def target_handler(evt): + if cond(evt): + sema.release() + + yield + sema.acquire() + + self.check_core_alive() + + shutdown_handler.unregister_mpv_events() + target_handler.unregister_mpv_events() + + def __del__(self): + if self.handle: + self.terminate() + + def terminate(self): + """Properly terminates this player instance. Preferably use this instead of relying on python's garbage + collector to cause this to be called from the object's destructor. + + This method will detach the main libmpv handle and wait for mpv to shut down and the event thread to finish. + """ + self.handle, handle = None, self.handle + if threading.current_thread() is self._event_thread: + raise UserWarning('terminate() should not be called from event thread (e.g. from a callback function). If ' + 'you want to terminate mpv from here, please call quit() instead, then sync the main thread ' + 'against the event thread using e.g. wait_for_shutdown(), then terminate() from the main thread. ' + 'This call has been transformed into a call to quit().') + self.quit() + else: + _mpv_terminate_destroy(handle) + if self._event_thread: + self._event_thread.join() + + def set_loglevel(self, level): + """Set MPV's log level. This adjusts which output will be sent to this object's log handlers. If you just want + mpv's regular terminal output, you don't need to adjust this but just need to pass a log handler to the MPV + constructur such as ``MPV(log_handler=print)``. + + Valid log levels are "no", "fatal", "error", "warn", "info", "v" "debug" and "trace". For details see your mpv's + client.h header file. + """ + _mpv_request_log_messages(self._event_handle, level.encode('utf-8')) + + def command(self, name, *args): + """Execute a raw command.""" + args = [name.encode('utf-8')] + [ (arg if type(arg) is bytes else str(arg).encode('utf-8')) + for arg in args if arg is not None ] + [None] + _mpv_command(self.handle, (c_char_p*len(args))(*args)) + + def node_command(self, name, *args, decoder=strict_decoder): + _1, _2, _3, pointer = _make_node_str_list([name, *args]) + out = cast(create_string_buffer(sizeof(MpvNode)), POINTER(MpvNode)) + ppointer = cast(pointer, POINTER(MpvNode)) + _mpv_command_node(self.handle, ppointer, out) + rv = out.contents.node_value(decoder=decoder) + _mpv_free_node_contents(out) + return rv + + def seek(self, amount, reference="relative", precision="default-precise"): + """Mapped mpv seek command, see man mpv(1).""" + self.command('seek', amount, reference, precision) + + def revert_seek(self): + """Mapped mpv revert_seek command, see man mpv(1).""" + self.command('revert_seek'); + + def frame_step(self): + """Mapped mpv frame-step command, see man mpv(1).""" + self.command('frame-step') + + def frame_back_step(self): + """Mapped mpv frame_back_step command, see man mpv(1).""" + self.command('frame_back_step') + + def property_add(self, name, value=1): + """Add the given value to the property's value. On overflow or underflow, clamp the property to the maximum. If + ``value`` is omitted, assume ``1``. + """ + self.command('add', name, value) + + def property_multiply(self, name, factor): + """Multiply the value of a property with a numeric factor.""" + self.command('multiply', name, factor) + + def cycle(self, name, direction='up'): + """Cycle the given property. ``up`` and ``down`` set the cycle direction. On overflow, set the property back to + the minimum, on underflow set it to the maximum. If ``up`` or ``down`` is omitted, assume ``up``. + """ + self.command('cycle', name, direction) + + def screenshot(self, includes='subtitles', mode='single'): + """Mapped mpv screenshot command, see man mpv(1).""" + self.command('screenshot', includes, mode) + + def screenshot_to_file(self, filename, includes='subtitles'): + """Mapped mpv screenshot_to_file command, see man mpv(1).""" + self.command('screenshot_to_file', filename.encode(fs_enc), includes) + + def screenshot_raw(self, includes='subtitles'): + """Mapped mpv screenshot_raw command, see man mpv(1). Returns a pillow Image object.""" + from PIL import Image + res = self.node_command('screenshot-raw', includes) + if res['format'] != 'bgr0': + raise ValueError('Screenshot in unknown format "{}". Currently, only bgr0 is supported.' + .format(res['format'])) + img = Image.frombytes('RGBA', (res['stride']//4, res['h']), res['data']) + b,g,r,a = img.split() + return Image.merge('RGB', (r,g,b)) + + def allocate_overlay_id(self): + free_ids = set(range(64)) - self.overlay_ids + if not free_ids: + raise IndexError('All overlay IDs are in use') + next_id, *_ = sorted(free_ids) + self.overlay_ids.add(next_id) + return next_id + + def free_overlay_id(self, overlay_id): + self.overlay_ids.remove(overlay_id) + + def create_file_overlay(self, filename=None, size=None, stride=None, pos=(0,0)): + overlay_id = self.allocate_overlay_id() + overlay = FileOverlay(self, overlay_id, filename, size, stride, pos) + self.overlays[overlay_id] = overlay + return overlay + + def create_image_overlay(self, img=None, pos=(0,0)): + overlay_id = self.allocate_overlay_id() + overlay = ImageOverlay(self, overlay_id, img, pos) + self.overlays[overlay_id] = overlay + return overlay + + def remove_overlay(self, overlay_id): + self.overlay_remove(overlay_id) + self.free_overlay_id(overlay_id) + del self.overlays[overlay_id] + + def playlist_next(self, mode='weak'): + """Mapped mpv playlist_next command, see man mpv(1).""" + self.command('playlist_next', mode) + + def playlist_prev(self, mode='weak'): + """Mapped mpv playlist_prev command, see man mpv(1).""" + self.command('playlist_prev', mode) + + def playlist_play_index(self, idx): + """Mapped mpv playlist-play-index command, see man mpv(1).""" + self.command('playlist-play-index', idx) + + @staticmethod + def _encode_options(options): + return ','.join('{}={}'.format(_py_to_mpv(str(key)), str(val)) for key, val in options.items()) + + def loadfile(self, filename, mode='replace', **options): + """Mapped mpv loadfile command, see man mpv(1).""" + self.command('loadfile', filename.encode(fs_enc), mode, MPV._encode_options(options)) + + def loadlist(self, playlist, mode='replace'): + """Mapped mpv loadlist command, see man mpv(1).""" + self.command('loadlist', playlist.encode(fs_enc), mode) + + def playlist_clear(self): + """Mapped mpv playlist_clear command, see man mpv(1).""" + self.command('playlist_clear') + + def playlist_remove(self, index='current'): + """Mapped mpv playlist_remove command, see man mpv(1).""" + self.command('playlist_remove', index) + + def playlist_move(self, index1, index2): + """Mapped mpv playlist_move command, see man mpv(1).""" + self.command('playlist_move', index1, index2) + + def playlist_shuffle(self): + """Mapped mpv playlist-shuffle command, see man mpv(1).""" + self.command('playlist-shuffle') + + def playlist_unshuffle(self): + """Mapped mpv playlist-unshuffle command, see man mpv(1).""" + self.command('playlist-unshuffle') + + def run(self, command, *args): + """Mapped mpv run command, see man mpv(1).""" + self.command('run', command, *args) + + def quit(self, code=None): + """Mapped mpv quit command, see man mpv(1).""" + self.command('quit', code) + + def quit_watch_later(self, code=None): + """Mapped mpv quit_watch_later command, see man mpv(1).""" + self.command('quit_watch_later', code) + + def stop(self, keep_playlist=False): + """Mapped mpv stop command, see man mpv(1).""" + if keep_playlist: + self.command('stop', 'keep-playlist') + else: + self.command('stop') + + def audio_add(self, url, flags='select', title=None, lang=None): + """Mapped mpv audio_add command, see man mpv(1).""" + self.command('audio_add', url.encode(fs_enc), *_drop_nones(flags, title, lang)) + + def audio_remove(self, audio_id=None): + """Mapped mpv audio_remove command, see man mpv(1).""" + self.command('audio_remove', audio_id) + + def audio_reload(self, audio_id=None): + """Mapped mpv audio_reload command, see man mpv(1).""" + self.command('audio_reload', audio_id) + + def video_add(self, url, flags='select', title=None, lang=None): + """Mapped mpv video_add command, see man mpv(1).""" + self.command('video_add', url.encode(fs_enc), *_drop_nones(flags, title, lang)) + + def video_remove(self, video_id=None): + """Mapped mpv video_remove command, see man mpv(1).""" + self.command('video_remove', video_id) + + def video_reload(self, video_id=None): + """Mapped mpv video_reload command, see man mpv(1).""" + self.command('video_reload', video_id) + + def sub_add(self, url, flags='select', title=None, lang=None): + """Mapped mpv sub_add command, see man mpv(1).""" + self.command('sub_add', url.encode(fs_enc), *_drop_nones(flags, title, lang)) + + def sub_remove(self, sub_id=None): + """Mapped mpv sub_remove command, see man mpv(1).""" + self.command('sub_remove', sub_id) + + def sub_reload(self, sub_id=None): + """Mapped mpv sub_reload command, see man mpv(1).""" + self.command('sub_reload', sub_id) + + def sub_step(self, skip): + """Mapped mpv sub_step command, see man mpv(1).""" + self.command('sub_step', skip) + + def sub_seek(self, skip): + """Mapped mpv sub_seek command, see man mpv(1).""" + self.command('sub_seek', skip) + + def toggle_osd(self): + """Mapped mpv osd command, see man mpv(1).""" + self.command('osd') + + def print_text(self, text): + """Mapped mpv print-text command, see man mpv(1).""" + self.command('print-text', text) + + def show_text(self, string, duration='-1', level=None): + """Mapped mpv show_text command, see man mpv(1).""" + self.command('show_text', string, duration, level) + + def expand_text(self, text): + """Mapped mpv expand-text command, see man mpv(1).""" + return self.node_command('expand-text', text) + + def expand_path(self, path): + """Mapped mpv expand-path command, see man mpv(1).""" + return self.node_command('expand-path', path) + + def show_progress(self): + """Mapped mpv show_progress command, see man mpv(1).""" + self.command('show_progress') + + def rescan_external_files(self, mode='reselect'): + """Mapped mpv rescan-external-files command, see man mpv(1).""" + self.command('rescan-external-files', mode) + + def discnav(self, command): + """Mapped mpv discnav command, see man mpv(1).""" + self.command('discnav', command) + + def mouse(x, y, button=None, mode='single'): + """Mapped mpv mouse command, see man mpv(1).""" + if button is None: + self.command('mouse', x, y, mode) + else: + self.command('mouse', x, y, button, mode) + + def keypress(self, name): + """Mapped mpv keypress command, see man mpv(1).""" + self.command('keypress', name) + + def keydown(self, name): + """Mapped mpv keydown command, see man mpv(1).""" + self.command('keydown', name) + + def keyup(self, name=None): + """Mapped mpv keyup command, see man mpv(1).""" + if name is None: + self.command('keyup') + else: + self.command('keyup', name) + + def keybind(self, name, command): + """Mapped mpv keybind command, see man mpv(1).""" + self.command('keybind', name, command) + + def write_watch_later_config(self): + """Mapped mpv write_watch_later_config command, see man mpv(1).""" + self.command('write_watch_later_config') + + def overlay_add(self, overlay_id, x, y, file_or_fd, offset, fmt, w, h, stride): + """Mapped mpv overlay_add command, see man mpv(1).""" + self.command('overlay_add', overlay_id, x, y, file_or_fd, offset, fmt, w, h, stride) + + def overlay_remove(self, overlay_id): + """Mapped mpv overlay_remove command, see man mpv(1).""" + self.command('overlay_remove', overlay_id) + + def script_message(self, *args): + """Mapped mpv script_message command, see man mpv(1).""" + self.command('script_message', *args) + + def script_message_to(self, target, *args): + """Mapped mpv script_message_to command, see man mpv(1).""" + self.command('script_message_to', target, *args) + + def observe_property(self, name, handler): + """Register an observer on the named property. An observer is a function that is called with the new property + value every time the property's value is changed. The basic function signature is ``fun(property_name, + new_value)`` with new_value being the decoded property value as a python object. This function can be used as a + function decorator if no handler is given. + + To unregister the observer, call either of ``mpv.unobserve_property(name, handler)``, + ``mpv.unobserve_all_properties(handler)`` or the handler's ``unregister_mpv_properties`` attribute:: + + @player.observe_property('volume') + def my_handler(new_volume, *): + print("It's loud!", volume) + + my_handler.unregister_mpv_properties() + + exit_handler is a function taking no arguments that is called when the underlying mpv handle is terminated (e.g. + from calling MPV.terminate() or issuing a "quit" input command). + """ + self._property_handlers[name].append(handler) + _mpv_observe_property(self._event_handle, hash(name)&0xffffffffffffffff, name.encode('utf-8'), MpvFormat.NODE) + + def property_observer(self, name): + """Function decorator to register a property observer. See ``MPV.observe_property`` for details.""" + def wrapper(fun): + self.observe_property(name, fun) + fun.unobserve_mpv_properties = lambda: self.unobserve_property(name, fun) + return fun + return wrapper + + def unobserve_property(self, name, handler): + """Unregister a property observer. This requires both the observed property's name and the handler function that + was originally registered as one handler could be registered for several properties. To unregister a handler + from *all* observed properties see ``unobserve_all_properties``. + """ + self._property_handlers[name].remove(handler) + if not self._property_handlers[name]: + _mpv_unobserve_property(self._event_handle, hash(name)&0xffffffffffffffff) + + def unobserve_all_properties(self, handler): + """Unregister a property observer from *all* observed properties.""" + for name in self._property_handlers: + self.unobserve_property(name, handler) + + def register_message_handler(self, target, handler=None): + """Register a mpv script message handler. This can be used to communicate with embedded lua scripts. Pass the + script message target name this handler should be listening to and the handler function. + + WARNING: Only one handler can be registered at a time for any given target. + + To unregister the message handler, call its ``unregister_mpv_messages`` function:: + + player = mpv.MPV() + @player.message_handler('foo') + def my_handler(some, args): + print(args) + + my_handler.unregister_mpv_messages() + """ + self._register_message_handler_internal(target, handler) + + def _register_message_handler_internal(self, target, handler): + self._message_handlers[target] = handler + + def unregister_message_handler(self, target_or_handler): + """Unregister a mpv script message handler for the given script message target name. + + You can also call the ``unregister_mpv_messages`` function attribute set on the handler function when it is + registered. + """ + if isinstance(target_or_handler, str): + del self._message_handlers[target_or_handler] + else: + for key, val in self._message_handlers.items(): + if val == target_or_handler: + del self._message_handlers[key] + + def message_handler(self, target): + """Decorator to register a mpv script message handler. + + WARNING: Only one handler can be registered at a time for any given target. + + To unregister the message handler, call its ``unregister_mpv_messages`` function:: + + player = mpv.MPV() + @player.message_handler('foo') + def my_handler(some, args): + print(args) + + my_handler.unregister_mpv_messages() + """ + def register(handler): + self._register_message_handler_internal(target, handler) + handler.unregister_mpv_messages = lambda: self.unregister_message_handler(handler) + return handler + return register + + def register_event_callback(self, callback): + """Register a blanket event callback receiving all event types. + + To unregister the event callback, call its ``unregister_mpv_events`` function:: + + player = mpv.MPV() + @player.event_callback('shutdown') + def my_handler(event): + print('It ded.') + + my_handler.unregister_mpv_events() + """ + self._event_callbacks.append(callback) + + def unregister_event_callback(self, callback): + """Unregiser an event callback.""" + self._event_callbacks.remove(callback) + + def event_callback(self, *event_types): + """Function decorator to register a blanket event callback for the given event types. Event types can be given + as str (e.g. 'start-file'), integer or MpvEventID object. + + WARNING: Due to the way this is filtering events, this decorator cannot be chained with itself. + + To unregister the event callback, call its ``unregister_mpv_events`` function:: + + player = mpv.MPV() + @player.event_callback('shutdown') + def my_handler(event): + print('It ded.') + + my_handler.unregister_mpv_events() + """ + def register(callback): + with self._event_handler_lock: + self.check_core_alive() + types = [MpvEventID.from_str(t) if isinstance(t, str) else t for t in event_types] or MpvEventID.ANY + @wraps(callback) + def wrapper(event, *args, **kwargs): + if event['event_id'] in types: + callback(event, *args, **kwargs) + self._event_callbacks.append(wrapper) + wrapper.unregister_mpv_events = partial(self.unregister_event_callback, wrapper) + return wrapper + return register + + @staticmethod + def _binding_name(callback_or_cmd): + return 'py_kb_{:016x}'.format(hash(callback_or_cmd)&0xffffffffffffffff) + + def on_key_press(self, keydef, mode='force'): + """Function decorator to register a simplified key binding. The callback is called whenever the key given is + *pressed*. + + To unregister the callback function, you can call its ``unregister_mpv_key_bindings`` attribute:: + + player = mpv.MPV() + @player.on_key_press('Q') + def binding(): + print('blep') + + binding.unregister_mpv_key_bindings() + + WARNING: For a single keydef only a single callback/command can be registered at the same time. If you register + a binding multiple times older bindings will be overwritten and there is a possibility of references leaking. So + don't do that. + + The BIG FAT WARNING regarding untrusted keydefs from the key_binding method applies here as well. + """ + def register(fun): + @self.key_binding(keydef, mode) + @wraps(fun) + def wrapper(state='p-', name=None, char=None): + if state[0] in ('d', 'p'): + fun() + return wrapper + return register + + def key_binding(self, keydef, mode='force'): + """Function decorator to register a low-level key binding. + + The callback function signature is ``fun(key_state, key_name)`` where ``key_state`` is either ``'U'`` for "key + up" or ``'D'`` for "key down". + + The keydef format is: ``[Shift+][Ctrl+][Alt+][Meta+]<key>`` where ``<key>`` is either the literal character the + key produces (ASCII or Unicode character), or a symbolic name (as printed by ``mpv --input-keylist``). + + To unregister the callback function, you can call its ``unregister_mpv_key_bindings`` attribute:: + + player = mpv.MPV() + @player.key_binding('Q') + def binding(state, name, char): + print('blep') + + binding.unregister_mpv_key_bindings() + + WARNING: For a single keydef only a single callback/command can be registered at the same time. If you register + a binding multiple times older bindings will be overwritten and there is a possibility of references leaking. So + don't do that. + + BIG FAT WARNING: mpv's key binding mechanism is pretty powerful. This means, you essentially get arbitrary code + exectution through key bindings. This interface makes some limited effort to sanitize the keydef given in the + first parameter, but YOU SHOULD NOT RELY ON THIS IN FOR SECURITY. If your input comes from config files, this is + completely fine--but, if you are about to pass untrusted input into this parameter, better double-check whether + this is secure in your case. + """ + def register(fun): + fun.mpv_key_bindings = getattr(fun, 'mpv_key_bindings', []) + [keydef] + def unregister_all(): + for keydef in fun.mpv_key_bindings: + self.unregister_key_binding(keydef) + fun.unregister_mpv_key_bindings = unregister_all + + self.register_key_binding(keydef, fun, mode) + return fun + return register + + def register_key_binding(self, keydef, callback_or_cmd, mode='force'): + """Register a key binding. This takes an mpv keydef and either a string containing a mpv command or a python + callback function. See ``MPV.key_binding`` for details. + """ + if not re.match(r'(Shift+)?(Ctrl+)?(Alt+)?(Meta+)?(.|\w+)', keydef): + raise ValueError('Invalid keydef. Expected format: [Shift+][Ctrl+][Alt+][Meta+]<key>\n' + '<key> is either the literal character the key produces (ASCII or Unicode character), or a ' + 'symbolic name (as printed by --input-keylist') + binding_name = MPV._binding_name(keydef) + if callable(callback_or_cmd): + self._key_binding_handlers[binding_name] = callback_or_cmd + self.register_message_handler('key-binding', self._handle_key_binding_message) + self.command('define-section', + binding_name, '{} script-binding py_event_handler/{}'.format(keydef, binding_name), mode) + elif isinstance(callback_or_cmd, str): + self.command('define-section', binding_name, '{} {}'.format(keydef, callback_or_cmd), mode) + else: + raise TypeError('register_key_binding expects either an str with an mpv command or a python callable.') + self.command('enable-section', binding_name, 'allow-hide-cursor+allow-vo-dragging') + + def _handle_key_binding_message(self, binding_name, key_state, key_name=None, key_char=None): + self._key_binding_handlers[binding_name](key_state, key_name, key_char) + + def unregister_key_binding(self, keydef): + """Unregister a key binding by keydef.""" + binding_name = MPV._binding_name(keydef) + self.command('disable-section', binding_name) + self.command('define-section', binding_name, '') + if binding_name in self._key_binding_handlers: + del self._key_binding_handlers[binding_name] + if not self._key_binding_handlers: + self.unregister_message_handler('key-binding') + + def register_stream_protocol(self, proto, open_fn=None): + """ Register a custom stream protocol as documented in libmpv/stream_cb.h: + https://github.com/mpv-player/mpv/blob/master/libmpv/stream_cb.h + + proto is the protocol scheme, e.g. "foo" for "foo://" urls. + + This function can either be used with two parameters or it can be used as a decorator on the target + function. + + open_fn is a function taking an URI string and returning an mpv stream object. + open_fn may raise a ValueError to signal libmpv the URI could not be opened. + + The mpv stream protocol is as follows: + class Stream: + @property + def size(self): + return None # unknown size + return size # int with size in bytes + + def read(self, size): + ... + return read # non-empty bytes object with input + return b'' # empty byte object signals permanent EOF + + def seek(self, pos): + return new_offset # integer with new byte offset. The new offset may be before the requested offset + in case an exact seek is inconvenient. + + def close(self): + ... + + # def cancel(self): (future API versions only) + # Abort a running read() or seek() operation + # ... + + """ + + def decorator(open_fn): + @StreamOpenFn + def open_backend(_userdata, uri, cb_info): + try: + frontend = open_fn(uri.decode('utf-8')) + except ValueError: + return ErrorCode.LOADING_FAILED + + def read_backend(_userdata, buf, bufsize): + data = frontend.read(bufsize) + for i in range(len(data)): + buf[i] = data[i] + return len(data) + + cb_info.contents.cookie = None + read = cb_info.contents.read = StreamReadFn(read_backend) + close = cb_info.contents.close = StreamCloseFn(lambda _userdata: frontend.close()) + + seek, size, cancel = None, None, None + if hasattr(frontend, 'seek'): + seek = cb_info.contents.seek = StreamSeekFn(lambda _userdata, offx: frontend.seek(offx)) + if hasattr(frontend, 'size') and frontend.size is not None: + size = cb_info.contents.size = StreamSizeFn(lambda _userdata: frontend.size) + + # Future API versions only + # if hasattr(frontend, 'cancel'): + # cb_info.contents.cancel = StreamCancelFn(lambda _userdata: frontend.cancel()) + + # keep frontend and callbacks in memory forever (TODO) + frontend._registered_callbacks = [read, close, seek, size, cancel] + self._stream_protocol_frontends[proto][uri] = frontend + return 0 + + if proto in self._stream_protocol_cbs: + raise KeyError('Stream protocol already registered') + self._stream_protocol_cbs[proto] = [open_backend] + _mpv_stream_cb_add_ro(self.handle, proto.encode('utf-8'), c_void_p(), open_backend) + + return open_fn + + if open_fn is not None: + decorator(open_fn) + return decorator + + # Convenience functions + def play(self, filename): + """Play a path or URL (requires ``ytdl`` option to be set).""" + self.loadfile(filename) + + @property + def playlist_filenames(self): + """Return all playlist item file names/URLs as a list of strs.""" + return [element['filename'] for element in self.playlist] + + def playlist_append(self, filename, **options): + """Append a path or URL to the playlist. This does not start playing the file automatically. To do that, use + ``MPV.loadfile(filename, 'append-play')``.""" + self.loadfile(filename, 'append', **options) + + # "Python stream" logic. This is some porcelain for directly playing data from python generators. + + def _python_stream_open(self, uri): + """Internal handler for python:// protocol streams registered through @python_stream(...) and + @python_stream_catchall + """ + name, = re.fullmatch('python://(.*)', uri).groups() + + if name in self._python_streams: + generator_fun, size = self._python_streams[name] + else: + if self._python_stream_catchall is not None: + generator_fun, size = self._python_stream_catchall(name) + else: + raise ValueError('Python stream name not found and no catch-all defined') + + return GeneratorStream(generator_fun, size) + + def python_stream(self, name=None, size=None): + """Register a generator for the python stream with the given name. + + name is the name, i.e. the part after the "python://" in the URI, that this generator is registered as. + size is the total number of bytes in the stream (if known). + + Any given name can only be registered once. The catch-all can also only be registered once. To unregister a + stream, call the .unregister function set on the callback. + + The generator signals EOF by returning, manually raising StopIteration or by yielding b'', an empty bytes + object. + + The generator may be called multiple times if libmpv seeks or loops. + + See also: @mpv.python_stream_catchall + + @mpv.python_stream('foobar') + def reader(): + for chunk in chunks: + yield chunk + mpv.play('python://foobar') + mpv.wait_for_playback() + reader.unregister() + """ + def register(cb): + if name in self._python_streams: + raise KeyError('Python stream name "{}" is already registered'.format(name)) + self._python_streams[name] = (cb, size) + def unregister(): + if name not in self._python_streams or\ + self._python_streams[name][0] is not cb: # This is just a basic sanity check + raise RuntimeError('Python stream has already been unregistered') + del self._python_streams[name] + cb.unregister = unregister + return cb + return register + + def python_stream_catchall(self, cb): + """ Register a catch-all python stream to be called when no name matches can be found. Use this decorator on a + function that takes a name argument and returns a (generator, size) tuple (with size being None if unknown). + + An invalid URI can be signalled to libmpv by raising a ValueError inside the callback. + + See also: @mpv.python_stream(name, size) + + @mpv.python_stream_catchall + def catchall(name): + if not name.startswith('foo'): + raise ValueError('Unknown Name') + + def foo_reader(): + with open(name, 'rb') as f: + while True: + chunk = f.read(1024) + if not chunk: + break + yield chunk + return foo_reader, None + mpv.play('python://foo23') + mpv.wait_for_playback() + catchall.unregister() + """ + if self._python_stream_catchall is not None: + raise KeyError('A catch-all python stream is already registered') + + self._python_stream_catchall = cb + def unregister(): + if self._python_stream_catchall is not cb: + raise RuntimeError('This catch-all python stream has already been unregistered') + self._python_stream_catchall = None + cb.unregister = unregister + return cb + + # Property accessors + def _get_property(self, name, decoder=strict_decoder, fmt=MpvFormat.NODE): + self.check_core_alive() + out = create_string_buffer(sizeof(MpvNode)) + try: + cval = _mpv_get_property(self.handle, name.encode('utf-8'), fmt, out) + + if fmt is MpvFormat.OSD_STRING: + return cast(out, POINTER(c_char_p)).contents.value.decode('utf-8') + elif fmt is MpvFormat.NODE: + rv = cast(out, POINTER(MpvNode)).contents.node_value(decoder=decoder) + _mpv_free_node_contents(out) + return rv + else: + raise TypeError('_get_property only supports NODE and OSD_STRING formats.') + except PropertyUnavailableError as ex: + return None + + def _set_property(self, name, value): + self.check_core_alive() + ename = name.encode('utf-8') + if isinstance(value, (list, set, dict)): + _1, _2, _3, pointer = _make_node_str_list(value) + _mpv_set_property(self.handle, ename, MpvFormat.NODE, pointer) + else: + _mpv_set_property_string(self.handle, ename, _mpv_coax_proptype(value)) + + def __getattr__(self, name): + return self._get_property(_py_to_mpv(name), lazy_decoder) + + def __setattr__(self, name, value): + try: + if name != 'handle' and not name.startswith('_'): + self._set_property(_py_to_mpv(name), value) + else: + super().__setattr__(name, value) + except AttributeError: + super().__setattr__(name, value) + + def __dir__(self): + return super().__dir__() + [ name.replace('-', '_') for name in self.property_list ] + + @property + def properties(self): + return { name: self.option_info(name) for name in self.property_list } + + # Dict-like option access + def __getitem__(self, name, file_local=False): + """Get an option value.""" + prefix = 'file-local-options/' if file_local else 'options/' + return self._get_property(prefix+name, lazy_decoder) + + def __setitem__(self, name, value, file_local=False): + """Set an option value.""" + prefix = 'file-local-options/' if file_local else 'options/' + return self._set_property(prefix+name, value) + + def __iter__(self): + """Iterate over all option names.""" + return iter(self.options) + + def option_info(self, name): + """Get information on the given option.""" + try: + return self._get_property('option-info/'+name) + except AttributeError: + return None + +class MpvRenderContext: + def __init__(self, mpv, api_type, **kwargs): + self._mpv = mpv + kwargs['api_type'] = api_type + + buf = cast(create_string_buffer(sizeof(MpvRenderCtxHandle)), POINTER(MpvRenderCtxHandle)) + _mpv_render_context_create(buf, mpv.handle, kwargs_to_render_param_array(kwargs)) + self._handle = buf.contents + + def free(self): + _mpv_render_context_free(self._handle) + + def __setattr__(self, name, value): + if name.startswith('_'): + super().__setattr__(name, value) + + elif name == 'update_cb': + func = value if value else (lambda: None) + self._update_cb = value + self._update_fn_wrapper = RenderUpdateFn(lambda _userdata: func()) + _mpv_render_context_set_update_callback(self._handle, self._update_fn_wrapper, None) + + else: + param = MpvRenderParam(name, value) + _mpv_render_context_set_parameter(self._handle, param) + + def __getattr__(self, name): + if name == 'update_cb': + return self._update_cb + + elif name == 'handle': + return self._handle + + param = MpvRenderParam(name) + data_type = type(param.data.contents) + buf = cast(create_string_buffer(sizeof(data_type)), POINTER(data_type)) + param.data = buf + _mpv_render_context_get_info(self._handle, param) + return buf.contents.as_dict() + + def update(self): + """ Calls mpv_render_context_update and returns the MPV_RENDER_UPDATE_FRAME flag (see render.h) """ + return bool(_mpv_render_context_update(self._handle) & 1) + + def render(self, **kwargs): + _mpv_render_context_render(self._handle, kwargs_to_render_param_array(kwargs)) + + def report_swap(self): + _mpv_render_context_report_swap(self._handle) + diff --git a/usr/share/applications/hypnotix.desktop b/usr/share/applications/hypnotix.desktop new file mode 100644 index 0000000..b7ba729 --- /dev/null +++ b/usr/share/applications/hypnotix.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Name=Hypnotix +Comment=Watch TV +Exec=hypnotix +Icon=hypnotix +Terminal=false +Type=Application +Encoding=UTF-8 +Categories=AudioVideo;Video;Player;TV; +StartupNotify=false 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 new file mode 100644 index 0000000..daec270 --- /dev/null +++ b/usr/share/glib-2.0/schemas/org.x.hypnotix.gschema.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<schemalist> + <schema id="org.x.hypnotix" path="/org/x/hypnotix/"> + <key type="as" name="providers"> + <default>['Free-IPTV:::https://raw.githubusercontent.com/Free-IPTV/Countries/master/ZZ_PLAYLIST_ALL_TV.m3u']</default> + <summary></summary> + <description></description> + </key> + </schema> +</schemalist> diff --git a/usr/share/hypnotix/hypnotix.ui b/usr/share/hypnotix/hypnotix.ui new file mode 100644 index 0000000..d3b2a0a --- /dev/null +++ b/usr/share/hypnotix/hypnotix.ui @@ -0,0 +1,224 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Generated with glade 3.22.2 --> +<interface> + <requires lib="gtk+" version="3.20"/> + <object class="GtkMenu" id="main_menu"> + <property name="visible">True</property> + <property name="can_focus">False</property> + </object> + <object class="GtkWindow" id="main_window"> + <property name="can_focus">False</property> + <property name="border_width">12</property> + <property name="default_width">600</property> + <property name="default_height">400</property> + <property name="icon_name">hypnotix</property> + <child type="titlebar"> + <object class="GtkHeaderBar" id="headerbar"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="title" translatable="yes">Hypnotix</property> + <property name="subtitle" translatable="yes">Watch TV</property> + <property name="show_close_button">True</property> + <child> + <object class="GtkBox"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <child> + <placeholder/> + </child> + <child> + <placeholder/> + </child> + <child> + <object class="GtkMenuButton"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="focus_on_click">False</property> + <property name="receives_default">True</property> + <property name="popup">main_menu</property> + <child> + <object class="GtkImage"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="icon_name">open-menu-symbolic</property> + </object> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">2</property> + </packing> + </child> + </object> + </child> + </object> + </child> + <child> + <object class="GtkBox"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="orientation">vertical</property> + <child> + <object class="GtkBox"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="spacing">6</property> + <child> + <object class="GtkBox"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="orientation">vertical</property> + <property name="spacing">6</property> + <child> + <object class="GtkComboBox" id="provider_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkScrolledWindow"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="shadow_type">in</property> + <property name="min_content_height">150</property> + <child> + <object class="GtkTreeView" id="group_treeview"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="headers_visible">False</property> + <child internal-child="selection"> + <object class="GtkTreeSelection"/> + </child> + </object> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + <child> + <object class="GtkScrolledWindow"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="shadow_type">in</property> + <property name="min_content_width">250</property> + <property name="min_content_height">200</property> + <child> + <object class="GtkTreeView" id="channel_treeview"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="headers_visible">False</property> + <property name="enable_search">False</property> + <property name="fixed_height_mode">True</property> + <child internal-child="selection"> + <object class="GtkTreeSelection"/> + </child> + </object> + </child> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">2</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkBox" id="mpv_box"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="orientation">vertical</property> + <child> + <object class="GtkStack" id="stack"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <child> + <object class="GtkBox"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="orientation">vertical</property> + <child> + <placeholder/> + </child> + </object> + <packing> + <property name="name">page_none</property> + </packing> + </child> + <child> + <object class="GtkDrawingArea" id="mpv_drawing_area"> + <property name="visible">True</property> + <property name="can_focus">False</property> + </object> + <packing> + <property name="name">page_player</property> + <property name="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkBox" id="status_bar"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <child> + <placeholder/> + </child> + <child> + <object class="GtkLabel" id="status_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + <child> + <placeholder/> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">2</property> + </packing> + </child> + </object> + </child> + </object> +</interface> diff --git a/usr/share/hypnotix/shortcuts.ui b/usr/share/hypnotix/shortcuts.ui new file mode 100644 index 0000000..e3241b5 --- /dev/null +++ b/usr/share/hypnotix/shortcuts.ui @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.17 --> + <object class="GtkShortcutsWindow" id="shortcuts"> + <property name="modal">1</property> + <child> + <object class="GtkShortcutsSection"> + <property name="visible">1</property> + <property name="section-name">Shortcuts</property> + <property name="max-height">15</property> + <child> + <object class="GtkShortcutsGroup"> + <property name="visible">1</property> + <property name="title" translatable="yes">Blah</property> + <child> + <object class="GtkShortcutsShortcut"> + <property name="visible">1</property> + <property name="accelerator"><ctrl>N</property> + <property name="title" translatable="yes">Blah</property> + </object> + </child> + </object> + </child> + </object> + </child> + </object> +</interface> diff --git a/usr/share/icons/hicolor/scalable/apps/hypnotix.svg b/usr/share/icons/hicolor/scalable/apps/hypnotix.svg new file mode 100644 index 0000000..4aea9b2 --- /dev/null +++ b/usr/share/icons/hicolor/scalable/apps/hypnotix.svg @@ -0,0 +1,512 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + height="96" + width="96" + version="1.1" + id="svg2" + inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)" + sodipodi:docname="hypnotix.svg"> + <metadata + id="metadata782"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + </cc:Work> + </rdf:RDF> + </metadata> + <sodipodi:namedview + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1" + objecttolerance="10" + gridtolerance="10" + guidetolerance="10" + inkscape:pageopacity="0" + inkscape:pageshadow="2" + inkscape:window-width="2560" + inkscape:window-height="1355" + id="namedview780" + showgrid="false" + inkscape:zoom="4.9166667" + inkscape:cx="139.89793" + inkscape:cy="35.538841" + inkscape:window-x="0" + inkscape:window-y="0" + inkscape:window-maximized="1" + inkscape:current-layer="svg2" /> + <defs + id="defs4"> + <linearGradient + id="e"> + <stop + stop-color="#b0b0b0" + offset="0" + id="stop7" /> + <stop + stop-color="#f5f5f5" + offset="1" + id="stop9" /> + </linearGradient> + <linearGradient + id="a" + stop-color="#fff"> + <stop + offset="0" + id="stop12" /> + <stop + stop-opacity="0" + offset="1" + id="stop14" /> + </linearGradient> + <filter + id="i" + height="1.384" + width="1.384" + color-interpolation-filters="sRGB" + y="-.192" + x="-.192"> + <feGaussianBlur + stdDeviation="5.28" + id="feGaussianBlur17" /> + </filter> + <linearGradient + id="j" + y2="138.66" + xlink:href="#a" + gradientUnits="userSpaceOnUse" + x2="48" + y1="20.221" + x1="48" /> + <clipPath + id="f"> + <rect + rx="6" + ry="6" + height="84" + width="84" + y="6" + x="6" + fill="#fff" + id="rect21" /> + </clipPath> + <linearGradient + id="d" + y2="7.0165" + gradientUnits="userSpaceOnUse" + x2="45.448" + gradientTransform="matrix(1.0059 0 0 .99417 100 0)" + y1="92.54" + x1="45.448"> + <stop + offset="0" + id="stop24" /> + <stop + stop-opacity=".58824" + offset="1" + id="stop26" /> + </linearGradient> + <clipPath + id="g"> + <rect + rx="8.0455" + ry="8.0455" + height="112.64" + width="112.64" + y="7.6818" + x="27.682" + fill="#fff" + id="rect29" /> + </clipPath> + <linearGradient + id="v" + y2="6" + xlink:href="#d" + gradientUnits="userSpaceOnUse" + x2="32.251" + gradientTransform="translate(0,1)" + y1="90" + x1="32.251" /> + <radialGradient + id="h" + xlink:href="#a" + gradientUnits="userSpaceOnUse" + cy="90.172" + cx="48" + gradientTransform="matrix(1.1573 0 0 .99591 -7.551 .19713)" + r="42" /> + <linearGradient + id="u" + y2="63.893" + xlink:href="#a" + gradientUnits="userSpaceOnUse" + x2="36.357" + y1="6" + x1="36.357" /> + <linearGradient + id="t" + y2="6" + gradientUnits="userSpaceOnUse" + x2="48" + gradientTransform="translate(.002534 .002375)" + y1="90" + x1="48"> + <stop + stop-color="#8f6033" + offset="0" + id="stop35" /> + <stop + stop-color="#a87849" + offset="1" + id="stop37" /> + </linearGradient> + <linearGradient + id="s" + y2="31.657" + gradientUnits="userSpaceOnUse" + x2="49.996" + y1="71.738" + x1="49.996"> + <stop + stop-color="#bf2222" + offset="0" + id="stop40" /> + <stop + stop-color="#e94c4c" + offset="1" + id="stop42" /> + </linearGradient> + <linearGradient + id="r" + y2="24" + gradientUnits="userSpaceOnUse" + x2="40" + gradientTransform="matrix(1,0,0,1.125,0,-10.5)" + y1="80" + x1="56"> + <stop + stop-color="#e7e7e7" + offset="0" + id="stop45" /> + <stop + stop-color="#fff" + offset="1" + id="stop47" /> + </linearGradient> + <linearGradient + id="q" + y2="6" + xlink:href="#e" + gradientUnits="userSpaceOnUse" + x2="51" + gradientTransform="matrix(0,-1,1,0,42,54)" + y1="6" + x1="45" /> + <linearGradient + id="p" + y2="6" + xlink:href="#e" + gradientUnits="userSpaceOnUse" + x2="52" + gradientTransform="matrix(0,1,-1,0,54,-42)" + y1="6" + x1="44" /> + <linearGradient + id="o" + y2="5" + gradientUnits="userSpaceOnUse" + x2="53" + gradientTransform="matrix(0,-1,1,0,43,54)" + y1="5" + x1="43"> + <stop + stop-color="#848484" + offset="0" + id="stop52" /> + <stop + stop-color="#f5f5f5" + offset="1" + id="stop54" /> + </linearGradient> + <linearGradient + id="n" + y2="15.055" + xlink:href="#a" + gradientUnits="userSpaceOnUse" + x2="36" + y1="6" + x1="36" /> + <linearGradient + id="m" + y2="6" + gradientUnits="userSpaceOnUse" + x2="37" + y1="16" + x1="37"> + <stop + stop-color="#3b3b3b" + offset="0" + id="stop58" /> + <stop + stop-color="#aeaeae" + offset=".12203" + id="stop60" /> + <stop + stop-color="#e9e9e9" + offset=".24519" + id="stop62" /> + <stop + stop-color="#8a8a8a" + offset=".44692" + id="stop64" /> + <stop + stop-color="#f0f0f0" + offset="1" + id="stop66" /> + </linearGradient> + <linearGradient + id="l" + y2="5.9996" + xlink:href="#d" + gradientUnits="userSpaceOnUse" + x2="32.251" + gradientTransform="matrix(1.0238 0 0 1.0119 -1.1429 .929)" + y1="90" + x1="32.251" /> + <linearGradient + id="k" + y2="90.239" + xlink:href="#d" + gradientUnits="userSpaceOnUse" + x2="32.251" + gradientTransform="matrix(1.0238,0,0,-1.0119,-1.1429,98.071)" + y1="6.1317" + x1="32.251" /> + <linearGradient + id="f-3" + y2="1.0923001" + gradientUnits="userSpaceOnUse" + x2="6.6201" + gradientTransform="matrix(-3.5754836,0,0,3.0823052,74.390602,19.873355)" + y1="16.385" + x1="6.6201" + spreadMethod="pad"> + <stop + stop-color="#dcdcdc" + offset="0" + id="stop27" /> + <stop + stop-color="#ebebeb" + offset=".30654" + id="stop29" /> + <stop + stop-color="#fff" + offset=".70706" + id="stop31" /> + <stop + stop-color="#fafafa" + offset=".84501" + id="stop33" /> + <stop + stop-color="#f0f0f0" + offset="1" + id="stop35-6" /> + </linearGradient> + <filter + style="color-interpolation-filters:sRGB" + id="filter3899"> + <feGaussianBlur + id="feGaussianBlur3901" + stdDeviation="0.57" /> + </filter> + <linearGradient + x1="12" + y1="4.5" + x2="12" + y2="20.5" + id="linearGradient3182" + xlink:href="#linearGradient3655" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(4,0,0,4,0,-82)" /> + <linearGradient + id="linearGradient3655"> + <stop + id="stop3657" + style="stop-color:#ad0707;stop-opacity:1" + offset="0" /> + <stop + id="stop3659" + style="stop-color:#f75535;stop-opacity:1" + offset="1" /> + </linearGradient> + <linearGradient + id="outerBackgroundGradient-8"> + <stop + style="stop-color:#465674;stop-opacity:1" + offset="0" + id="stop3864-8-6-9" /> + <stop + style="stop-color:#798bac;stop-opacity:1" + offset="1" + id="stop3866-9-1-0" /> + </linearGradient> + <linearGradient + id="outerBackgroundGradient-43"> + <stop + style="stop-color:#2c333d;stop-opacity:1" + offset="0" + id="stop3864-8-6-744" /> + <stop + style="stop-color:#485362;stop-opacity:1" + offset="1" + id="stop3866-9-1-541" /> + </linearGradient> + <clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath4492"> + <path + id="path4494" + d="m 15.562635,9.6015624 c -3.857522,0 -6.9609379,3.1034156 -6.9609379,6.9609376 v 16.875 c 0,3.857522 3.1034159,6.960937 6.9609379,6.960937 h 16.875 c 3.857522,0 6.960937,-3.103415 6.960937,-6.960937 v -16.875 c 0,-3.857522 -3.103415,-6.9609376 -6.960937,-6.9609376 z" + style="color:#bebebe;display:inline;overflow:visible;visibility:visible;opacity:0.5;fill:#b35151;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;marker:none;enable-background:accumulate" + inkscape:connector-curvature="0" /> + </clipPath> + <linearGradient + id="linearGradient3929"> + <stop + style="stop-color:#f6a602;stop-opacity:1;" + offset="0" + id="stop3931" /> + <stop + style="stop-color:#ffb621;stop-opacity:1;" + offset="1" + id="stop3933" /> + </linearGradient> + <clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath10483"> + <path + inkscape:connector-curvature="0" + id="path10485" + d="m 84,46.500023 c -27.43126,0 -49.5,22.06873 -49.5,49.5 V 216.00002 c 0,27.43127 22.06874,49.5 49.5,49.5 h 120 c 27.43127,0 49.5,-22.06873 49.5,-49.5 V 96.000023 c 0,-27.43127 -22.06873,-49.5 -49.5,-49.5 z" + style="color:#bebebe;display:inline;overflow:visible;visibility:visible;opacity:0.5;fill:#bf4f4f;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;marker:none;enable-background:accumulate" /> + </clipPath> + <clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath4295"> + <path + style="color:#bebebe;display:inline;overflow:visible;visibility:visible;fill:url(#linearGradient4299);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;marker:none;enable-background:accumulate" + d="m -429,-10.2 c -43.89002,0 -79.2,35.309971 -79.2,79.2 v 192 c 0,43.89003 35.30998,79.2 79.2,79.2 h 192 c 43.89003,0 79.2,-35.30997 79.2,-79.2 V 69 c 0,-43.890029 -35.30997,-79.2 -79.2,-79.2 z" + id="path4297" + inkscape:connector-curvature="0" /> + </clipPath> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient4259" + id="linearGradient4265" + x1="-389" + y1="333" + x2="59" + y2="333" + gradientUnits="userSpaceOnUse" /> + <linearGradient + inkscape:collect="always" + id="linearGradient4259"> + <stop + style="stop-color:#2ba4e2;stop-opacity:1" + offset="0" + id="stop4261" /> + <stop + style="stop-color:#3dbfff;stop-opacity:1" + offset="1" + id="stop4263" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient4259" + id="linearGradient928" + gradientUnits="userSpaceOnUse" + x1="-389" + y1="333" + x2="59" + y2="333" /> + </defs> + <g + id="g926" + transform="matrix(0.75159236,0,0,0.75159236,-160.77144,10.577473)" + style="stroke:none;stroke-opacity:1"> + <g + id="g938" + transform="translate(39.602667,1.9943789)"> + <g + style="display:inline;stroke:#3dbdff;stroke-opacity:1;enable-background:new" + id="g4249" + transform="matrix(0.34174019,0,0,0.34174019,351.96898,-8.5905209)" + clip-path="url(#clipPath4295)"> + <rect + clip-path="none" + ry="224" + rx="224" + y="109" + x="-389" + height="448" + width="448" + id="rect4523" + style="display:inline;fill:url(#linearGradient928);fill-opacity:1;stroke:#3dbdff;stroke-opacity:1;enable-background:new" + transform="matrix(0,-1,-1,0,0,0)" /> + <path + inkscape:connector-curvature="0" + style="display:inline;opacity:1;fill:#ffffff;fill-opacity:1;stroke:#3dbdff;stroke-opacity:1;enable-background:new" + d="m -375.80156,-54.676205 c -5.75377,1.05616 -11.46408,2.33856 -17.11749,3.84238 -7.197,1.99707 -14.28961,4.35276 -21.25118,7.05834 -6.96068,2.70591 -13.78158,5.75802 -20.43728,9.14523 -6.65492,3.38744 -13.13623,7.10549 -19.41976,11.14029 -6.28498,4.03602 -12.3641,8.38396 -18.21475,13.02748 -5.8502,4.6416 -11.46463,9.57273 -16.82214,14.77512 -0.6888,0.72912 -1.37223,1.46373 -2.05095,2.20248 4.87905,33.48761 31.56177,75.072345 57.8703,104.519675 -8.93915,16.58358 -13.62421,35.12582 -13.63564,53.96508 0.0336,5.38955 0.46144,10.76935 1.26291,16.09908 -37.98383,-17.22255 -80.77235,-49.75434 -99.00065,-88.80968 -0.82656,2.15947 -1.61907,4.3317 -2.3781,6.5157 -2.20662,7.1345 -4.05495,14.37484 -5.53827,21.69391 -1.48423,7.31852 -2.60142,14.7067 -3.34768,22.13676 -0.7448,7.43153 -1.11776,14.89553 -1.11776,22.36423 -8.9e-4,5.28651 0.18592,10.57133 0.55888,15.8445 0.52976,7.45001 1.4308,14.86876 2.70133,22.22861 1.17308,6.5969 2.64151,13.1378 4.40103,19.6031 31.46007,12.532 80.84257,10.21909 119.53179,2.14323 12.78522,20.67728 31.85207,36.72058 54.41096,45.78237 -33.88599,24.25647 -83.42205,45.0405 -126.32906,41.31191 2.59526,3.122 5.27497,6.17287 8.0361,9.14916 5.20328,5.35717 10.67123,10.45092 16.38333,15.26199 5.71165,4.81095 11.6603,9.33339 17.82365,13.55018 6.16379,4.21657 12.53468,8.12256 19.08857,11.70275 6.55412,3.58052 13.28318,6.83131 20.16198,9.73972 6.87948,2.90797 13.90019,5.4703 21.03569,7.67681 2.69741,0.74255 5.40847,1.43404 8.13186,2.0749 26.51103,-20.97129 49.13868,-64.84441 61.46538,-102.31025 24.29735,-0.7168 47.72782,-9.19014 66.86399,-24.17952 4.04869,41.48808 -2.7506,94.79887 -27.46392,130.09684 5.75377,-1.05728 11.46397,-2.33878 17.11749,-3.84249 7.197,-1.99718 14.2895,-4.35276 21.25118,-7.05845 6.96057,-2.70592 13.78158,-5.75813 20.43728,-9.14523 6.6548,-3.38744 13.13623,-7.10549 19.41976,-11.14018 6.28498,-4.03602 12.3641,-8.38408 18.21475,-13.02748 5.8502,-4.64149 11.46452,-9.57284 16.82215,-14.77512 0.68879,-0.73024 1.37222,-1.46373 2.05094,-2.20248 -4.87905,-33.4876 -31.56177,-75.07235 -57.8703,-104.51966 8.93904,-16.58359 13.62421,-35.12583 13.63564,-53.96521 -0.0448,-5.38943 -0.46144,-10.76922 -1.26291,-16.09896 37.98383,17.22255 80.77235,49.75423 99.00066,88.80968 0.82655,-2.15946 1.61918,-4.3317 2.3782,-6.5158 2.20651,-7.13439 4.05484,-14.37473 5.53817,-21.69381 1.48422,-7.31851 2.60153,-14.70669 3.34767,-22.13687 0.7448,-7.43141 1.11776,-14.89553 1.11776,-22.36424 7.8e-4,-5.28639 -0.18592,-10.57121 -0.55888,-15.84438 -0.52976,-7.45001 -1.4308,-14.86876 -2.70132,-22.2286 -1.17712,-6.6267 -2.65227,-13.19705 -4.42097,-19.69091 -38.55448,-13.74048 -86.06233,-8.9918 -119.53381,-2.08824 -12.78489,-20.6615 -31.843,-36.69237 -54.38901,-45.74957 33.88588,-24.256465 83.42205,-45.040385 126.32895,-41.311905 -2.59526,-3.122 -5.27497,-6.17276 -8.03599,-9.14915 -5.20328,-5.35729 -10.67123,-10.45093 -16.38333,-15.26199 -5.71165,-4.81096 -11.6603,-9.3334 -17.82376,-13.55019 -6.1638,-4.21668 -12.53457,-8.12245 -19.08857,-11.70275 -6.55412,-3.58063 -13.28307,-6.83131 -20.16187,-9.73972 -6.87959,-2.90808 -13.9003,-5.47019 -21.0358,-7.67692 -2.67501,-0.73584 -5.36345,-1.42251 -8.06399,-2.05889 -26.52637,20.97006 -49.18169,64.82941 -61.52216,102.294235 -24.30127,0.71456 -47.73589,9.18802 -66.87509,24.17964 -4.04868,-41.487975 2.75061,-94.798875 27.46404,-130.096735 z" + id="path4598" + sodipodi:nodetypes="ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + clip-path="none" /> + </g> + <g + style="display:inline;stroke:none;stroke-opacity:1;enable-background:new" + transform="matrix(2.8120339,0,0,2.8120339,-595.6597,-759.96676)" + id="g4425"> + <g + style="stroke:none;stroke-opacity:1" + id="g916" + transform="translate(-15.478271,43.252366)"> + <rect + ry="13.987269" + rx="13.987269" + y="-407.27615" + x="33.958504" + height="27.974539" + width="27.974539" + id="rect4427" + style="display:inline;opacity:0.2;fill:#000000;fill-opacity:1;stroke:none;stroke-opacity:1;enable-background:new" + transform="matrix(0.70710678,-0.70710678,-0.70710678,-0.70710678,0,0)" /> + <rect + transform="matrix(0.70710678,-0.70710678,-0.70710678,-0.70710678,0,0)" + style="display:inline;opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-opacity:1;enable-background:new" + id="rect4429" + width="27.974539" + height="27.974539" + x="34.095997" + y="-407.13864" + rx="13.987269" + ry="13.987269" /> + <path + style="display:inline;fill:#3dbdff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;enable-background:new" + d="m 308.8889,248.94444 v -10 l 9,5 z" + id="path4431" + inkscape:connector-curvature="0" + sodipodi:nodetypes="cccc" /> + </g> + </g> + </g> + </g> +</svg>