multi-presentation support in JS client, etc.
[ppt-control.git] / ppt_control / ppt_control.py
old mode 100644 (file)
new mode 100755 (executable)
index 2b4d6f6..e5a38c1
@@ -16,6 +16,7 @@ import time
 import pythoncom\r
 import pystray\r
 from PIL import Image\r
+from pystray._util import win32\r
 from copy import copy\r
 \r
 import ppt_control.__init__ as pkg_base\r
@@ -24,32 +25,37 @@ import ppt_control.config as config
 \r
 logging.basicConfig()\r
 \r
-global http_daemon\r
 http_daemon = None\r
-global http_server\r
-http_server = None\r
-global ws_daemon\r
+my_http_server = None\r
 ws_daemon = None\r
-global users\r
 users = set()\r
-global logger\r
 logger = None\r
-global refresh_daemon\r
 refresh_daemon = None\r
-global icon\r
 icon = None\r
-global ppt_application\r
 ppt_application = None\r
-global ppt_presentations\r
 ppt_presentations = {}\r
-global disable_protected_attempted\r
 disable_protected_attempted = set()\r
 \r
 PKG_NAME = pkg_base.__name__\r
 PKG_VERSION = pkg_base.__version__\r
-CONFIG_FILE = r'''..\ppt-control.ini'''\r
-LOGFILE = r'''..\ppt-control.log'''\r
+DELAY_CLOSE = 0.2\r
+DELAY_PROTECTED = 0.5\r
+DELAY_FINAL = 0.1\r
 \r
+class MyIcon(pystray.Icon):\r
+    """\r
+    Custom pystray.Icon class which displays menu when left-clicking on icon, as well as the \r
+    default right-click behaviour.\r
+    """\r
+    def _on_notify(self, wparam, lparam):\r
+        """Handles ``WM_NOTIFY``.\r
+        If this is a left button click, this icon will be activated. If a menu\r
+        is registered and this is a right button click, the popup menu will be\r
+        displayed.\r
+        """\r
+        if lparam == win32.WM_LBUTTONUP or (\r
+                self._menu_handle and lparam == win32.WM_RBUTTONUP):\r
+            super()._on_notify(wparam, win32.WM_RBUTTONUP)\r
 \r
 class Handler(http_server.SimpleHTTPRequestHandler):\r
     """\r
@@ -126,27 +132,61 @@ async def ws_receive(websocket, path):
         async for message in websocket:\r
             logger.debug("Received websocket message: " + str(message))\r
             data = json.loads(message)\r
-            pres = ppt_presentations[data["presentation"]]\r
+            if data["presentation"]:\r
+                pres = ppt_presentations[data["presentation"]]\r
+            else:\r
+                # Control last-initialised presentation if none specified (e.g. if using OBS script\r
+                # which doesn't have any visual feedback and hence no method to choose a\r
+                # presentation). This relies on any operations on the ppt_presentations dictionary\r
+                # being stable so that the order does not change. So far no problems have been\r
+                # detected with this, but it is not an ideal method.\r
+                pres = ppt_presentations[list(ppt_presentations.keys())[-1]]\r
             if data["action"] == "prev":\r
                 pres.prev()\r
             elif data["action"] == "next":\r
                 pres.next()\r
+                # Advancing to the black screen before the slideshow ends doesn't trigger \r
+                # ApplicationEvents.OnSlideShowNextSlide, so we have to check for that here and\r
+                # broadcast the new state if necessary. A delay is required since the event is \r
+                # triggered before the slideshow is actually closed, and we don't want to attempt\r
+                # to check the current slide of a slideshow that isn't running.\r
+                time.sleep(DELAY_FINAL)\r
+                if (pres.get_slideshow() is not None and \r
+                        pres.slide_current() == pres.slide_total() + 1):\r
+                    logger.debug("Advanced to black slide before end")\r
+                    broadcast_presentation(pres)\r
             elif data["action"] == "first":\r
                 pres.first()\r
             elif data["action"] == "last":\r
                 pres.last()\r
             elif data["action"] == "black":\r
-                if pres.state() == 3:\r
+                if pres.state() == 3 or (\r
+                    config.prefs["Main"]["blackwhite"] == "both" and pres.state() == 4):\r
                     pres.normal()\r
                 else:\r
                     pres.black()\r
             elif data["action"] == "white":\r
-                if pres.state() == 4:\r
+                if pres.state() == 4  or (\r
+                    config.prefs["Main"]["blackwhite"] == "both" and pres.state() == 3):\r
                     pres.normal()\r
                 else:\r
                     pres.white()\r
             elif data["action"] == "goto":\r
                 pres.goto(int(data["value"]))\r
+                # Advancing to the black screen before the slideshow ends doesn't trigger \r
+                # ApplicationEvents.OnSlideShowNextSlide, so we have to check for that here and\r
+                # broadcast the new state if necessary. A delay is required since the event is \r
+                # triggered before the slideshow is actually closed, and we don't want to attempt\r
+                # to check the current slide of a slideshow that isn't running.\r
+                time.sleep(DELAY_FINAL)\r
+                if (pres.get_slideshow() is not None and \r
+                        pres.slide_current() == pres.slide_total() + 1):\r
+                    logger.debug("Jumped to black slide before end")\r
+                    broadcast_presentation(pres)\r
+            elif data["action"] == "start":\r
+                pres.start_slideshow()\r
+            elif data["action"] == "stop":\r
+                pres.stop_slideshow()\r
             else:\r
                 logger.error("Received unnsupported event: {}", data)\r
     finally:\r
@@ -165,9 +205,9 @@ def run_http():
     """\r
     Start the HTTP server\r
     """\r
-    global http_server\r
-    http_server = http_server.HTTPServer((config.prefs["HTTP"]["interface"], config.prefs.getint("HTTP", "port")), Handler)\r
-    http_server.serve_forever()\r
+    global my_http_server\r
+    my_http_server = http_server.HTTPServer((config.prefs["HTTP"]["interface"], config.prefs.getint("HTTP", "port")), Handler)\r
+    my_http_server.serve_forever()\r
 \r
 \r
 def run_ws():\r
@@ -263,7 +303,7 @@ class ApplicationEvents:
         Triggered when a new presentation is opened. This adds the new presentation to the list\r
         of open presentations.\r
         """\r
