My journey in building display-dj, a cross-platform application to control monitors / display brightness
Display DJ

My journey in building display-dj, a cross-platform application to control monitors / display brightness

In this article, I will describe my journey and challenges I faced when building display-dj , a cross-platform desktop application to control integrated laptop display brightness along with external display brightness.

The Problems:

  • As of right now, it requires physical controls to change the brightness of external monitors. It is very time consuming and quirky to get the brightness notch just right. Also some external monitors bury the brightness under sub-menus within the on screen display, which requires a lot of mental strength to figure out.
  • There are applications out there that do these things: adjust external monitor brightness, integrated laptop monitor brightness and dark mode, but they are completely different apps and require more context switching. These applications sometimes don't support shortcuts or key bindings. And most importantly none of them are cross platform and only support either Windows or Mac OSX.
  • Windows and Mac OSX have their own built in controls that allow you to adjust brightness and dark mode. But this built-in option only works for integrated displays such as your laptop monitors or certain proprietary monitors such as Apple Displays. The built-in solution does not work for third party displays.
  • Another issue with built-in solution is sometimes the user interface is not intuitive and requires extra clicks and navigations to get to because they are buried deep inside a set of nested menus.

The motivations:

What I showed you here is nuisances I have to deal with on a daily basis. I worked with both Mac OSX and Windows. There is no easy solution out there that allows me to go from a dark room mode (brightness all the way down and dark mode on) to a well lit room mode (brightness cranked all the way up and dark mode off).

Normally when this happens, I have to manually tune each monitors one by one with their physical controls and also update the display individually. This change should be easier with a few keystrokes or mouse clicks, and not you fiddling with physical controls and clicking on components that are buried deep underneath nested System Preference menus.

No joke, the following pictures are real photos from my workstation. Day and night difference in comparison. In day mode, dark mode is turned off, and brightness is turned to the max. Whereas night mode, dark mode is turned on, and brightness is dimmed down to the minimum.

My day working setup
My nightworking setup

The challenge of work from home in the last 2 years with 2 young toddlers is that they can charge into your room any time of the day and playing with the light switch. This is my defense mechanism for those sudden changes in light intensity. I can toggle between 2 different modes rather quickly with a key stroke: going to the dark side vs going to the light side of the force.

The Links:

The Stack:

First, I started building out the boilerplate app with Electron , React and Typescript . For simplicity, I used pure Webpack instead of create-react-app. I used raw SCSS and styles with no UI system design to simplify things. The application is simple and doesn't have a router.

The Journey:

Initially, in the first version of the UI, my layout lists out all the individual monitors with the option to change all brightness at the bottom.

Version 1 of display-dj: all monitors as a part of the overall controls

This first version is too complicated to visualize and takes up too much space. As from the code perspective, you will have a harder time synching the sliders anytime all monitors slider changes its value.

So I decided to make a second version of the UI to hide the complexity of the sliders in 2 modes. Mode 1 (collapsed mode) allows you to change brightness of all monitors and mode 2 (expanded mode) allows you to update individual display brightness. You clicked on the top right expansion icon to go between mode. At the end, version 2 is what I settled with the final app.

Version 2: collapsed mode - showing all brightness control as a single slider
Version 2: expanded mode - showing individual display brightness control

For this program, I made the decision to have the app as part of the system tray or taskbar. So it's easier to get to from the users' perspective. And it also supports key bindings, so the user can trigger a brightness change based on key bindings.

Then I spent majority of my time debugging and investigating how to control the brightness of the internal display as well as those external displays using pure software instead of physical controls. This is when things get really complicated really quickly.

Due to this complexity, initially I built out the app with support only for Windows and slowly investigating to see if support for Mac OSX is even an option. At the end, it is possible to support both platforms.

Finally, my time is allocated to setting up the CI /CD pipeline for building and releasing the electron app. This time around, I chose electron-packager over electron-builder which I used in my previous project for electron. And I like electron-packager more than electron-builder because you can actually control and fine tune the configuration using Java Script, whereas electron-builder is a stricter solution and requires more configuration to get right. You can refer to this dist.js file on how I set it up.

Challenges with Internal Display Interactions

For integrated displays, Mac OSX and Windows handle brightness controls very differently.

For Windows, internal display brightness can be controlled using the WMI (Windows Management Instrumentation) . Below are the snippets of the script I used to get and update internal display brightness.

Getting brightness of internal display on Windows

(Get-WmiObject -Namespace root/WMI -Class WmiMonitorBrightness).CurrentBrightness
        

Updating brightness of internal display on Windows

(Get-WmiObject -Namespace root/WMI -Class WmiMonitorBrightnessMethods).WmiSetBrightness(1,<newBrightness>)
        

For Mac OSX, internal display can be updated in 2 ways.

Using AppleScript to send a keyboard event for brightness down and brightness up. But this option does not give you a more refined control such as change the brightness to 80% instead of just going up or down by a notch.

# turn brightness one notch down
osascript -e 'tell application "System Events"' -e 'key code 144' -e ' end tell'


# turn brightness one notch up
osascript -e 'tell application "System Events"' -e 'key code 145' -e ' end tell'
        

I ended up settled with the second option. In this case, using a CLI tool called brightness (can be installed via brew install brightness)

Getting brightness of internal display on Mac OSX

brightness -l        

Updating brightness of internal display on Mac OSX

brightness -d <newBrightness>        

Challenges with External Display Interactions

Things don't look any better with external displays. For external displays, we have a protocol called DDC/CI (Display Data Channel / Control Interface) . This protocol is supposed to make controlling external monitors with software easier, but in reality vendors choose to do their own things and implemented part of the protocol. We ended up with a lot of discrepancy. This makes it nearly impossible to support a wide array of displays and setups.

