DEV Community

Nick Parsons
Nick Parsons

Posted on

Winds 2.1: Building Touch Bar Support for macOS in Electron with React

Winds 2.1

One of the newest and coolest innovations in the tech/hardware world as of late is the touch bar that Apple released on its MacBook Pro last year. As these machines have become more and more popular, more and more applications are utilizing the touch bar to interact with their users in a new dimension. As we watched this trend unfold, we decided that it seemed like a fun challenge to tackle ourselves, since our pet project of the last several months, Winds 2.0, made its debut earlier this month.

Winds

As we continue to iterate on Winds based on feedback from the community, now seemed like the perfect time to launch support for touch bar control on macOS, in Winds 2.1. Going into it, it seemed like it was going to be a piece of cake. However, we couldn’t have been more wrong. The API communication between macOS and Electron is far from complete. Hopefully, we’ll see some changes in the near future for better support between the macOS and Electron.

For now, we’ve come up with a decent solution to the problem that allows us to communicate bi-directionally between Electron and the macOS touch bar. To do this, we heavily relied on three major Electron components:

  • The (limited) touch bar API that is provided by Electron
  • The ipcMain module, which handles asynchronous and synchronous messages sent from a renderer process (web page)
  • The ipcRenderer module, which provides a few methods that allow you to send synchronous and asynchronous messages from the renderer process (web page) to the main process (ipcMain).

In this post, we’ll do a deep dive into how we accomplished this task. Let’s do it.

The ipcMain Module

The ipcMain module is an instance of the EventEmitter class. When used in the main process, it handles asynchronous and synchronous messages sent from a renderer process (web page). Messages sent from a renderer are emitted to this module and picked up by an event handler and then passed off to a function for further processing.

Send & Receive from Electron

In /app/public/electron.js, we initialize the following code once the window is ready to show:

ipcMain.on('media-controls', (event, args) => {
    mediaControls(event, args);
});
Enter fullscreen mode Exit fullscreen mode

The event property specifies what happened, whereas the args can be a single value or an object of key-value pairs. For Winds, we chose to go with an object so we could pass along additional metadata (from the frontend), such as the current episode title and podcast name.

The ipcRenderer Module

The ipcRenderer module is an instance of the EventEmitter class. It provides a few methods that allow you to send synchronous and asynchronous messages from the renderer process (web page) to the main process (Electron).

Understanding how communication works was the first step in our journey to get media control support in place. To better understand how it works, let’s look at a few short code examples:

Send & Receive from React

In /app/src/components/Player.js, we use window.ipcRenderer, as ipcRenderer is not directly available, thus requiring us to pull it off of the window object:

window.ipcRenderer.send('media-controls', {
    type: 'play',
    title: `${episode.title} - ${episode.podcast.title}`,
});
Enter fullscreen mode Exit fullscreen mode

AND

window.ipcRenderer.send('media-controls', {
    type: 'pause',
});
Enter fullscreen mode Exit fullscreen mode

So, after all is said and done, we can use the player context to differentiate between a playing episode and a paused episode. It looks something like this:

if (isElectron()) {
    if (context.playing) {
        window.ipcRenderer.send('media-controls', {
            type: 'play',
            title: `${episode.title} - ${episode.podcast.title}`,
        });
    } else {
        window.ipcRenderer.send('media-controls', {
            type: 'pause',
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

React Lifecycle Events

On componentDidMount(), we use the following handler to ensure that our incoming events are picked up.

Note: We wrap our code in an Electron check via the is-electron Node module to ensure that we only execute this in an Electron environment – this is important because we have web and native versions of the application.

componentDidMount() {
    if (isElectron()) {
        window.ipcRenderer.on('media-controls', this.incomingMediaControls);
    }
}
Enter fullscreen mode Exit fullscreen mode

On componentWillUnmount(), we use the following handler to ensure that all listeners are destroyed:

componentWillUnmount() {
    if (isElectron()) {
        window.ipcRenderer.removeAllListeners('media-controls', this.incomingMediaControls);
    }
}
Enter fullscreen mode Exit fullscreen mode

Electron Touch Bar API

As pointed out in the previous portion of this post, we initialize ipcMain in our electron.js file. But wait, there’s more... We also have a portion of code dedicated to handling the incoming (and outbound) messages, in addition to toggling the touch bar images, and handling touch bar events:

mediaControls = (event, args) => {
    let next = new TouchBarButton({
        icon: `${__dirname}/static/next.png`,
        click: () => {
            event.sender.send('media-controls', 'next');
        },
    });

    let previous = new TouchBarButton({
        icon: `${__dirname}/static/previous.png`,
        click: () => {
            event.sender.send('media-controls', 'previous');
        },
    });

    let playPause = new TouchBarButton({
        icon: `${__dirname}/static/pause.png`,
        click: () => {
            event.sender.send('media-controls', 'togglePlayPause');
        },
    });

    let info = new TouchBarLabel({
        label:
            args.title && args.title.length > 40
                ? `${args.title.substr(0, 40) + '...'}`
                : args.title,
        textColor: '#FFFFFF',
    });

    if (args.type === 'play') {
        playPause.icon = `${__dirname}/static/pause.png`;
        info.label = args.title;
    } else {
        playPause.icon = `${__dirname}/static/play.png`;
    }

    let touchBar = new TouchBar([
        previous,
        playPause,
        next,
        new TouchBarSpacer({ size: 'flexible' }),
        info,
        new TouchBarSpacer({ size: 'flexible' }),
    ]);

    mainWindow.setTouchBar(touchBar);
};
Enter fullscreen mode Exit fullscreen mode

This function should go in your main.js file, or in our case, the electron.js file.

Final Product

All of this put together gives Winds 2.1 an awesome touch bar feature that allows our users to pause and play podcast episodes in Winds, view the current podcast that is playing, and seek both forward and backward.

Touch Bar

As we continue building the app and receive feedback from our awesome community, we are hoping to continue to add new ways for the user to interact with the touch bar and leave people feeling pleasantly surprised with each of their interactions with Winds.

If you think that I’ve missed anything, please feel free to drop a line in the comments below or find me on Twitter – @NickParsons.

Top comments (0)