-        logger.debug("Presentation {} opened - adding to list".format(presentation.Name))\r
+        logger.debug("Presentation {} opened (blank) - adding to list".format(presentation.Name))\r
         global ppt_presentations\r
         ppt_presentations[presentation.Name] = Presentation(ppt_application, pres_obj=presentation)\r
         broadcast_presentation(ppt_presentations[presentation.Name])\r
@@ -272,14 +312,14 @@ class ApplicationEvents:
     def OnPresentationClose(self, presentation, *args):\r
         """\r
         Triggered when a presentation is closed. This removes the presentation from the list of\r
-        open presentations. A delay of 200 ms is included to make sure the presentation is \r
+        open presentations. A delay is included to make sure the presentation is \r
         actually closed, since the event is called simultaneously as the presentation is removed\r
         from PowerPoint's internal structure. Ref:\r
         https://docs.microsoft.com/en-us/office/vba/api/powerpoint.application.presentationclose\r
         """\r
         logger.debug("Presentation {} closed - removing from list".format(presentation.Name))\r
         global ppt_presentations\r
-        time.sleep(0.2)\r
+        time.sleep(DELAY_CLOSE)\r
         broadcast_presentation(ppt_presentations.pop(presentation.Name))\r
         icon.notify("Disconnected from {}".format(presentation.Name), PKG_NAME)\r
 \r
@@ -337,18 +377,42 @@ class Presentation:
             logger.debug("Couldn't get slideshow for {}: {}".format(self.presentation.Name, exc))\r
             return None\r
 \r
