Major rewrite of main module
authorAndrew Lorimer <andrew@lorimer.id.au>
Sun, 15 Aug 2021 12:46:12 +0000 (22:46 +1000)
committerAndrew Lorimer <andrew@lorimer.id.au>
Sun, 15 Aug 2021 12:46:12 +0000 (22:46 +1000)
Uses more event-driven syntax in main module to reduce refreshes, and
now supports multiple presentations (on the server side at least... a
bit more work needed in JS).

ppt_control/config.py
ppt_control/ppt_control.py
ppt_control/static/index.html
ppt_control/static/ppt-control.js
ppt_control/static/style.css
index e0416a498084b13889d14a38d30be86d81032bf2..74df1f3acdff43f2eb9c3c206d581e0db9d61521 100644 (file)
@@ -11,7 +11,9 @@ defaults = {
             'cache_format': 'JPG',
             'cache_timeout': 5*60,
             'cache_init': True,
-            'blackwhite': 'both'
+            'blackwhite': 'both',
+            'refresh': 2,
+            'disable_protected': False
         },
         'HTTP': {
             'interface': '',
index 8502bcbd2f70442ad6278b2ddac45fd32ded8c70..2b4d6f6d93520b545b8c74925112983231a81632 100644 (file)
@@ -4,7 +4,6 @@ import win32com.client
 import pywintypes\r
 import os\r
 import shutil\r
-#import http.server as server\r
 import socketserver\r
 import threading\r
 import asyncio\r
@@ -16,51 +15,46 @@ import posixpath
 import time\r
 import pythoncom\r
 import pystray\r
-import tkinter as tk\r
-from tkinter import ttk\r
 from PIL import Image\r
 from copy import copy\r
 \r
-import ppt_control.http_server_39 as server\r
+import ppt_control.__init__ as pkg_base\r
+import ppt_control.http_server_39 as http_server    # 3.9 version of the HTTP server (details in module)\r
 import ppt_control.config as config\r
 \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
-global STATE\r
-global STATE_DEFAULT\r
-global current_slideshow\r
-global interface_root\r
-global interface_thread\r
+ws_daemon = None\r
+global users\r
+users = set()\r
 global logger\r
+logger = None\r
 global refresh_daemon\r
-global status_label\r
-global http_label\r
-global ws_label\r
-global reset_ppt_button\r
-global http_server\r
+refresh_daemon = None\r
 global icon\r
-scheduler = None\r
-current_slideshow = None\r
-interface_root = None\r
-interface_thread = None\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
-REFRESH_INTERVAL = 2\r
-logger = None\r
-refresh_daemon = None\r
-status_label = None\r
-http_label = None\r
-ws_label = None\r
-ws_daemon = None\r
-http_server = None\r
-reset_ppt_button = None\r
-icon = None\r
-ws_stop_event = False\r
 \r
 \r
-class Handler(server.SimpleHTTPRequestHandler):\r
+class Handler(http_server.SimpleHTTPRequestHandler):\r
+    """\r
+    Custom handler to translate /cache* urls to the cache directory (set in the config)\r
+    """\r
     def __init__(self, *args, **kwargs):\r
         super().__init__(*args, directory=os.path.dirname(os.path.realpath(__file__)) + r'''\static''')\r
         \r
@@ -89,16 +83,11 @@ class Handler(server.SimpleHTTPRequestHandler):
         words = path.split('/')\r
         words = list(filter(None, words))\r
         if len(words) > 0 and words[0] == "cache":\r
-            black = 0\r
-            if current_slideshow:\r
-                try:\r
-                    path = config.prefs["Main"]["cache"] + "\\" + current_slideshow.name()\r
-                except Exception as e:\r
-                    path = "black.jpg"\r
-                    logger.warning("Failed to get current slideshow name: ", e)\r
+            if words[1] in ppt_presentations:\r
+                path = config.prefs["Main"]["cache"]\r
             else:\r
                 path = "black.jpg"\r
-                return path\r
+                logger.warning("Request for cached file {} for non-existent presentation".format(path))\r
             words.pop(0)\r
         else:\r
             path = self.directory\r
@@ -111,42 +100,10 @@ class Handler(server.SimpleHTTPRequestHandler):
             path += '/'\r
         return path\r
 \r
-\r
-def run_http():\r
-    global http_server\r
-    http_server = server.HTTPServer((config.prefs["HTTP"]["interface"], config.prefs.getint("HTTP", "port")), Handler)\r
-    http_server.serve_forever()\r
-\r
-STATE_DEFAULT = {"connected": 0, "current": 0, "total": 0, "visible": 0, "name": ""}\r
-STATE = copy(STATE_DEFAULT)\r
-USERS = set()\r
-\r
-\r
-def state_event():\r
-    return json.dumps({"type": "state", **STATE})\r
-\r
-\r
-def notify_state():\r
-    logger.debug("Notifying state")\r
-    global STATE\r
-    if current_slideshow and STATE["connected"] == 1:\r
-        try:\r
-            STATE["current"] = current_slideshow.current_slide()\r
-            STATE["total"] = current_slideshow.total_slides()\r
-            STATE["visible"] = current_slideshow.visible()\r
-            STATE["name"] = current_slideshow.name()\r
-        except Exception as e:\r
-            logger.info("Failed to update state variables, presumably PPT instance doesn't exist anymore: {}".format(e))\r
-            current_slideshow.unload()\r
-    else:\r
-        STATE = copy(STATE_DEFAULT)\r
-    if USERS:  # asyncio.wait doesn't accept an empty list\r
-        message = state_event()\r
-        loop.call_soon_threadsafe(ws_queue.put_nowait, state_event())\r
-\r
-\r
-\r
 async def ws_handler(websocket, path):\r
+    """\r
+    Handle a WebSockets connection\r
+    """\r
     logger.debug("Handling WebSocket connection")\r
     recv_task = asyncio.ensure_future(ws_receive(websocket, path))\r
     send_task = asyncio.ensure_future(ws_send(websocket, path))\r
@@ -158,60 +115,65 @@ async def ws_handler(websocket, path):
         task.cancel()\r
 \r
 async def ws_receive(websocket, path):\r
-    logger.debug("Received websocket request")\r
-    USERS.add(websocket)\r
+    """\r
+    Process data received on the WebSockets connection\r
+    """\r
+    users.add(websocket)\r
     try:\r
         # Send initial state to clients on load\r
-        notify_state()\r
+        for pres in ppt_presentations:\r
+            broadcast_presentation(ppt_presentations[pres])\r
         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["action"] == "prev":\r
-                if current_slideshow:\r
-                    current_slideshow.prev()\r
-                #notify_state()\r
+                pres.prev()\r
             elif data["action"] == "next":\r
-                if current_slideshow:\r
-                    current_slideshow.next()\r
-                #notify_state()\r
+                pres.next()\r
             elif data["action"] == "first":\r
-                if current_slideshow:\r
-                    current_slideshow.first()\r
-                #notify_state()\r
+                pres.first()\r
             elif data["action"] == "last":\r
-                if current_slideshow:\r
-                    current_slideshow.last()\r
-                #notify_state()\r
+                pres.last()\r
             elif data["action"] == "black":\r
-                if current_slideshow:\r
-                    if current_slideshow.visible() == 3:\r
-                        current_slideshow.normal()\r
-                    else:\r
-                        current_slideshow.black()\r
-                #notify_state()\r
+                if pres.state() == 3:\r
+                    pres.normal()\r
+                else:\r
+                    pres.black()\r
             elif data["action"] == "white":\r
-                if current_slideshow:\r
-                    if current_slideshow.visible() == 4:\r
-                        current_slideshow.normal()\r
-                    else:\r
-                        current_slideshow.white()\r
-                #notify_state()\r
+                if pres.state() == 4:\r
+                    pres.normal()\r
+                else:\r
+                    pres.white()\r
             elif data["action"] == "goto":\r
-                if current_slideshow:\r
-                    current_slideshow.goto(int(data["value"]))\r
-                #notify_state()\r
+                pres.goto(int(data["value"]))\r
             else:\r
                 logger.error("Received unnsupported event: {}", data)\r
     finally:\r
-        USERS.remove(websocket)\r
+        users.remove(websocket)\r
 \r
 async def ws_send(websocket, path):\r
+    """\r
+    Broadcast data to all WebSockets clients\r
+    """\r
     while True:\r
         message = await ws_queue.get()\r
-        await asyncio.wait([user.send(message) for user in USERS])\r
+        await asyncio.wait([user.send(message) for user in users])\r
+\r
+\r
+def run_http():\r
+    """\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
 \r
 \r
 def run_ws():\r
+    """\r
+    Set up threading/async for WebSockets server\r
+    """\r
     # https://stackoverflow.com/questions/21141217/how-to-launch-win32-applications-in-separate-threads-in-python/22619084#22619084\r
     # https://www.reddit.com/r/learnpython/comments/mwt4qi/pywintypescom_error_2147417842_the_application/\r
     pythoncom.CoInitializeEx(pythoncom.COINIT_MULTITHREADED)\r
@@ -224,208 +186,273 @@ def run_ws():
     asyncio.get_event_loop().run_until_complete(start_server)\r
     asyncio.get_event_loop().run_forever()\r
 \r
-def start_http():\r
+def setup_http():\r
+    """\r
+    Set up threading for HTTP server\r
+    """\r
     http_daemon = threading.Thread(name="http_daemon", target=run_http)\r
     http_daemon.setDaemon(True)\r
     http_daemon.start()\r
     logger.info("Started HTTP server")\r
 \r
-def restart_http():\r
-    global http_server\r
-    if http_server:\r
-        http_server.shutdown()\r
-        http_server = None\r
-        refresh_status()\r
-    start_http()\r
-    time.sleep(0.5)\r
-    refresh_status()\r
-\r
-def start_ws():\r
+def setup_ws():\r
+    """\r
+    Set up threading for WebSockets server\r
+    """\r
     global ws_daemon\r
     ws_daemon = threading.Thread(name="ws_daemon", target=run_ws)\r
     ws_daemon.setDaemon(True)\r
     ws_daemon.start()\r
     logger.info("Started websocket server")\r
+    \r
 \r
-def restart_ws():\r
-    global ws_daemon\r
-    global ws_stop_event\r
-    if ws_daemon and not ws_stop_event:\r
-        ws_stop_event = True\r
-        logger.debug("Stopped WebSocket server")\r
-        refresh_status()\r
-        #ws_daemon = None\r
-        time.sleep(2)\r
-        #start_ws()\r
-        refresh_status()\r
+def broadcast_presentation(pres):\r
+    """\r
+    Broadcast the state of a single presentation to all connected clients. Also ensures the current\r
+    slide and the two upcoming slides are exported and cached.\r
+    """\r
+    name = pres.presentation.Name\r
+    pres_open = name in ppt_presentations\r
+    slideshow = pres.slideshow is not None\r
+    visible = pres.state()\r
+    slide_current = pres.slide_current()\r
+    slide_total = pres.slide_total()\r
+\r
+    pres.export_current_next()\r
     \r
+    if users:   # asyncio.wait doesn't accept an empty list\r
+        state = {"name": name, "pres_open": pres_open, "slideshow": slideshow, "visible": visible,\r
+                    "slide_current": slide_current, "slide_total": slide_total}\r
+        loop.call_soon_threadsafe(ws_queue.put_nowait, json.dumps({"type": "state", **state}))\r
 \r
 class ApplicationEvents:\r
-    def OnSlideShowNextSlide(self, *args):\r
-        notify_state()\r
-        logger.debug("Slide changed")\r
-        current_slideshow.export_current_next()\r
-\r
-    def OnSlideShowPrevSlide(self, *args):\r
-        notify_state()\r
-        logger.debug("Slide changed")\r
-        current_slideshow.export_current_next()\r
-\r
-class Slideshow:\r
-    def __init__(self, instance, blackwhite):\r
-        self.instance = instance\r
-        if self.instance is None:\r
-            raise ValueError("PPT instance cannot be None")\r
-\r
-        if self.instance.SlideShowWindows.Count == 0:\r
-            raise ValueError("PPT instance has no slideshow windows")\r
-        self.view = self.instance.SlideShowWindows(1).View\r
-\r
-        if self.instance.ActivePresentation is None:\r
-            raise ValueError("PPT instance has no active presentation")\r
-        self.presentation = self.instance.ActivePresentation\r
-\r
-        self.blackwhite = blackwhite\r
-\r
-        if config.prefs["Main"]["cache_init"]:\r
-            self.export_all()\r
-        else:\r
-            self.export_current_next()\r
+    """\r
+    Events assigned to the root application.\r
+    Ref: https://docs.microsoft.com/en-us/office/vba/api/powerpoint.application#events\r
+    """\r
+    def OnSlideShowNextSlide(self, window, *args):\r
+        """\r
+        Triggered when the current slide number of any slideshow is incremented, locally or through\r
+        ppt_control.\r
+        """\r
+        logger.debug("Slide advanced for {}".format(window.Presentation.Name))\r
+        broadcast_presentation(ppt_presentations[window.Presentation.Name])\r
 \r
-        events = win32com.client.WithEvents(win32com.client.GetActiveObject("Powerpoint.Application"), ApplicationEvents)\r
-        logger.debug("Dispatched events")\r
+    def OnSlideShowPrevSlide(self, window, *args):\r
+        """\r
+        Triggered when the current slide number of any slideshow is decremented, locally or through\r
+        ppt_control.\r
+        """\r
+        logger.debug("Slide decremented for {}".format(window.Presentation.Name))\r
+        broadcast_presentation(ppt_presentations[window.Presentation.Name])\r
+    \r
+    def OnAfterPresentationOpen(self, presentation, *args):\r
+        """\r
+        Triggered when an existing presentation is opened. This adds the newly opened presentation\r
+        to the list of open presentations.\r
+        """\r
+        logger.debug("Presentation {} opened - 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
+        disable_protected_attempted.discard(presentation.Name)\r
+        icon.notify("Connected to {}".format(presentation.Name), PKG_NAME)\r
+\r
+    def OnAfterNewPresentation(self, presentation, *args):\r
+        """\r
+        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
+        global ppt_presentations\r
+        ppt_presentations[presentation.Name] = Presentation(ppt_application, pres_obj=presentation)\r
+        broadcast_presentation(ppt_presentations[presentation.Name])\r
+        icon.notify("Connected to {}".format(presentation.Name), PKG_NAME)\r
 \r
-    def unload(self):\r
-        connect_ppt()\r
+    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
+        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
+        broadcast_presentation(ppt_presentations.pop(presentation.Name))\r
+        icon.notify("Disconnected from {}".format(presentation.Name), PKG_NAME)\r
 \r
-    def refresh(self):\r
-        try:\r
-            if self.instance is None:\r
-                raise ValueError("PPT instance cannot be None")\r
+    def OnSlideShowBegin(self, window, *args):\r
+        """\r
+        Triggered when a slideshow is started. This initialises the Slideshow object in the \r
+        appropriate Presentation object.\r
+        """\r
+        logger.debug("Slideshow started for {}".format(window.Presentation.Name))\r
+        global ppt_presentations\r
+        ppt_presentations[window.Presentation.Name].slideshow = window\r
+        broadcast_presentation(ppt_presentations[window.Presentation.Name])\r
+    \r
+    def OnSlideShowEnd(self, presentation, *args):\r
+        """\r
+        Triggered when a slideshow is ended. This deinitialises the Slideshow object in the \r
+        appropriate Presentation object.\r
+        """\r
+        logger.debug("Slideshow ended for {}".format(presentation.Name))\r
+        global ppt_presentations\r
+        ppt_presentations[presentation.Name].slideshow = None\r
+        broadcast_presentation(ppt_presentations[presentation.Name])\r
+\r
+\r
+class Presentation:\r
+    """\r
+    Class representing an instance of PowerPoint with a file open (so-called "presentation"\r
+    in PowerPoint terms). Mostly just a wrapper for PowerPoint's `Presentation` object.\r
+    """\r
+    def __init__(self, application, pres_index=None, pres_obj=None):\r
+        """\r
+        Initialise a Presentation object.\r
+            application     The PowerPoint application which the presentation is being run within\r
+            pres_index      PowerPoint's internal presentation index (NOTE this is indexed from 1)\r
+        """\r
+        if pres_index == None and pres_obj == None:\r
+            raise ValueError("Cannot initialise a presentation without a presentation ID or object")\r
+        assert len(application.Presentations) > 0, "Cannot initialise presentation from application with no presentations"\r
 \r
-            if self.instance.SlideShowWindows.Count == 0:\r
-                raise ValueError("PPT instance has no slideshow windows")\r
-            self.view = self.instance.SlideShowWindows(1).View\r
+        self.__application = application\r
+        if pres_obj is not None:\r
+            self.presentation = pres_obj\r
+        else:\r
+            self.presentation = application.Presentations(pres_index)\r
+        self.slideshow = self.get_slideshow()\r
 \r
-            if self.instance.ActivePresentation is None:\r
-                raise ValueError("PPT instance has no active presentation")\r
-        except:\r
-            self.unload()\r
 \r
-    def total_slides(self):\r
+    def get_slideshow(self):\r
+        """\r
+        Check whether the presentation is in slideshow mode, and if so, return the SlideShowWindow.\r
+        """\r
         try:\r
-            self.refresh()\r
-            return len(self.presentation.Slides)\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
+            return self.presentation.SlideShowWindow\r
+        except pywintypes.com_error as exc:\r
+            logger.debug("Couldn't get slideshow for {}: {}".format(self.presentation.Name, exc))\r
+            return None\r
 \r
-    def current_slide(self):\r
-        try:\r
-            self.refresh()\r
-            return self.view.CurrentShowPosition\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
+    def state(self):\r
+        """\r
+        Returns the visibility state of the slideshow:\r
+        2: normal\r
+        3: black\r
+        4: white\r
+        5: done\r
+        """\r
+        if self.slideshow is not None:\r
+            return self.slideshow.View.State\r
+        else:\r
+            return 2\r
 \r
-    def visible(self):\r
-        try:\r
-            self.refresh()\r
-            return self.view.State\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
+    def slide_current(self):\r
+        """\r
+        Returns the current slide number of the slideshow, or 0 if no slideshow is running.\r
+        """\r
+        if self.slideshow is not None:\r
+            return self.slideshow.View.CurrentShowPosition\r
+        else:\r
+            return 0\r
+\r
+    def slide_total(self):\r
+        """\r
+        Returns the total number of slides in the presentation, regardless of whether a slideshow\r
+        is running.\r
+        """\r
+        return self.presentation.Slides.Count\r
 \r
     def prev(self):\r
-        try:\r
-            self.refresh()\r
-            self.view.Previous()\r
-            self.export_current_next()\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
+        """\r
+        Go to the previous slide if there is a slideshow running. Notifying clients of the new state\r
+        is managed by the ApplicationEvent.\r
+        """\r
+        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+        self.slideshow.View.Previous()\r
 \r
     def next(self):\r
-        try:\r
-            self.refresh()\r
-            self.view.Next()\r
-            self.export_current_next()\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
+        """\r
+        Go to the previous slide if there is a slideshow running. Notifying clients of the new state\r
+        is managed by the ApplicationEvent.\r
+        """\r
+        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+        self.slideshow.View.Next()\r
 \r
     def first(self):\r
-        try:\r
-            self.refresh()\r
-            self.view.First()\r
-            self.export_current_next()\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
+        """\r
+        Go to the first slide if there is a slideshow running. Notifying clients of the new state\r
+        is managed by the ApplicationEvent.\r
+        """\r
+        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+        self.slideshow.View.First()\r
                 \r
     def last(self):\r
-        try:\r
-            self.refresh()\r
-            self.view.Last()\r
-            self.export_current_next()\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
+        """\r
+        Go to the last slide if there is a slideshow running. Notifying clients of the new state\r
+        is managed by the ApplicationEvent.\r
+        """\r
+        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+        self.slideshow.View.Last()\r
 \r
     def goto(self, slide):\r
-        try:\r
-            self.refresh()\r
-            if slide <= self.total_slides():\r
-                self.view.GotoSlide(slide)\r
-            else:\r
-                self.last()\r
-                self.next()\r
-            self.export_current_next()\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
-\r
-    def black(self):\r
-        try:\r
-            self.refresh()\r
-            if self.blackwhite == "both" and self.view.State == 4:\r
-                self.view.state = 1\r
-            else:\r
-                self.view.State = 3\r
-            self.export_current_next()\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
-\r
-    def white(self):\r
-        try:\r
-            self.refresh()\r
-            if self.blackwhite == "both" and self.view.State == 3:\r
-                self.view.state = 1\r
-            else:\r
-                self.view.State = 4\r
-            self.export_current_next()\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
+        """\r
+        Go to a numbered slide if there is a slideshow running. Notifying clients of the new state\r
+        is managed by the ApplicationEvent.\r
+        """\r
+        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+        if slide <= self.slide_total():\r
+            self.slideshow.View.GotoSlide(slide)\r
+        else:\r
+            self.last()\r
+            self.next()\r
 \r
     def normal(self):\r
-        try:\r
-            self.refresh()\r
-            self.view.State = 1\r
-            self.export_current_next()\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
+        """\r
+        Make the slideshow visible if there is a slideshow running.\r
+        """\r
+        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+        self.slideshow.View.State = 2\r
+        broadcast_presentation(self)\r
 \r
-    def name(self):\r
-        try:\r
-            self.refresh()\r
-            return self.presentation.Name\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
+    def black(self):\r
+        """\r
+        Make the slideshow black if there is a slideshow running. \r
+        """\r
+        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+        self.slideshow.View.State = 3\r
+        broadcast_presentation(self)\r
 \r
+    def white(self):\r
+        """\r
+        Make the slideshow white if there is a slideshow running. \r
+        """\r
+        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+        self.slideshow.View.State = 4\r
+        broadcast_presentation(self)\r
 \r
     def export_current_next(self):\r
-        self.export(self.current_slide())\r
-        self.export(self.current_slide() + 1)\r
-        self.export(self.current_slide() + 2)\r
+        """\r
+        Export the current slide, the next slide, and the one after (ensures enough images are \r
+        always cached)\r
+        """\r
+        self.export(self.slide_current())\r
+        self.export(self.slide_current() + 1)\r
+        self.export(self.slide_current() + 2)\r
 \r
     def export(self, slide):\r
-        destination = config.prefs["Main"]["cache"] + "\\" + self.name() + "\\" + str(slide) + ".jpg"\r
-        logger.debug("Exporting slide " + str(slide))\r
+        """\r
+        Export a relatively low-resolution image of a slide using PowerPoint's built-in export \r
+        function. The cache destination is set in the config.\r
+        """\r
+        destination = config.prefs["Main"]["cache"] + "\\" + self.presentation.Name + "\\" + str(slide) + "." + config.prefs["Main"]["cache_format"].lower()\r
+        logger.debug("Exporting slide {} of {}".format(slide, self.presentation.Name))\r
         os.makedirs(os.path.dirname(destination), exist_ok=True)\r
         if not os.path.exists(destination) or time.time() - os.path.getmtime(destination) > config.prefs.getint("Main", "cache_timeout"):\r
-            if slide <= self.total_slides():\r
+            if slide <= self.slide_total():\r
                 attempts = 0\r
                 while attempts < 3:\r
                     try:\r
@@ -434,200 +461,168 @@ class Slideshow:
                     except:\r
                         pass\r
                     attempts += 1\r
-            elif slide == self.total_slides() + 1:\r
+            elif slide == self.slide_total() + 1:\r
                 try:\r
                     shutil.copyfileobj(open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\black.jpg''', 'rb'), open(destination, 'wb'))\r
-                except Exception as e:\r
-                    logger.warning("Failed to copy black slide: " + str(e))\r
+                except Exception as exc:\r
+                    logger.warning("Failed to copy black slide (number {} for presentation {}): {}".format(slide, self.presentation.Name, exc))\r
             else:\r
                 pass\r
 \r
     def export_all(self):\r
-        for i in range(1, self.total_slides() + 2):\r
+        """\r
+        Export all slides in the presentation\r
+        """\r
+        for i in range(1, self.slide_total() + 2):\r
             self.export(i)\r
 \r
-def get_ppt_instance():\r
-    instance = win32com.client.Dispatch('Powerpoint.Application')\r
-    if instance is None or instance.SlideShowWindows.Count == 0:\r
-        return None\r
-    return instance\r
-\r
-def get_current_slideshow():\r
-    return current_slideshow\r
-\r
-def refresh_interval():\r
+def null_action(*args):\r
+    """\r
+    Placeholder for disabled menu items in systray\r
+    """\r
+    pass\r
+                \r
+def refresh():\r
+    """\r
+    Clear COM events and update interface elements at an interval defined in "refresh" in the config\r
+    """\r
     while getattr(refresh_daemon, "do_run", True):\r
-        logger.debug("Refreshing general")\r
-        pythoncom.PumpWaitingMessages()\r
-        current_slideshow.refresh()\r
-        if current_slideshow.visible != STATE["visible"]:\r
-            notify_state()\r
-        #refresh_status()\r
-        time.sleep(REFRESH_INTERVAL)\r
-\r
-def refresh_status():\r
-    if interface_root is not None:\r
-        logger.debug("Refreshing UI")\r
-        if status_label is not None:\r
-            status_label.config(text="PowerPoint status: " + ("not " if not STATE["connected"] else "") +  "connected")\r
-        if http_label is not None:\r
-            http_label.config(text="HTTP server: " + ("not " if http_server is None else "") +  "running")\r
-            #ws_label.config(text="WebSocket server: " + ("not " if ws_daemon is not None or not ws_daemon.is_alive() else "") +  "running")\r
-        if reset_ppt_button is not None:\r
-            reset_ppt_button.config(state = tk.DISABLED if not STATE["connected"] else tk.NORMAL)\r
-\r
-def connect_ppt():\r
-    global STATE\r
-    global refresh_daemon\r
-    if STATE["connected"] == 1:\r
-        logger.info("Disconnected from PowerPoint instance")\r
-        icon.notify("Disconnected from PowerPoint instance")\r
-        if reset_ppt_button is not None:\r
-            reset_ppt_button.config(state = tk.DISABLED)\r
-        refresh_daemon.do_run = False\r
-        STATE = copy(STATE_DEFAULT)\r
-        if icon is not None:\r
-            refresh_menu()\r
-        refresh_status()\r
-        logger.debug("State is now " + str(STATE))\r
-    while True:\r
         try:\r
-            instance = get_ppt_instance()\r
-            global current_slideshow\r
-            current_slideshow = Slideshow(instance, config.prefs["Main"]["blackwhite"])\r
-            STATE["connected"] = 1\r
-            STATE["current"] = current_slideshow.current_slide()\r
-            STATE["total"] = current_slideshow.total_slides()\r
-            icon.notify("Connected to PowerPoint instance")\r
-            if icon is not None:\r
-                refresh_menu()\r
-            refresh_status()\r
-            logger.info("Connected to PowerPoint instance")\r
-            refresh_daemon = threading.Thread(name="refresh_daemon", target=refresh_interval)\r
-            refresh_daemon.setDaemon(True)\r
-            refresh_daemon.start()\r
-            break\r
-        except ValueError as e:\r
-            current_slideshow = None\r
-            pass\r
-        time.sleep(1)\r
-\r
-def start(_=None):\r
-    start_http()\r
-    start_ws()\r
-    connect_ppt()\r
-\r
-def on_closing():\r
-    global status_label\r
-    global http_label\r
-    global ws_label\r
-    global interface_thread\r
-    status_label = None\r
-    http_label = None\r
-    ws_label = None\r
-    logger.debug("Destroying interface root")\r
-    interface_root.destroy()\r
-    logger.debug("Destroying interface thread")\r
-    interface_thread.root.quit()\r
-    interface_thread = None\r
-    \r
-def open_settings(_=None):\r
-    global interface_root\r
-    global interface_thread\r
-    if interface_root is None:\r
-        interface_root = tk.Tk()\r
-        interface_root.protocol("WM_DELETE_WINDOW", on_closing)\r
-        interface_root.iconphoto(False, tk.PhotoImage(file=os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.png'''))\r
-        interface_root.geometry("600x300+300+300")\r
-        app = Interface(interface_root)\r
-        interface_thread = threading.Thread(target=interface_root.mainloop())\r
-        interface_thread.setDaemon(True)\r
-        interface_thread.start()\r
-\r
-def null_action():\r
-    pass\r
-\r
-def save_settings():\r
-    pass\r
-\r
-class Interface(ttk.Frame):\r
+            pythoncom.PumpWaitingMessages()\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
+            manage_protected_view(ppt_application)\r
+            time.sleep(float(config.prefs["Main"]["refresh"]))\r
+        except Exception as exc:\r
+            # Deal with any exceptions, such as RPC server restarting, by reconnecting to application\r
+            # (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
-    def __init__(self, parent):\r
-        ttk.Frame.__init__(self, parent)\r
-\r
-        self.parent = parent\r
-\r
-        self.initUI()\r
-\r
-    def initUI(self):\r
-        global status_label\r
-        global http_label\r
-        global ws_label\r
-        global reset_ppt_button\r
-        self.parent.title("ppt-control")\r
-        self.style = ttk.Style()\r
-        #self.style.theme_use("default")\r
-        self.focus_force()\r
-\r
-        self.pack(fill=tk.BOTH, expand=1)\r
-\r
-        quitButton = ttk.Button(self, text="Cancel", command=interface_root.destroy)\r
-        quitButton.place(x=480, y=280)\r
-\r
-        save_button = ttk.Button(self, text="OK", command=save_settings)\r
-        save_button.place(x=400, y=280)\r
-\r
-        reset_ppt_button = ttk.Button(self, text="Reconnect", command=connect_ppt)\r
-        reset_ppt_button.config(state = tk.DISABLED)\r
-        reset_ppt_button.place(x=300, y=10)\r
-\r
-        reset_http_button = ttk.Button(self, text="Restart", command=restart_http)\r
-        reset_http_button.place(x=300, y=30)\r
+        \r
 \r
