diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..4c830d2 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include jellyfin_mpv_shim/systray.png diff --git a/README.md b/README.md index e905992..55f9953 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ All of these settings apply to direct play and are adjustable through the contro - If you change this, it should be changed to a profile that supports `hls` streaming. - `sanitize_output` - Prevent Plex tokens from being printed to the console. Default: `true` - `fullscreen` - Fullscreen the player when starting playback. Default: `true` + - `enable_gui` - Enable the system tray icon and GUI features. Default: `true` ### MPV Configuration @@ -137,7 +138,8 @@ The project is written entierly in Python 3. There are no closed-source components in this project. It is fully hackable. The project is dependent on `python-mpv` and `requests`. If you are using Windows -and would like mpv to be maximize properly, `pywin32` is also needed. +and would like mpv to be maximize properly, `pywin32` is also needed. The GUI component +uses `pystray` and `tkinter`, but there is a fallback cli mode. If you are using a local firewall, you'll want to allow inbound connections on TCP 3000 and UDP 32410, 32412, 32413, and 32414. The TCP port is for the web @@ -155,6 +157,11 @@ If you are on Linux, you can install via pip. You'll need [libmpv1](https://gith ```bash sudo pip3 install --upgrade plex-mpv-shim ``` +If you would like the GUI and systray features, also install: +```bash +sudo pip3 install pystray +sudo apt install python3-tk +``` The current Debian package for `libmpv1` doesn't support the on-screen controller. If you'd like this, or need codecs that aren't packaged with Debian, you need to build mpv from source. Execute the following: ```bash @@ -175,8 +182,8 @@ following these directions, please take care to ensure both the python and libmpv libraries are either 64 or 32 bit. (Don't mismatch them.) 1. Install [Python3](https://www.python.org/downloads/) with PATH enabled. Install [7zip](https://ninite.com/7zip/). -2. After installing python3, open `cmd` as admin and run `pip install --upgrade pyinstaller python-mpv requests pywin32`. +2. After installing python3, open `cmd` as admin and run `pip install --upgrade pyinstaller python-mpv requests pywin32 pystray`. 3. Download [libmpv](https://sourceforge.net/projects/mpv-player-windows/files/libmpv/). 4. Extract the `mpv-1.dll` from the file and move it to the `plex-mpv-shim` folder. 5. Open a regular `cmd` prompt. Navigate to the `plex-mpv-shim` folder. -6. Run `pyinstaller -cF --add-binary "mpv-1.dll;." --icon media.ico run.py`. +6. Run `pyinstaller -wF --add-binary "mpv-1.dll;." --add-binary "jellyfin_mpv_shim\systray.png;." --icon media.ico run.py`. diff --git a/plex_mpv_shim/cli_mgr.py b/plex_mpv_shim/cli_mgr.py new file mode 100644 index 0000000..c40d437 --- /dev/null +++ b/plex_mpv_shim/cli_mgr.py @@ -0,0 +1,12 @@ +import time + +class UserInterface(object): + def __init__(self): + self.open_player_menu = lambda: None + self.stop = lambda: None + + def run(self): + while True: + time.sleep(1) + +userInterface = UserInterface() diff --git a/plex_mpv_shim/conf.py b/plex_mpv_shim/conf.py index 108e37f..2e745c7 100644 --- a/plex_mpv_shim/conf.py +++ b/plex_mpv_shim/conf.py @@ -38,6 +38,7 @@ class Settings(object): "subtitle_color": "#FFFFFFFF", "subtitle_position": "bottom", "fullscreen": True, + "enable_gui": True, } def __getattr__(self, name): diff --git a/plex_mpv_shim/gui_mgr.py b/plex_mpv_shim/gui_mgr.py new file mode 100644 index 0000000..15ce976 --- /dev/null +++ b/plex_mpv_shim/gui_mgr.py @@ -0,0 +1,181 @@ +from pystray import Icon, MenuItem, Menu +from PIL import Image +from collections import deque +import tkinter as tk +from tkinter import ttk, messagebox +import subprocess +from multiprocessing import Process, Queue +import threading +import sys +import logging +import queue +import os.path + +APP_NAME = "plex-mpv-shim" +from .conffile import confdir + +if (sys.platform.startswith("win32") or sys.platform.startswith("cygwin")) and getattr(sys, 'frozen', False): + # Detect if bundled via pyinstaller. + # From: https://stackoverflow.com/questions/404744/ + icon_file = os.path.join(sys._MEIPASS, "systray.png") +else: + icon_file = os.path.join(os.path.dirname(__file__), "systray.png") +log = logging.getLogger('gui_mgr') + +# From https://stackoverflow.com/questions/6631299/ +# This is for opening the config directory. +def _show_file_darwin(path): + subprocess.check_call(["open", path]) + +def _show_file_linux(path): + subprocess.check_call(["xdg-open", path]) + +def _show_file_win32(path): + subprocess.check_call(["explorer", "/select", path]) + +_show_file_func = {'darwin': _show_file_darwin, + 'linux': _show_file_linux, + 'win32': _show_file_win32, + 'cygwin': _show_file_win32} + +try: + show_file = _show_file_func[sys.platform] + def open_config(): + show_file(confdir(APP_NAME)) +except KeyError: + open_config = None + log.warning("Platform does not support opening folders.") + +# Setup a log handler for log items. +log_cache = deque([], 1000) +root_logger = logging.getLogger('') + +class GUILogHandler(logging.Handler): + def __init__(self): + self.callback = None + super().__init__() + + def emit(self, record): + log_entry = self.format(record) + log_cache.append(log_entry) + + if self.callback: + try: + self.callback(log_entry) + except Exception: + pass + +guiHandler = GUILogHandler() +guiHandler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)8s] %(message)s")) +root_logger.addHandler(guiHandler) + +# Why am I using another process for the GUI windows? +# Because both pystray and tkinter must run +# in the main thread of their respective process. + +class LoggerWindow(threading.Thread): + def __init__(self): + self.dead = False + threading.Thread.__init__(self) + + def run(self): + self.queue = Queue() + self.r_queue = Queue() + self.process = LoggerWindowProcess(self.queue, self.r_queue) + + def handle(message): + self.handle("append", message) + + self.process.start() + handle("\n".join(log_cache)) + guiHandler.callback = handle + while True: + action, param = self.r_queue.get() + if action == "die": + self._die() + break + + def handle(self, action, params=None): + self.queue.put((action, params)) + + def stop(self, is_source=False): + self.r_queue.put(("die", None)) + + def _die(self): + guiHandler.callback = None + self.handle("die") + self.process.terminate() + self.dead = True + +class LoggerWindowProcess(Process): + def __init__(self, queue, r_queue): + self.queue = queue + self.r_queue = r_queue + Process.__init__(self) + + def update(self): + try: + self.text.config(state=tk.NORMAL) + while True: + action, param = self.queue.get_nowait() + if action == "append": + self.text.config(state=tk.NORMAL) + self.text.insert(tk.END, "\n") + self.text.insert(tk.END, param) + self.text.config(state=tk.DISABLED) + self.text.see(tk.END) + elif action == "die": + self.root.destroy() + self.root.quit() + return + except queue.Empty: + pass + self.text.after(100, self.update) + + def run(self): + root = tk.Tk() + self.root = root + root.title("Application Log") + text = tk.Text(root) + text.pack(side=tk.LEFT, fill=tk.BOTH, expand = tk.YES) + text.config(wrap=tk.WORD) + self.text = text + yscroll = tk.Scrollbar(command=text.yview) + text['yscrollcommand'] = yscroll.set + yscroll.pack(side=tk.RIGHT, fill=tk.Y) + text.config(state=tk.DISABLED) + self.update() + root.mainloop() + self.r_queue.put(("die", None)) + +class UserInterface: + def __init__(self): + self.open_player_menu = lambda: None + self.icon_stop = lambda: None + self.log_window = None + + def stop(self): + if self.log_window and not self.log_window.dead: + self.log_window.stop() + self.icon_stop() + + def show_console(self): + if self.log_window is None or self.log_window.dead: + self.log_window = LoggerWindow() + self.log_window.start() + + def run(self): + menu_items = [ + MenuItem("Show Console", self.show_console), + MenuItem("Application Menu", self.open_player_menu), + ] + + if open_config: + menu_items.append(MenuItem("Open Config Folder", open_config)) + menu_items.append(MenuItem("Quit", self.stop)) + icon = Icon(APP_NAME, menu=Menu(*menu_items)) + icon.icon = Image.open(icon_file) + self.icon_stop = icon.stop + icon.run() + +userInterface = UserInterface() diff --git a/plex_mpv_shim/mpv_shim.py b/plex_mpv_shim/mpv_shim.py index ad20ac6..6ce79dc 100755 --- a/plex_mpv_shim/mpv_shim.py +++ b/plex_mpv_shim/mpv_shim.py @@ -33,6 +33,17 @@ def main(): settings.load(conf_file) settings.add_listener(update_gdm_settings) + use_gui = False + if settings.enable_gui: + try: + from .gui_mgr import userInterface + use_gui = True + except Exception: + log.warning("Cannot load GUI. Falling back to command line interface.", exc_info=1) + + if not use_gui: + from .cli_mgr import userInterface + update_gdm_settings() gdm.start_all() @@ -45,10 +56,10 @@ def main(): playerManager.timeline_trigger = timelineManager.trigger actionThread.start() playerManager.action_trigger = actionThread.trigger + userInterface.open_player_menu = playerManager.menu.show_menu try: - while True: - time.sleep(1) + userInterface.run() except KeyboardInterrupt: print("") log.info("Stopping services...") diff --git a/plex_mpv_shim/systray.png b/plex_mpv_shim/systray.png new file mode 100644 index 0000000..dd8932f Binary files /dev/null and b/plex_mpv_shim/systray.png differ diff --git a/run.py b/run.py index 147c815..74f8d6a 100755 --- a/run.py +++ b/run.py @@ -3,6 +3,7 @@ # Newer revisions of python-mpv require mpv-1.dll in the PATH. import os import sys +import multiprocessing if sys.platform.startswith("win32") or sys.platform.startswith("cygwin"): # Detect if bundled via pyinstaller. # From: https://stackoverflow.com/questions/404744/ @@ -13,4 +14,8 @@ os.environ["PATH"] = application_path + os.pathsep + os.environ["PATH"] from plex_mpv_shim.mpv_shim import main -main() +if __name__ == '__main__': + # https://stackoverflow.com/questions/24944558/pyinstaller-built-windows-exe-fails-with-multiprocessing + multiprocessing.freeze_support() + + main() diff --git a/setup.py b/setup.py index 97505cd..8a049d8 100644 --- a/setup.py +++ b/setup.py @@ -25,5 +25,7 @@ "Operating System :: OS Independent", ], python_requires='>=3.6', - install_requires=['python-mpv', 'requests'] + install_requires=['python-mpv', 'requests'], + include_package_data=True + )