+    def start_slideshow(self):\r
+        """\r
+        Start the slideshow. Updating the state of this object is managed by the OnSlideshowBegin \r
+        event of the applicable Application.\r
+        """\r
+        if self.get_slideshow() is None:\r
+            self.presentation.SlideShowSettings.Run()\r
+        else:\r
+            logger.warning("Cannot start slideshow that is already running (presentation {})".format(\r
+                self.presentation.Name))\r
+\r
+    def stop_slideshow(self):\r
+        """\r
+        Stop the slideshow. Updating the state of this object is managed by the OnSlideshowEnd\r
+        event of the applicable Application.\r
+        """\r
+        if self.get_slideshow() is not None:\r
+            self.presentation.SlideShowWindow.View.Exit()\r
+        else:\r
+            logger.warning("Cannot stop slideshow that is not running (presentation {})".format(\r
+                self.presentation.Name))\r
+\r
     def state(self):\r
         """\r
         Returns the visibility state of the slideshow:\r
-        2: normal\r
+        1: running\r
+        2: paused\r
         3: black\r
         4: white\r
         5: done\r
+        Source: https://docs.microsoft.com/en-us/office/vba/api/powerpoint.ppslideshowstate\r
         """\r
         if self.slideshow is not None:\r
             return self.slideshow.View.State\r
         else:\r
-            return 2\r
+            return 0\r
 \r
     def slide_current(self):\r
         """\r
@@ -412,10 +476,14 @@ class Presentation:
 \r
     def normal(self):\r
         """\r
-        Make the slideshow visible if there is a slideshow running.\r
+        Make the slideshow visible if there is a slideshow running. Note this puts the slideshow into \r
+        "running" state rather than the normal "paused" to ensure animations work correctly and the \r
+        slide is actually visible after changing the state. The state is normally returned to \r
+        "paused" automatically by PPT when advancing to the following slide. State enumeration ref: \r
+        https://docs.microsoft.com/en-us/office/vba/api/powerpoint.ppslideshowstate\r
         """\r
         assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
-        self.slideshow.View.State = 2\r
+        self.slideshow.View.State = 1\r
         broadcast_presentation(self)\r
 \r
     def black(self):\r
@@ -481,17 +549,43 @@ def null_action(*args):
     Placeholder for disabled menu items in systray\r
     """\r
     pass\r
+\r
+def edit_config(*args):\r
+    """\r
+    Open the config file for editing in Notepad, and create the directory if not existing\r
+    """\r
+    logger.debug("Opening config {}".format(pkg_base.CONFIG_PATH))\r
+    if not os.path.exists(pkg_base.CONFIG_DIR):\r
+        try:\r
+            os.makedirs(pkg_base.CONFIG_DIR)\r
+            logger.info("Made directory {}".format(pkg_base.CONFIG_DIR))\r
+        except Exception as exc:\r
+            logger.warning("Failed to create directory {} for config file".format(\r
+                pkg_base.CONFIG_DIR))\r
+            icon.notify("Create {} manually".format((pkg_base.CONFIG_DIR[:40] + '...') \r
+                        if len(pkg_base.CONFIG_DIR) > 40 else pkg_base.CONFIG_DIR),\r
+                        "Failed to create config directory")\r
+    try:\r
+        os.popen("notepad.exe {}".format(pkg_base.CONFIG_PATH))\r
+    except Exception as exc:\r
+        logger.warning("Failed to edit config {}: {}".format(pkg_base.CONFIG_PATH, exc))\r
+        icon.notify("Edit {} manually".format((pkg_base.CONFIG_PATH[:40] + '...') \r
+                        if len(pkg_base.CONFIG_PATH) > 40 else pkg_base.CONFIG_PATH),\r
+                        "Failed to open config")\r
                 \r
 def refresh():\r
     """\r
     Clear COM events and update interface elements at an interval defined in "refresh" in the config\r
+    TODO: fix "argument of type 'com_error' is not iterable"\r
     """\r
     while getattr(refresh_daemon, "do_run", True):\r
         try:\r
             pythoncom.PumpWaitingMessages()\r
+            # TODO: don't regenerate entire menu on each refresh, use pystray.Icon.update_menu()\r
             icon.menu = (pystray.MenuItem("Status: " + "dis"*(len(ppt_presentations) == 0) + "connected",\r
                 lambda: null_action(), enabled=False),\r
-                pystray.MenuItem("Stop", lambda: exit(icon)))\r
+                pystray.MenuItem("Stop", lambda: exit(icon)),\r
+                pystray.MenuItem("Edit config", lambda: edit_config()))\r
             manage_protected_view(ppt_application)\r
             time.sleep(float(config.prefs["Main"]["refresh"]))\r
         except Exception as exc:\r