-        #reset_ws_button = ttk.Button(self, text="Restart", command=restart_ws)\r
-        #reset_ws_button.place(x=300, y=50)\r
+def get_application():\r
+    """\r
+    Create an Application object representing the PowerPoint application installed on the machine.\r
+    This should succeed regardless of whether PowerPoint is running, as long as PowerPoint is\r
+    installed.\r
+    Returns the Application object if successful, otherwise returns None.\r
+    """\r
+    try:\r
+        return win32com.client.Dispatch('PowerPoint.Application')\r
+    except pywintypes.com_error:\r
+        # PowerPoint is probably not installed, or other COM failure\r
+        return None\r
+        \r
 \r
-        status_label = ttk.Label(self)\r
-        status_label.place(x=10,y=10)\r
+def manage_protected_view(app):\r
+    """\r
+    Attempt to unlock any presentations that have been opened in protected view. These cannot be\r
+    controlled by the program whilst they are in protected view, so we attempt to disable protected\r
+    view, or show a notification if this doesn't work for some reason.\r
+    """\r
+    try:\r
+        if app.ProtectedViewWindows.Count > 0:\r
+            logger.debug("Found open presentation(s) but at least one is in protected view")\r
+            for i in range(1, app.ProtectedViewWindows.Count + 1):  # +1 to account for indexing from 1\r
+                pres_name = app.ProtectedViewWindows(i).Presentation.Name\r
+                if pres_name in disable_protected_attempted:\r
+                    continue\r
+                if config.prefs.getboolean("Main", "disable_protected"):\r
+                    try:\r
+                        app.ProtectedViewWindows(i).Edit()\r
+                        logger.info("Enabled editing for {}".format(pres_name))\r
+                    except Exception as exc:\r
+                        icon.notify("Failed to disable protected view on \"{}\"".format((pres_name[:22] + '...') \r
+                        if len(pres_name) > 25 else pres_name), "Disable protected view in PowerPoint")\r
+                        logger.warning("Failed to disable protected view {} for editing - do this manually: {}".format(pres_name, exc))\r
+                        disable_protected_attempted.add(pres_name)\r
+                else:\r
+                    icon.notify("Cannot control \"{}\" in protected view".format((pres_name[:22] + '...') \r
+                        if len(pres_name) > 25 else pres_name), "Disable protected view in PowerPoint")\r
+                    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
+        # 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
+        app = get_application()\r
 \r