For Windows, I ended up using an npm package called @hensm/node-ddcci . It works pretty well with the external monitors I have at home. The API is also pretty simple to use and everything seems to be keyed off a monitorId.

const ddcci = require("@hensm/ddcci");

for (const monitor of ddcci.getMonitorList()) {
    // to get the monitor brightness
? ? console.log(`${monitor} current brightness: ${ddcci.getBrightness(monitor)}`);

   ?// to set the brightness
  ? ddcci.setBrightness(monitor, 25);
}        

For Mac OSX, I ended up using a CLI tool called ddcctl . Given the same setup I have at home, mac support for DDC/CI is pretty inconsistent. ddcctl seems to fail intermittently trying to get the current brightness level of the same external display. So I ended up caching the last known brightness of each display and use that instead as a fallback when ddcctl failed.

The API of ddcctl is pretty straightforward. Below is the code snippet. The only thing is we need to figure out which displayId to use for the API.

# getting the brightness for a display
ddcctl -d 1 -b \?


# setting the brightness for a display
ddcctl -d 1 -b 50        

Challenges with bundling dependent binaries

This application requires a few extra dependencies in the form of binaries such as ddcctl and brightness for mac and volume helper for windows to run. Initially I would ask the users of the application to download and install these dependencies manually.

I find it extremely hard to ask non-tech-savvy users to do such a thing, then I went on a journey to figure out how I can bundle that as part of the application and users can start using display-dj out of the box without any additional steps.

What I did was I create a task that bundle these binaries into the final build and reference the path of these binaries using process['resourcePath']. The following is a sample code I used to get the path to the resource binary.

const _getVolumeHelperBinary = async () => path.join(process['resourcesPath'], `win32_volume_helper.exe`);        

Then as part of the build, these binaries are automatically copied over to the final bundle.

Challenges with tray menu positioning

Trying to position the tray icon and its menu on the desktop is really complicated because there are so many variations of the tray orientations and sizes.

On Mac OSX, things are simpler since the tray can only be located on the top of the display.

On Windows, things are wild. You can change the taskbar location to left side, right side, top side, bottom side and they can have different taskbar dimensions. Also the tray icon of your app might be collapsed inside the overflow menu.

Windows headache - tray icon is present at all times
Windows headache - tray icon is a part of the overflow menu

The way I tackled this is using screen API from Electron to get the width and height of the screen and then define where to place the tray menu. In my case, I split it up to 4 quadrant and depending on the tray icon location, it can be in either one of those quadrant. This calculation is using the above API for screen width and height.

import { screen } from 'electron';

const mainScreen = screen.getPrimaryDisplay();
const mainScreenSize = mainScreen.size;
        

CI / CD challenges

A challenge I faced in setting up the CI/CD pipeline is that some packages are only OS dependent and can't be compiled for other platform. In my case, @hensm/ddcci can't be compiled on a Mac.

The solution I came up with for this issue is to use optionalDependencies in package.json to store any OS specific addons and create my own script to capture the packages that need to be installed for the app in different operating systems.

Here's a snippet of the package.json

"optionalDependencies": {
? "@hensm/ddcci": "^0.1.0",
? "dark-mode": "^4.0.0",
  "electron-installer-dmg": "^3.0.0",
? "electron-winstaller": "^5.0.0"
}        

Then I relied on --no-optional flag of npm to install only required dependencies for the core app.

npm install --no-optional        

Then I created a pre-build hook to install the OS specific dependencies.

Sample pre-build.js script. Note that here I also define which DisplayAdapter to use for the build. In this case, specific adapter for Darwin (Mac OSX) and Win32 (Windows) will be selected for the bundling.

const fs = require('fs')
const { exec } = require('child_process');


let source;
const dest = `src/electron/utils/DisplayAdapter.ts`;
let packages = [];


switch (process.platform) {
? case 'win32':
? ? source = `src/electron/utils/DisplayAdapter.Win32.ts`;
? ? packages = ['@hensm/ddcci', 'electron-winstaller'];
? ? break;
? case 'darwin':
? ? source = `src/electron/utils/DisplayAdapter.Darwin.ts`;
? ? packages = ['dark-mode', 'electron-installer-dmg'];
? ? break;
}


// install extra dependencies
exec(`npm install ${packages.join(' ')}`);


fs.copyFileSync(source, dest);        

You can refer to the whole CI/CD definition here

The Future Features:

Although this is a simple application, but there are so many quality of life improvements you can do. Below is a list of potential features I think will make it better:

  • Automatically change brightness of displays based on time of day. Just like dark mode, high brightness in the morning can be useful to see things under heavy lightning, whereas in the afternoon, it's better to tune down the brightness to comfort your eyes.
  • Ability to set up custom profiles for displays, so with a click of a button, automatically adjust individual display to that preferred settings of brightness.
  • What about Linux ports? Investigate and see if it's possible to support Linux. This can be quite challenging because Linux has a lot of GUI and Desktop Environment like MATE, KDE, etc...

The Summary:

  • Ideally these components to control display brightness regardless of internal or external display should be a part of the OS and shouldn't require additional application. But the reality is it's not available at the OS level.
  • Building a software application that controls hardware configs is hard due to inconsistencies in the API as well as protocols used in each operating system.
  • Being able to make application works consistently across different platforms and configurations in terms of software and layouts requires intensive thought process and tinkering.

要查看或添加评论,请登录

社区洞察

其他会员也浏览了