@@ -499,7 +593,6 @@ def refresh():
             # (if this fails again, that's okay because we'll keep trying until it works)\r
             logger.error("Error whilst refreshing state: {}".format(exc))\r
             app = get_application()\r
-\r
         \r
 \r
 def get_application():\r
@@ -544,9 +637,17 @@ def manage_protected_view(app):
                     logger.warning("Cannot control {} in protected view, and automatic disabling of protected view is turned off".format(pres_name))\r
                     disable_protected_attempted.add(pres_name)\r
     except Exception as exc:\r
+        if type(exc) == pywintypes.com_error and "application is busy" in exc:\r
+            # PowerPoint needs some time to finish loading file if it has just been opened,\r
+            # otherwise we get "The message filter indicated that the application is busy". Here,\r
+            # we deal with this by gracefully ignoring any protected view windows until the next \r
+            # refresh cycle, when PowerPoint is hopefully finished loading (if the refresh interval\r
+            # is sufficiently long).\r
+            logger.debug("COM interface not taking requests right now - will try again on next refresh")\r
+            return\r
         # Sometimes gets pywintypes.com_error "The object invoked has disconnected from its clients"\r
-        # at this point which leads to "Exception whilst dealing with protected view windows".\r
-        logger.warning("Exception whilst dealing with protected view windows: {}".format(exc))\r
+        # at this point.\r
+        logger.warning("{} whilst dealing with protected view windows: {}".format(type(exc), exc))\r
         app = get_application()\r
 \r
 \r
@@ -624,7 +725,7 @@ def start_interface():
     global icon\r
     global logger\r
     # Load config\r
-    config.prefs = config.loadconf(CONFIG_FILE)\r
+    config.prefs = config.loadconf(pkg_base.CONFIG_PATH)\r
 \r
     # Set up logging\r
     if config.prefs["Main"]["logging"] == "debug":\r
@@ -640,16 +741,26 @@ def start_interface():
     logger.setLevel(log_level)\r
     logger.propagate = False\r
 \r
-    file_handler = logging.FileHandler("{0}/{1}".format(os.getenv("APPDATA"), LOGFILE))\r
-    file_handler.setFormatter(log_formatter)\r
-    file_handler.setLevel(log_level)\r
-    logger.addHandler(file_handler)\r
-\r
     console_handler = logging.StreamHandler()\r
     console_handler.setFormatter(log_formatter)\r
     console_handler.setLevel(log_level)\r
     logger.addHandler(console_handler)\r
 \r
+    if not os.path.exists(pkg_base.LOG_DIR):\r
+        try:\r
+            os.makedirs(pkg_base.LOG_DIR)\r
+            logger.info("Made directory {}".format(pkg_base.LOG_DIR))\r
+        except Exception as exc:\r
+            logger.warning("Failed to create directory {} for log".format(\r
+                pkg_base.LOG_DIR))\r
+            icon.notify("Create {} manually".format((pkg_base.LOG_DIR[:40] + '...') \r
+                        if len(pkg_base.LOG_DIR) > 40 else pkg_base.LOG_DIR),\r
+                        "Failed to create log directory")\r
+    file_handler = logging.FileHandler(pkg_base.LOG_PATH)\r
+    file_handler.setFormatter(log_formatter)\r
+    file_handler.setLevel(log_level)\r
+    logger.addHandler(file_handler)\r
+\r
     #logging.getLogger("asyncio").setLevel(logging.ERROR)\r
     #logging.getLogger("asyncio.coroutines").setLevel(logging.ERROR)\r
     logging.getLogger("websockets.server").setLevel(logging.ERROR)\r
@@ -660,7 +771,7 @@ def start_interface():
 \r
     # Start systray icon and server\r
     logger.debug("Starting system tray icon")\r
-    icon = pystray.Icon(PKG_NAME)\r
+    icon = MyIcon(PKG_NAME)\r
     icon.icon = Image.open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.ico''')\r
     icon.title = "{} {}".format(PKG_NAME, PKG_VERSION)\r
     #refresh_menu(icon)\r
@@ -671,4 +782,4 @@ def start_interface():
     sys.exit(0)\r
 \r
 if __name__ == "__main__":\r
-    start_interface()
\ No newline at end of file
+    start_interface()\r