-        http_label = ttk.Label(self)\r
-        http_label.place(x=10,y=30)\r
 \r
-        ws_label = ttk.Label(self)\r
-        ws_label.place(x=10,y=50)\r
 \r
-        refresh_status()\r
+def connect_ppt():\r
+    """\r
+    Connect to the PowerPoint COM interface and perform initial enumeration of open files \r
+    ("presentations"). Files that are subsequently opened are dealt with using COM events (see the\r
+    ApplicationEvents class above). Therefore, once we are finished setting things up, we just \r
+    call refresh() as a daemon in order to keep clients up to date.\r
+    """\r
+    logger.debug("Searching for a PowerPoint slideshow...")\r
+    global ppt_application\r
+    global ppt_presentations\r
+\r
+    # Initialise PowerPoint application\r
+    ppt_application = get_application()\r
+    if ppt_application is None:\r
+        # Couldn't find PowerPoint application\r
+        icon.notify("Couldn't find PowerPoint application", "Error starting {}".format(PKG_NAME))\r
+        logger.error("Couldn't find PowerPoint application - check that PowerPoint is installed and COM is working")\r
+        sys.exit()\r
+\r
+    # Continue because we can connect to PowerPoint\r
+    logger.debug("Found PowerPoint application")\r
+\r
+    # Dispatch events\r
+    win32com.client.WithEvents(ppt_application, ApplicationEvents)\r
+    logger.debug("Dispatched events")\r
+\r
+    # Deal with windows in protected view\r
+    manage_protected_view(ppt_application)\r
+\r
+    # Initial enumeration of open presentations\r
+    logger.debug("Enumerating {} presentation(s)".format(len(ppt_application.Presentations)))\r
+    for n in range(1, len(ppt_application.Presentations)+1):    # PowerPoint's slide indexing starts at 1.. why!?!?!?\r
+        pres = Presentation(ppt_application, n)\r
+        icon.notify("Connected to {}".format(pres.presentation.Name), PKG_NAME)\r
+        logger.debug("Found presentation {} with index {}".format(pres.presentation.Name, n))\r
+        ppt_presentations[pres.presentation.Name] = pres\r
+    refresh_daemon = threading.Thread(name="refresh_daemon", target=refresh)\r
+    refresh_daemon.setDaemon(True)\r
+    logger.debug("Handing over to refresh daemon - goodbye...")\r
+    if len(ppt_presentations) == 0:\r
+        # Provide some confirmation that the program has started if we haven't sent any \r
+        # connection notifications yet\r
+        icon.notify("Started server", PKG_NAME)\r
+    refresh_daemon.start()\r
+\r
+\r
+def start_server(_=None):\r
+    """\r
+    Start HTTP and WS servers, then connect to PPT instance with connect_ppt() which will then \r
+    set off the refresh daemon.\r
+    """\r
+    setup_http()\r
+    setup_ws()\r
+    connect_ppt()\r
         \r
         \r
