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
\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
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
"""\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
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
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
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
\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
def export(self, 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
+ function. The cache destination is set in the config. The slide is not exported if it has \r
+ a non-stale cached file.\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 not os.path.exists(destination) or (config.prefs.getint("Main", "cache_timeout") > 0 and \r
+ time.time() - os.path.getmtime(destination) > config.prefs.getint("Main", "cache_timeout")):\r
if slide <= self.slide_total():\r
attempts = 0\r
while attempts < 3:\r
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
# (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
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
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
log_level = logging.DEBUG\r
elif config.prefs["Main"]["logging"] == "info":\r
- log_level = logging.CRITICAL\r
+ log_level = logging.INFO\r
else:\r
log_level = logging.WARNING\r
- log_level = logging.DEBUG\r
\r
log_formatter = logging.Formatter("%(asctime)s [%(threadName)-12.12s] [%(levelname)-7.7s] %(message)s")\r
logger = logging.getLogger("ppt-control")\r
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
\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
icon.visible = True\r
icon.run(setup=start_server)\r
\r
sys.exit(0)\r
\r
if __name__ == "__main__":\r
- start_interface()
\ No newline at end of file
+ start_interface()\r