From: Andrew Lorimer Date: Tue, 27 Apr 2021 11:41:03 +0000 (+1000) Subject: add keyboard shortcuts, icons, preloading X-Git-Tag: v0.0.1~4 X-Git-Url: https://git.lorimer.id.au/ppt-control.git/diff_plain/d403fb62e2d0d54d217f6c9ae5d76ca5051855ad?ds=sidebyside;hp=384420f45de9bd39a4c0f1e2a89d876c52efe1c9 add keyboard shortcuts, icons, preloading --- diff --git a/icons/first.svg b/icons/first.svg new file mode 100644 index 0000000..8d0f155 --- /dev/null +++ b/icons/first.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/last.svg b/icons/last.svg new file mode 100644 index 0000000..7064515 --- /dev/null +++ b/icons/last.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/left.svg b/icons/left.svg new file mode 100644 index 0000000..acb94c1 --- /dev/null +++ b/icons/left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/right.svg b/icons/right.svg new file mode 100644 index 0000000..a9e5aa7 --- /dev/null +++ b/icons/right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/index.html b/index.html index a1a4c3f..5a0cec6 100755 --- a/index.html +++ b/index.html @@ -6,32 +6,39 @@ ppt-control +
+

Current slide

+

Next slide

+
-

- - - - - - - Current: /? -

- - Show current slide - Show next slide - -

Not connected

+
+

+ + + + + Current: /? + + +

+ + Show current slide + Show next slide + Keyboard shortcuts + +

Not connected

