Please, stop saying "next slide please"

Published: Friday, Nov 6, 2020
Cover

If your company is working at home because of COVID-19 and you all use Google Slides and Google Meet, then you’re probably used to hearing “next slide please” whenever multiple presenters need to jointly present to a group. This isn’t an issue with Zoom because it allows others to share control of their screen. Microsoft Teams also lets participants share control. But not Google Meet.

I looked for an extension for Google Meet that would solve this problem, but I couldn’t find anything, so I had to build one! Hence my latest project: the Shared Slides Clicker Chrome Extension.

Here’s a quick preview of how it works:

If this sounds like a tool that might help you, go to the Shared Slides Clicker Chrome Extension to learn more.

Lessons Learned

This was a really fun project and I want to share how I built it and what I learned. The source is available on Gitlab if you just want to jump straight into the bits and bytes: fonner/shared-slides-clicker

Don’t start from scratch

When I last built a browser extension, it wasn’t until the end that I learned about the amazing 🚀 web-extension-starter project on Github. For this new project, I wanted to give it a shot, and after having used it, I can say that it’s totally awesome. Not only does it allow you to choose your preferred technologies, but it also provides convenience methods for easy development and building.

The only change I had to make was a small tweak to support multiple different content scripts. I just had to add both files to the contentScript entry in the Extension Reloader and add both content scripts to the entry configuration in webpack.config.js:

...
20: const extensionReloaderPlugin =
...
25:   entries: {
26:     contentScript: ['presentationContentScript', 'meetContentScript'],
...
47: module.exports = {
...
62:   entry: {
...
65:     presentationContentScript: path.join(sourcePath, 'PresentationContentScript', 'index.js'),
66:     meetContentScript: path.join(sourcePath, 'MeetContentScript', 'index.js'),
...

Sending keystrokes was tricky

The most important part of this extension is the ability to change slides and control video. This is accomplished by simulating the keyboard shortcuts that Google Slides accepts during presentation mode. It’s not possible to do this from the content script, so instead the content script must inject Javascript into the Google Slides page by calling const newScript = document.createElement('script'), adding the necessary code, and then calling document.appendChild(newScript). After the script is injected and runs, the content script then removes the newly injected Javascript using newScript.parentNode.removeChild(newScript)

The next trick is to actually send the keystokes to the browser. There are multiple ways to send keyboard events, but the way I found that works best is to create a new keydown KeyboardEvent, e.g., new KeyboardEvent('keydown'...). The second argument to KeyboardEvent is an object that needs to contain the keyCode of the desired keystroke. I used the Javascript Keycode website to find the keyCode for the important keyboard keys. Then the final trick is to call dispatchEvent() on the right DOM element. For Google Slides in presentation-mode, that DOM element is a DIV with the classname punch-present-iframe;

Background communication isn’t so bad once you get used to it

Browser extensions use background scripts to handle long-lived operations. Since the Shared Slides Clicker needs to handle communication across the internet between presenters, the extension needs a background script that can work alongside the content scripts.

To prevent Chrome from deactivating the Shared Slides Clicker background script while it’s listening for commands from other presenters, I had to set the persistent flag to true in the manifest.json file. Google recommends setting persistent to false but if set to false, the background page will eventually become deactivated and no longer be able to listen for remote commands.

Background pages are also tricky to use because content scripts must communicate with the background page via message passing. I recommend creating one generic listener in the background script and using a key in the message to determine how to handle the message. For examples, see the bottom of the Shared Slides Clicker background script.

The content scripts can pass messages to the background script using the appropriate key and additonal data if needed using the browser.runtime.sendMessage API method. This method also accepts an optional pair of callback functions for handling return data and errors from the background script respectively. See messageUtils.js for how this is handled in Shared Slides Clicker.

Google Sign-In is easy except when it’s not

Shared Slides Clicker connects Google Slides and Google Meet, so it’s reasonable to assume that the user has a Google Account. Therefore the Shared Slides Clicker uses Google Sign-In to authenticate and keep track of users.

Setting up Google Sign-In using Firebase is quite easy and is well documented on the Authenticate Using Google Sign-In with JavaScript docs page. Getting it working in Shared Slides Clicker required just a few lines of code.

When using Google Sign-In within a Chrome extension, however, there are two important things to be aware of:

  1. You must add the chrome extension id to the list of Authorized Domains in the Firebase console. See Authenticate with Firebase in a Chrome extension for more information.
  2. Google Sign-In does not work within a Firefox extension. I found multiple people complaining about this, and the issue is known by Google but apparently they do not care. Because of this, I’m unable to create a version of this extension for Firefox 😭.

Also, if you want the authentication screen to show a nice application name like “Shared Slides Clicker” instead of an ugly url, you need to get your app verified by Google. This requires jumping through lots and lots of hoops as outlined here: OAuth API verification FAQs. Basically, you need to go to the Google Cloud console, not Firebase console, and find the APIs & Services > OAuth consent screen settings, and follow the steps to verify your app. You’ll need a domain or subdomain you control, such as a personal website or Gitlab/Github pages subdomain.

Reverse engineering Google apps for fun and (no) profit

In order for Shared Slides Clicker to work, it needs to do two key things:

  1. Know when a Google Slides presentation starts so it knows when remote commands can be processed
  2. Add control buttons into Google Meet but only after the user has joined an active Meet session

Shared Slides Clicker detects when a Google Slides is in presentation mode by watching for the presence of a div with the class punch-present-iframe.

Unfortunately the approach for Google Meet isn’t quite as clean. Initially I tried adding a button next to the strip on the top-right of Meet, similar to how the GridView extension works, but I noticed that Google changed the code for the button strip, causing my injection algorithm to break. So I opted to just inject a new, similar looking button in the top-middle of the screen. When a Meet actually starts, Google creates a new div for the video with a class of NzPR9b. This is what Shared Slides Clicker watches for to know when to inject the control buttons into Meet. This also feels very fragile, but so far it seems this part of Google Meet’s codebase changes much less frequently than the buttons. If they do change this, however, I’ll need to figure out the new DOM element and update the extension.

By the way, by “watching for” I mean that Shared Slides Clicker creates a new MutationObserver and observes the document body for the addition or removal of certain DOM elements. I’ve found that as long as you pass attributes: false to the observe method, the performance is very good.

CSS animations can be fun

Styling an app always seems hard, and using CSS animations seems doubly so. In trying to get the interactions for Shared Slides Clicker to not look janky, I had to learn a lot about CSS transition and animation features. The key was to use the Animations Dev Tools Pane and slow down the animation speed to see clearly what was going on. Doing so made it clear how to set up the timings for the various animations and transitions to make everything look smooth.

Storybook is totally worth it

Because Shared Slides Clicker gets injected into Meet, testing the actual implementation requires reloading Meet after every extension change, which is frustratingly slow. This is where Storybook comes in. It lets you rapidly and easily test UI components. With a little code to mock the message passing between client-script and background-script, it’s possible to render the Shared Slides Clicker Meet UIs inside storybook. This makes rapid development possible and fun!

Who needs Google Analytics?

Firebase Analytics is not supported inside browser extensions. You can use plain old Google Analytics, but I decided to not do that because I don’t want to capture all that personal info from users of Shared Slides Clicker. I just want to know at a high level how much the extension is being used.

It turns out you can use Cloud Functions to create cheap and easy analytics. By creating a database trigger, it’s possible to track when new data is created or removed, aka analytics.

To ensure that your analytics updates are safe from race conditions, it’s important to use the Firebase transaction operation. This takes a function to update the data, which in this case will be to increment the analytics metrics. Shared Slides Clicker uses this approach to keep track of how many slide actions have been successfully sent and how many unique domains have used the extension.

In the future, I am planning on creating a Cloud Function to expose the metrics as a GET endpoint, and then show the realtime metrics on the Shared Slides Clicker Chrome homepage.

One Git repo, multiple projects

A git lifehack I learned while doing this project was the idea of storing completely different, yet related, codebases within one Git repo. I first came across this idea while reading the Gitlab Pages documentation. The idea is to create an empty, orphan branch within your repo like:

git checkout --orphan pages

This creates a new, blank branch totally disconnected from all the other branches and commits. They recommend this for the Gitlab Pages code and it worked so nicely I decided to use it to keep my Firebase Functions code separate from the extension code as well. This is useful since they’re all separate codebases but are connected to a shared purpose.

So I can have 3 separate working directories checked out on my local, but all the code is nicely stored in one Gitlab repo. Fun!

Wrapup

All in all, this was one of the most enjoyable projects I’ve done recently. I got to explore new technologies and techniques while also building something genuinely useful (I hope).

If you use Google Slides and Google Meet at work, please check out the Shared Slides Clicker Chrome extension and let me know what you think.