-def exit_action(icon):\r
+def exit(icon):\r
+    """\r
+    Clean up and exit when user clicks "Stop" from systray menu\r
+    """\r
     logger.debug("User requested shutdown")\r
-    if interface_root is not None:\r
-        try:\r
-            interface_root.destroy()\r
-        except:\r
-            pass\r
     icon.visible = False\r
     icon.stop()\r
 \r
-def refresh_menu():\r
-    icon.menu = (pystray.MenuItem("Status: " + "dis"*(not STATE["connected"]) + "connected", lambda: null_action(), enabled=False),\r
-            pystray.MenuItem("Stop", lambda: exit_action(icon)),\r
-            pystray.MenuItem("Settings", lambda: open_settings(), enabled=True)\r
-            )\r
-\r
-def show_icon():\r
-    global icon\r
-    logger.debug("Starting system tray icon")\r
-    icon = pystray.Icon("ppt-control")\r
-    icon.icon = Image.open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.ico''')\r
-    icon.title = "ppt-control"\r
-    refresh_menu()\r
-    icon.visible = True\r
-    icon.run(setup=start)\r
 \r
 def start_interface():\r
+    """\r
+    Main entrypoint for the program. Loads config and logging, starts systray icon, and calls\r
+    start_server() to start the backend.\r
+    """\r
+    global icon\r
     global logger\r
-\r
     # Load config\r
     config.prefs = config.loadconf(CONFIG_FILE)\r
 \r
@@ -664,8 +659,16 @@ def start_interface():
     logger.debug("Finished setting up config and logging")\r
 \r
     # Start systray icon and server\r
-    show_icon()\r
+    logger.debug("Starting system tray icon")\r
+    icon = pystray.Icon(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
+    icon.visible = True\r
+    icon.run(setup=start_server)\r
+\r
+    # Exit when icon has stopped\r
     sys.exit(0)\r
 \r
 if __name__ == "__main__":\r
-    start_interface()\r
+    start_interface()
\ No newline at end of file
index 21d08afbd0d70c91900f26c1dc8a0ee8d40aacfc..685c789d19458eeba4111ee6023435432699e31e 100644 (file)
@@ -29,7 +29,7 @@
                                <img class="icon" id="prev" src="icons/left.svg" />\r
                                <img class="icon" id="next" src="icons/right.svg" />\r
                                <img class="icon" id="last" src="icons/last.svg" />\r
-                                <span id="count"><span id="slide_label">Current: </span><input type="text" id="current"></input>/<span id="total">?</span></span>\r
+                       <span id="count"><span id="slide_label">Current: </span><input type="text" id="current"></input>/<span id="total">?</span></span>\r
                                <button id="black">Black</button>\r
                                <button id="white">White</button>\r
                        </p>\r
@@ -39,6 +39,7 @@
                        <input type="checkbox" checked="true" id="shortcuts">Keyboard shortcuts</input>\r
 \r
                        <p class="status_text">Disconnected</p>\r
+                               <p class="presentation_text">Presentation: <select id="presentation"></select></p>\r
                </div>\r
         </div>\r
 \r
index f884d90a8cfef532b74bbaf6064db4c953cc5ce4..1001b5d2b9e9ed123bbed084feddb7be2f6ed5da 100644 (file)
@@ -2,7 +2,8 @@ var DEFAULT_TITLE = "ppt-control"
 var preloaded = false;
 var preload = [];
 
-var prev = document.querySelector('#prev'),
+var presentation = document.querySelector('#presentation'),
+    prev = document.querySelector('#prev'),
     next = document.querySelector('#next'),
     first = document.querySelector('#first'),
     last = document.querySelector('#last'),
@@ -12,6 +13,7 @@ var prev = document.querySelector('#prev'),
     current = document.querySelector('#current'),
     total = document.querySelector('#total'),
     status_text = document.querySelector('.status_text'),
+    presentation_text = document.querySelector('.presentation_text'),
     current_img = document.querySelector('#current_img'),
     next_img = document.querySelector('#next_img'),
     current_div = document.querySelector('#current_div'),
@@ -22,6 +24,17 @@ var prev = document.querySelector('#prev'),
     show_next = document.querySelector('#show_next'),
     shortcuts = document.querySelector('#shortcuts');
 
+var presentations_list = {};
+
+
+function getPresentationName() {
+    if (presentation.selectedOptions.length > 0) {
+        return presentation.selectedOptions[0].innerText;
+    } else {
+        return "";
+    }
+}
+
 
 function startWebsocket() {
     console.log("Attempting to connect")
@@ -40,31 +53,45 @@ function startWebsocket() {
 var websocket = startWebsocket();
 
 prev.onclick = function (event) {
-    websocket.send(JSON.stringify({action: 'prev'}));
+    if (getPresentationName()) {
+        websocket.send(JSON.stringify({presentation: getPresentationName(), action: 'prev'}));
+    }
 }
 
 next.onclick = function (event) {
-    websocket.send(JSON.stringify({action: 'next'}));
+    if (getPresentationName()) {
+        websocket.send(JSON.stringify({presentation: getPresentationName(), action: 'next'}));
+    }
 }
 
 first.onclick = function (event) {
-    websocket.send(JSON.stringify({action: 'first'}));
+    if (getPresentationName()) {
+        websocket.send(JSON.stringify({presentation: getPresentationName(), action: 'first'}));
+    }
 }
 
 last.onclick = function (event) {
-    websocket.send(JSON.stringify({action: 'last'}));
+    if (getPresentationName()) {
+        websocket.send(JSON.stringify({presentation: getPresentationName(), action: 'last'}));
+    }
 }
 
 black.onclick = function (event) {
-    websocket.send(JSON.stringify({action: 'black'}));
+    if (getPresentationName()) {
+        websocket.send(JSON.stringify({presentation: getPresentationName(), action: 'black'}));
+    }
 }
 
 white.onclick = function (event) {
-    websocket.send(JSON.stringify({action: 'white'}));
+    if (getPresentationName()) {
+        websocket.send(JSON.stringify({presentation: getPresentationName(), action: 'white'}));
+    }
 }
 
 current.onblur = function (event) {
-    websocket.send(JSON.stringify({action: 'goto', value: current.value}));
+    if (getPresentationName()) {
+        websocket.send(JSON.stringify({presentation: getPresentationName(), action: 'goto', value: current.value}));
+    }
 }
 
 current.addEventListener('keyup',function(e){
@@ -159,6 +186,42 @@ document.addEventListener('keydown', function (e) {
        }
 });
 
+function refreshInterface() {
+    var d = new Date;
+    currentPresentationData = presentations_list[getPresentationName()];
+    presentation_text.style.display = "block";
+    status_text.textContent = "Slideshow running";
+    if (show_current.checked) {
+        switch (currentPresentationData.visible) {
+            case 3:
+                current_img.src = "/black.jpg";
+                break;
+            case 4:
+                current_img.src = "/white.jpg";
+                break;
+            default:
+                current_img.src = "/cache/" + currentPresentationData.name + "/" + currentPresentationData.slide_current + ".jpg?t=" + d.getTime();
+                break;
+        }
+    }
+    if (currentPresentationData.slide_current == currentPresentationData.slide_total + 1) { 
+        next_img.src = "/black.jpg";
+    } else {
+        next_img.src = "/cache/" + currentPresentationData.name + "/" + (currentPresentationData.slide_current + 1) + ".jpg?t=" + d.getTime();
+    }
+    if (currentPresentationData.slide_current == 0) {
+        current_img.src = "/black.jpg";
+        next_img.src = "/black.jpg";
+        status_text.textContent = "Slideshow not running";
+    }
+
+    if (document.activeElement != current) {
+        current.value = currentPresentationData.slide_current;
+    }
+    total.textContent = currentPresentationData.slide_total;
+    document.title = currentPresentationData.name;
+}
+
 function disconnect() {
   console.log("Disconnecting")
     document.title = DEFAULT_TITLE;
@@ -167,55 +230,47 @@ function disconnect() {
     status_text.textContent = "Disconnected";
     total.textContent = "?";
     current.value = "";
+    presentation_text.style.display = "none";
 }
 
 function receive_message(event) {
     data = JSON.parse(event.data);
-    switch (data.type) {
-        case 'state':
-            if (data.connected == "0" || data.connected == 0) {
-               disconnect();
-               break;
-            } else {
-              status_text.textContent = "Connected";
-            }
-            var d = new Date;
-            if (show_current.checked) {
-              switch (data.visible) {
-                  case 3:
-                      current_img.src = "/black.jpg";
-                      break;
-                  case 4:
-                      current_img.src = "/white.jpg";
-                      break;
-                  default:
-                      current_img.src = "/cache/" + data.current + ".jpg?t=" + d.getTime();
-                      //current_img.src = "/cache/" + data.current + ".jpg";
-                      break;
-              }
-            }
-            if (data.current == data.total + 1) { 
-                next_img.src = "/cache/" + (data.total + 1) + ".jpg?t=" + d.getTime();
-                //next_img.src = "/cache/" + (data.total + 1) + ".jpg";
-            } else {
-                next_img.src = "/cache/" + (data.current + 1) + ".jpg?t=" + d.getTime();
-                //next_img.src = "/cache/" + (data.current + 1) + ".jpg";
-            }
+    presentations_list[data.name] = {"name": data.name, "pres_open": data.pres_open, "slideshow": data.slideshow, "visible": data.visible, "slide_current": data.slide_current, "slide_total": data.slide_total};
+    if (! presentations_list[data.name].pres_open) {
+        delete presentations_list[data.name];
+        if (Object.keys(presentations_list).length == 0) {
+            disconnect();
+        } else {
+            status_text.textContent = "Connected";
+        }
+    } else {
+        
+        var presentation_dropdown = Array.from(presentation.children);
 
-            if (document.activeElement != current) {
-               current.value = data.current;
+        var found = false;
+        for(var i = 0; i < presentation_dropdown.length; i++) {
+            if (presentation_dropdown[i].textContent == data.name) {
+                if (! data.pres_open) {
+                    presentation_dropdown[i].remove()
+                } else {
+                    found = true;
+                    break;
+                }
             }
-            total.textContent = data.total;
-            document.title = data.name;
-            break;
-        default:
-            console.error(
-                "Unsupported event", data);
+        }
+        if (found == false) {
+            var dropdown_option = document.createElement("option");
+            dropdown_option.textContent = data.name;
+            dropdown_option.value = data.name;
+            presentation.appendChild(dropdown_option);
+        }
+        refreshInterface()
     }
+        
        if (preloaded == false && ! isNaN(total.textContent)) {
                image = document.getElementById("preload_img");
                for (let i=1; i<=Number(total.textContent); i++) {
-                       image.src = "/cache/" + i + ".jpg";
+                       image.src = "/cache/" + getPresentationName() + "/" + i + ".jpg";
                        preload.push(image);
                }
                preloaded = true;
index 01c57347f6a8fb9857a082e92c0ebb33a8c34929..018e8636048c03754fb663cba9fde4d71ee11fa9 100644 (file)
@@ -75,3 +75,7 @@ button {
 input[type='checkbox'] {
        font-size: 15px;
 }
+
+.presentation_text {
+       display: none;
+}