+
diff --git a/obs_ppt_server.py b/obs_ppt_server.py deleted file mode 100755 index 4172d94..0000000 --- a/obs_ppt_server.py +++ /dev/null @@ -1,326 +0,0 @@ -import sys -sys.coinit_flags= 0 -import win32com.client -import pywintypes -import os -import shutil -import http_server_39 as server -#import http.server as server -import socketserver -import threading -import asyncio -import websockets -import logging, json -import urllib -import posixpath -import time -import pythoncom - -logging.basicConfig() - -global current_slideshow -current_slideshow = None -CACHEDIR = r'''C:\Windows\Temp\ppt-cache''' -CACHE_TIMEOUT = 2*60*60 - -class Handler(server.SimpleHTTPRequestHandler): - def __init__(self, *args, **kwargs): - super().__init__(*args, directory=os.path.dirname(os.path.realpath(__file__))) - - def translate_path(self, path): - """Translate a /-separated PATH to the local filename syntax. - - Components that mean special things to the local file system - (e.g. drive or directory names) are ignored. (XXX They should - probably be diagnosed.) - - """ - # abandon query parameters - path = path.split('?',1)[0] - path = path.split('#',1)[0] - # Don't forget explicit trailing slash when normalizing. Issue17324 - trailing_slash = path.rstrip().endswith('/') - try: - path = urllib.parse.unquote(path, errors='surrogatepass') - except UnicodeDecodeError: - path = urllib.parse.unquote(path) - path = posixpath.normpath(path) - words = path.split('/') - words = list(filter(None, words)) - if len(words) > 0 and words[0] == "cache": - if current_slideshow: - path = CACHEDIR + "\\" + current_slideshow.name() - words.pop(0) - else: - path = self.directory - for word in words: - if os.path.dirname(word) or word in (os.curdir, os.pardir): - # Ignore components that are not a simple file/directory name - continue - path = os.path.join(path, word) - if trailing_slash: - path += '/' - print(path) - return path - - -def run_http(): - http_server = server.HTTPServer(("", 80), Handler) - http_server.serve_forever() - -STATE = {"connected": 0, "current": 0, "total": 0, "visible": 0} -USERS = set() - - -def state_event(): - return json.dumps({"type": "state", **STATE}) - - -def users_event(): - return json.dumps({"type": "users", "count": len(USERS)}) - - -async def notify_state(): - global current_slideshow - if current_slideshow: - STATE["current"] = current_slideshow.current_slide() - STATE["total"] = current_slideshow.total_slides() - STATE["visible"] = current_slideshow.visible() - if USERS: # asyncio.wait doesn't accept an empty list - message = state_event() - await asyncio.wait([user.send(message) for user in USERS]) - - -async def notify_users(): - if USERS: # asyncio.wait doesn't accept an empty list - message = users_event() - await asyncio.wait([user.send(message) for user in USERS]) - - -async def register(websocket): - USERS.add(websocket) - await notify_users() - - -async def unregister(websocket): - USERS.remove(websocket) - await notify_users() - - -async def ws_handle(websocket, path): - global current_slideshow - # register(websocket) sends user_event() to websocket - await register(websocket) - try: - await websocket.send(state_event()) - async for message in websocket: - data = json.loads(message) - if data["action"] == "prev": - if current_slideshow: - current_slideshow.prev() - await notify_state() - elif data["action"] == "next": - if current_slideshow: - current_slideshow.next() - await notify_state() - elif data["action"] == "first": - if current_slideshow: - current_slideshow.first() - await notify_state() - elif data["action"] == "last": - if current_slideshow: - current_slideshow.last() - await notify_state() - elif data["action"] == "black": - if current_slideshow: - if current_slideshow.visible() == 3: - current_slideshow.normal() - else: - current_slideshow.black() - await notify_state() - elif data["action"] == "white": - if current_slideshow: - if current_slideshow.visible() == 4: - current_slideshow.normal() - else: - current_slideshow.white() - await notify_state() - elif data["action"] == "goto": - if current_slideshow: - current_slideshow.goto(int(data["value"])) - await notify_state() - elif data["action"] == "refresh": - if current_slideshow: - current_slideshow.export_current_next() - current_slideshow.refresh() - await notify_state() - else: - logging.error("unsupported event: {}", data) - finally: - await unregister(websocket) - -def run_ws(): - pythoncom.CoInitializeEx(pythoncom.COINIT_MULTITHREADED) - asyncio.set_event_loop(asyncio.new_event_loop()) - start_server = websockets.serve(ws_handle, "0.0.0.0", 5678) - print("Initialised websocket server") - asyncio.get_event_loop().run_until_complete(start_server) - print("Running websocket server until complete") - asyncio.get_event_loop().run_forever() - -def start_server(): - #STATE["current"] = current_slide() - http_daemon = threading.Thread(name="http_daemon", target=run_http) - http_daemon.setDaemon(True) - http_daemon.start() - print("Started HTTP server") - - #run_ws() - - ws_daemon = threading.Thread(name="ws_daemon", target=run_ws) - ws_daemon.setDaemon(True) - ws_daemon.start() - print("Started websocket server") - - #try: - # ws_daemon.start() - # http_daemon.start() - #except (KeyboardInterrupt, SystemExit): - # cleanup_stop_thread() - # sys.exit() - -class Slideshow: - def __init__(self, instance): - self.instance = instance - if self.instance is None: - raise ValueError("PPT instance cannot be None") - - if self.instance.SlideShowWindows.Count == 0: - raise ValueError("PPT instance has no slideshow windows") - self.view = self.instance.SlideShowWindows[0].View - - if self.instance.ActivePresentation is None: - raise ValueError("PPT instance has no active presentation") - self.presentation = self.instance.ActivePresentation - - self.export_all() - - def refresh(self): - if self.instance is None: - raise ValueError("PPT instance cannot be None") - - #if self.instance.SlideShowWindows.Count == 0: - # raise ValueError("PPT instance has no slideshow windows") - self.view = self.instance.SlideShowWindows[0].View - - if self.instance.ActivePresentation is None: - raise ValueError("PPT instance has no active presentation") - - def total_slides(self): - return len(self.presentation.Slides) - - def current_slide(self): - return self.view.CurrentShowPosition - - def visible(self): - return self.view.State - - def prev(self): - self.refresh() - self.view.Previous() - - def next(self): - self.refresh() - self.view.Next() - self.export_current_next() - - def first(self): - self.refresh() - self.view.First() - self.export_current_next() - - def last(self): - self.refresh() - self.view.Last() - self.export_current_next() - - def goto(self, slide): - self.refresh() - if slide <= self.total_slides(): - self.view.GotoSlide(slide) - else: - self.last() - self.next() - self.export_current_next() - - def black(self): - self.refresh() - self.view.State = 3 - - def white(self): - self.refresh() - self.view.State = 4 - - def normal(self): - self.refresh() - self.view.State = 1 - - def name(self): - return self.presentation.Name - - def export_current_next(self): - self.export(self.current_slide()) - self.export(self.current_slide() + 1) - - def export(self, slide): - destination = CACHEDIR + "\\" + self.name() + "\\" + str(slide) + ".jpg" - os.makedirs(os.path.dirname(destination), exist_ok=True) - if not os.path.exists(destination) or time.time() - os.path.getmtime(destination) > CACHE_TIMEOUT: - if slide <= self.total_slides(): - attempts = 0 - while attempts < 3: - try: - self.presentation.Slides(slide).Export(destination, "JPG") - time.sleep(0.5) - break - except: - pass - attempts += 1 - elif slide == self.total_slides() + 1: - shutil.copyfileobj(open(os.path.dirname(os.path.realpath(__file__)) + r'''\black.jpg''', 'rb'), open(destination, 'wb')) - else: - pass - - def export_all(self): - for i in range(1, self.total_slides()): - self.export(i) - -def get_ppt_instance(): - instance = win32com.client.Dispatch('Powerpoint.Application') - if instance is None or instance.SlideShowWindows.Count == 0: - return None - return instance - -def get_current_slideshow(): - print(str(current_slideshow)) - return current_slideshow - - -if __name__ == "__main__": - - start_server() - - while True: - # Check if PowerPoint is running - instance = get_ppt_instance() - try: - current_slideshow = Slideshow(instance) - STATE["connected"] = 1 - STATE["current"] = current_slideshow.current_slide() - STATE["total"] = current_slideshow.total_slides() - print("Connected to PowerPoint instance " + str(get_current_slideshow())) - break - except ValueError as e: - current_slideshow = None - pass - time.sleep(1) diff --git a/ppt-control.js b/ppt-control.js index 7496f1b..7e4c312 100644 --- a/ppt-control.js +++ b/ppt-control.js @@ -1,3 +1,5 @@ +var preloaded = false; + function imageRefresh(id) { img = document.getElementById(id); var d = new Date; @@ -17,9 +19,6 @@ function startWebsocket() { var websocket = startWebsocket(); -//if (window.obssstudio) { -//} - var prev = document.querySelector('#prev'), next = document.querySelector('#next'), first = document.querySelector('#first'), @@ -30,12 +29,15 @@ var prev = document.querySelector('#prev'), current = document.querySelector('#current'), total = document.querySelector('#total'), users = document.querySelector('.users'), - prev_img = document.querySelector('#prev_img'), + current_img = document.querySelector('#current_img'), next_img = document.querySelector('#next_img'), current_div = document.querySelector('#current_div'), next_div = document.querySelector('#next_div'), + controls_container = document.querySelector('#controls_container'), + controls_container_inner = document.querySelector('#controls_container_inner'), show_current = document.querySelector('#show_current'), - show_next = document.querySelector('#show_next'); + show_next = document.querySelector('#show_next'), + shortcuts = document.querySelector('#shortcuts'); prev.onclick = function (event) { websocket.send(JSON.stringify({action: 'prev'})); @@ -69,34 +71,89 @@ current.addEventListener('keyup',function(e){ if (e.which == 13) this.blur(); }); +current_img.onclick = function (event) { + next.click() +} + +next_img.onclick = function (event) { + next.click() +} + + function sync_current() { - console.log("State of current checkbox changed"); if (show_current.checked) { current_div.style.display = "block"; slide_label.style.display = "none"; next_div.style.width = "25%"; } else { current_div.style.display = "none"; - slide_label.style.display = "block"; - next_div.style.width = "95%"; + slide_label.style.display = "inline"; + next_div.style.width = "calc(100% - 20px)"; } + set_control_width(); saveSettings(); } show_current.onclick = sync_current; function sync_next() { - console.log("State of next checkbox changed"); if (show_next.checked) { next_div.style.display = "block"; current_div.style.width = "70%"; } else { next_div.style.display = "none"; - current_div.style.width = "95%"; + current_div.style.width = "calc(100% - 20px)"; } + set_control_width(); saveSettings(); } show_next.onclick = sync_next; +function set_control_width() { + var width = window.innerWidth + || document.documentElement.clientWidth + || document.body.clientWidth; + if (show_current.checked && show_next.checked && width > 800) { + controls_container_inner.style.width = "70%" + } else { + controls_container_inner.style.width = "100%" + } +} + + +document.addEventListener('keydown', function (e) { + if (shortcuts.checked) { + switch (e.key) { + case "Left": + case "ArrowLeft": + case "Up": + case "ArrowUp": + case "k": + case "K": + prev.click(); + break; + case " ": + case "Spacebar": + case "Enter": + case "Right": + case "ArrowRight": + case "Down": + case "ArrowDown": + case "j": + case "J": + next.click(); + break; + case "b": + case "B": + black.click(); + case "w": + case "W": + white.click(); + default: + return + } + } +}); + websocket.onmessage = function (event) { data = JSON.parse(event.data); switch (data.type) { @@ -110,7 +167,8 @@ websocket.onmessage = function (event) { current_img.src = "/white.jpg"; break; default: - current_img.src = "/cache/" + data.current + ".jpg?t=" + d.getTime(); + //current_img.src = "/cache/" + data.current + ".jpg?t=" + d.getTime(); + current_img.src = "/cache/" + data.current + ".jpg"; break; } if (data.current == data.total + 1) { @@ -125,6 +183,7 @@ websocket.onmessage = function (event) { current.value = data.current; } total.textContent = data.total; + document.title = data.name; break; case 'users': users.textContent = ( @@ -135,6 +194,18 @@ websocket.onmessage = function (event) { console.error( "unsupported event", data); } + if (!preloaded) { + var i = 0 + var preload = []; + for (let i=1; i<=Number(total.textContent); i++) { + image = new Image(); + image.src = "/cache/" + i + ".jpg"; + preload.push(image); + console.log("Preloaded image " + i); + } + preloaded = true; + } + }; var interval = setInterval(refresh, 5000); @@ -142,3 +213,4 @@ var interval = setInterval(refresh, 5000); function refresh() { websocket.send(JSON.stringify({action: 'refresh'})); } + diff --git a/ppt_control.py b/ppt_control.py new file mode 100755 index 0000000..75cf1b0 --- /dev/null +++ b/ppt_control.py @@ -0,0 +1,323 @@ +import sys +sys.coinit_flags= 0 +import win32com.client +import pywintypes +import os +import shutil +import http_server_39 as server +#import http.server as server +import socketserver +import threading +import asyncio +import websockets +import logging, json +import urllib +import posixpath +import time +import pythoncom + +logging.basicConfig() + +global current_slideshow +current_slideshow = None +CACHEDIR = r'''C:\Windows\Temp\ppt-cache''' +CACHE_TIMEOUT = 2*60*60 + +class Handler(server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=os.path.dirname(os.path.realpath(__file__))) + + def translate_path(self, path): + """Translate a /-separated PATH to the local filename syntax. + + Components that mean special things to the local file system + (e.g. drive or directory names) are ignored. (XXX They should + probably be diagnosed.) + + """ + # abandon query parameters + path = path.split('?',1)[0] + path = path.split('#',1)[0] + # Don't forget explicit trailing slash when normalizing. Issue17324 + trailing_slash = path.rstrip().endswith('/') + try: + path = urllib.parse.unquote(path, errors='surrogatepass') + except UnicodeDecodeError: + path = urllib.parse.unquote(path) + path = posixpath.normpath(path) + words = path.split('/') + words = list(filter(None, words)) + if len(words) > 0 and words[0] == "cache": + if current_slideshow: + path = CACHEDIR + "\\" + current_slideshow.name() + words.pop(0) + else: + path = self.directory + for word in words: + if os.path.dirname(word) or word in (os.curdir, os.pardir): + # Ignore components that are not a simple file/directory name + continue + path = os.path.join(path, word) + if trailing_slash: + path += '/' + return path + + +def run_http(): + http_server = server.HTTPServer(("", 80), Handler) + http_server.serve_forever() + +STATE = {"connected": 0, "current": 0, "total": 0, "visible": 0, "name": ""} +USERS = set() + + +def state_event(): + return json.dumps({"type": "state", **STATE}) + + +def users_event(): + return json.dumps({"type": "users", "count": len(USERS)}) + + +async def notify_state(): + global current_slideshow + if current_slideshow: + STATE["current"] = current_slideshow.current_slide() + STATE["total"] = current_slideshow.total_slides() + STATE["visible"] = current_slideshow.visible() + STATE["name"] = current_slideshow.name() + if USERS: # asyncio.wait doesn't accept an empty list + message = state_event() + await asyncio.wait([user.send(message) for user in USERS]) + + +async def notify_users(): + if USERS: # asyncio.wait doesn't accept an empty list + message = users_event() + await asyncio.wait([user.send(message) for user in USERS]) + + +async def register(websocket): + USERS.add(websocket) + await notify_users() + + +async def unregister(websocket): + USERS.remove(websocket) + await notify_users() + + +async def ws_handle(websocket, path): + global current_slideshow + # register(websocket) sends user_event() to websocket + await register(websocket) + try: + await websocket.send(state_event()) + async for message in websocket: + data = json.loads(message) + if data["action"] == "prev": + if current_slideshow: + current_slideshow.prev() + await notify_state() + elif data["action"] == "next": + if current_slideshow: + current_slideshow.next() + await notify_state() + elif data["action"] == "first": + if current_slideshow: + current_slideshow.first() + await notify_state() + elif data["action"] == "last": + if current_slideshow: + current_slideshow.last() + await notify_state() + elif data["action"] == "black": + if current_slideshow: + if current_slideshow.visible() == 3: + current_slideshow.normal() + else: + current_slideshow.black() + await notify_state() + elif data["action"] == "white": + if current_slideshow: + if current_slideshow.visible() == 4: + current_slideshow.normal() + else: + current_slideshow.white() + await notify_state() + elif data["action"] == "goto": + if current_slideshow: + current_slideshow.goto(int(data["value"])) + await notify_state() + elif data["action"] == "refresh": + if current_slideshow: + current_slideshow.export_current_next() + current_slideshow.refresh() + await notify_state() + else: + logging.error("unsupported event: {}", data) + finally: + await unregister(websocket) + +def run_ws(): + pythoncom.CoInitializeEx(pythoncom.COINIT_MULTITHREADED) + asyncio.set_event_loop(asyncio.new_event_loop()) + start_server = websockets.serve(ws_handle, "0.0.0.0", 5678) + asyncio.get_event_loop().run_until_complete(start_server) + asyncio.get_event_loop().run_forever() + +def start_server(): + #STATE["current"] = current_slide() + http_daemon = threading.Thread(name="http_daemon", target=run_http) + http_daemon.setDaemon(True) + http_daemon.start() + print("Started HTTP server") + + #run_ws() + + ws_daemon = threading.Thread(name="ws_daemon", target=run_ws) + ws_daemon.setDaemon(True) + ws_daemon.start() + print("Started websocket server") + + #try: + # ws_daemon.start() + # http_daemon.start() + #except (KeyboardInterrupt, SystemExit): + # cleanup_stop_thread() + # sys.exit() + +class Slideshow: + def __init__(self, instance): + self.instance = instance + if self.instance is None: + raise ValueError("PPT instance cannot be None") + + if self.instance.SlideShowWindows.Count == 0: + raise ValueError("PPT instance has no slideshow windows") + self.view = self.instance.SlideShowWindows[0].View + + if self.instance.ActivePresentation is None: + raise ValueError("PPT instance has no active presentation") + self.presentation = self.instance.ActivePresentation + + self.export_all() + + def refresh(self): + if self.instance is None: + raise ValueError("PPT instance cannot be None") + + #if self.instance.SlideShowWindows.Count == 0: + # raise ValueError("PPT instance has no slideshow windows") + self.view = self.instance.SlideShowWindows[0].View + + if self.instance.ActivePresentation is None: + raise ValueError("PPT instance has no active presentation") + + def total_slides(self): + return len(self.presentation.Slides) + + def current_slide(self): + return self.view.CurrentShowPosition + + def visible(self): + return self.view.State + + def prev(self): + self.refresh() + self.view.Previous() + + def next(self): + self.refresh() + self.view.Next() + self.export_current_next() + + def first(self): + self.refresh() + self.view.First() + self.export_current_next() + + def last(self): + self.refresh() + self.view.Last() + self.export_current_next() + + def goto(self, slide): + self.refresh() + if slide <= self.total_slides(): + self.view.GotoSlide(slide) + else: + self.last() + self.next() + self.export_current_next() + + def black(self): + self.refresh() + self.view.State = 3 + + def white(self): + self.refresh() + self.view.State = 4 + + def normal(self): + self.refresh() + self.view.State = 1 + + def name(self): + return self.presentation.Name + + def export_current_next(self): + self.export(self.current_slide()) + self.export(self.current_slide() + 1) + + def export(self, slide): + destination = CACHEDIR + "\\" + self.name() + "\\" + str(slide) + ".jpg" + os.makedirs(os.path.dirname(destination), exist_ok=True) + if not os.path.exists(destination) or time.time() - os.path.getmtime(destination) > CACHE_TIMEOUT: + if slide <= self.total_slides(): + attempts = 0 + while attempts < 3: + try: + self.presentation.Slides(slide).Export(destination, "JPG") + time.sleep(0.5) + break + except: + pass + attempts += 1 + elif slide == self.total_slides() + 1: + shutil.copyfileobj(open(os.path.dirname(os.path.realpath(__file__)) + r'''\black.jpg''', 'rb'), open(destination, 'wb')) + else: + pass + + def export_all(self): + for i in range(1, self.total_slides()): + self.export(i) + +def get_ppt_instance(): + instance = win32com.client.Dispatch('Powerpoint.Application') + if instance is None or instance.SlideShowWindows.Count == 0: + return None + return instance + +def get_current_slideshow(): + return current_slideshow + + +if __name__ == "__main__": + + start_server() + + while True: + # Check if PowerPoint is running + instance = get_ppt_instance() + try: + current_slideshow = Slideshow(instance) + STATE["connected"] = 1 + STATE["current"] = current_slideshow.current_slide() + STATE["total"] = current_slideshow.total_slides() + print("Connected to PowerPoint instance") + break + except ValueError as e: + current_slideshow = None + pass + time.sleep(1) diff --git a/settings.js b/settings.js index 2807801..eb94f8e 100644 --- a/settings.js +++ b/settings.js @@ -25,21 +25,22 @@ function getCookie(cname) { } function saveSettings() { - settingsString = JSON.stringify({showcurrent: show_current.checked, shownext: show_next.checked}); - console.log("Saving cookie " + settingsString); + settingsString = JSON.stringify({showcurrent: show_current.checked, shownext: show_next.checked, enable_shortcuts: shortcuts.checked}); setCookie(COOKIENAME, settingsString, COOKIEEXP); } function initSettings() { - console.log("Retrieving cookie"); if (getCookie(COOKIENAME) == 0) { - console.log("No cookie found - setting new cookie"); + if (window.obssstudio) { + shortcuts.checked = False; + show_current.checked = False; + } saveSettings() } else { cookie = JSON.parse(getCookie(COOKIENAME)); - console.log("Found cookie " + cookie); show_current.checked = cookie.showcurrent; show_next.checked = cookie.shownext; + shortcuts.checked = cookie.enable_shortcuts; sync_current(); sync_next(); } diff --git a/style.css b/style.css index 5b2be56..01c5734 100644 --- a/style.css +++ b/style.css @@ -30,10 +30,6 @@ p { clear: both; } -::-webkit-scrollbar { - //display: none; -} - body { background: #3a393a; color: #efefef; @@ -52,6 +48,30 @@ input { @media only screen and (max-width: 800px) { #current_div, #next_div { - width: 95% !important; + width: calc(100% - 20px) !important; } } + +.icon { + width: 50px; + filter: invert(88%) sepia(4%) saturate(15%) hue-rotate(18deg) brightness(92%) contrast(97%); +} + +.icon:hover { + cursor: pointer; + filter: invert(100%) sepia(24%) saturate(1720%) hue-rotate(187deg) brightness(123%) contrast(87%); +} + +.icon#first, .icon#last { + width: 20px; + margin-bottom: 10px; +} + +button { + float: right; + margin: 0 10px 0 0; +} + +input[type='checkbox'] { + font-size: 15px; +}