Fixing the Netflix Volume Slider

Intro

A while ago, I joked around with my friend about something we like to call ‘frustration driven development’, which seems to be the most effective method in certain situations. Sometimes the level of disappointment in user experience makes us go to great lengths, which is what this post relates to.

So what’s wrong with Netflix’s volume slider?

If it was simply a visual glitch or some minor quality-of-life issue, I wouldn’t be making this post. The problem is that the volume slider is almost fundamentally broken, and has a linear, rather than logarithmic, response. You probably don’t feel this way as you’ve gotten used to the abomination that is the status quo, especially since virtually no-one is getting this right apart from professional audio software, and are wondering what I’m possibly on about. I promise you if you give this a chance, you will never want to go back to a linear volume slider again.

There’s an article by Dr. Lex, the inspiration for my post, which explains this better than I ever could, and I recommend anyone who is interested to read this before you continue.

For those who skipped, the gist of it is that our ears have a logarithmic response to amplitude, which means when you have a volume slider with a linear response, halfway down will seem almost as loud as maximum volume, and the majority of the dynamic range will seem to be towards the very bottom part of the slider, in a tiny segment where you have to play microsurgeon to get the volume just quite right.

The article suggests the following equation in order to remedy this:
volume = a * e ^ (sliderPosition * b)

Where e is Euler’s number, a, b are constants, depending on the dynamic range of the equipment used to reproduce the sound, and sliderPosition is a float from 0 to 1, indicating the position of the volume slider.

In JS, this translates to:

1
const volume = a * Math.exp(sliderPosition * b)

I’ve found the following constants which are suggested in the article for a device with a dynamic range of 50 dB to work quite well on both my external speakers and headphones:

1
2
const a = 3.1623e-3
const b = 5.757

Please pretend these are now defined in the global scope, as I’m going to plainly reference a and b onwards in the code.

Approach

I’m convinced there are many methods to do this, especially after peeking into the Netflix front end javascript code. It’s over a hundred thousand lines long when prettified, which often brings Chrome to its knees when using the built-in debugger. Just searching for the word ‘volume’ brings up more than 250 references.

Luckily, all we have to do is find a way to reference the needed objects in our script and overwrite their volume setters / getters. The Netflix UI is built with React, so we can get some help from React Dev Tools as well.

The approach which I’ve chosen is to simply make the UI lie to the player about the value which was selected. Slider is at 80%? Take that, modify it, and send the modified value to the player. Let the UI think it’s still at 80%, even though the actual volume in the player will be different.

The first thing we need to do is get some kind of reference to the volume controller. Let’s start up a random movie, and have a look at the source code which we can inspect.

Looking into the Source tab in Chrome Dev Tools, we can quickly find the source code for the player’s front end in this file:

The file is huge, over 100,000 lines of JS, so let’s import it into a text editor and go through it locally. We’re searching for a function, probably within some kind of volume controller object, which tells the player the volume which was set with the slider.

I won’t go into too much detail here as this part was very manual and involved me reading and comprehending hundreds of lines of minified JS code, which is probably not the most interesting thing for the reader. After a few minutes though, intuition leads us to search for the phrase setVolume:, which brings up three function definitions.

We can actually see the individual components via the minified require function, and the code looks like this:

1
2
C.r("player/nfplayer/components/controls/volume.jsx", function(t, e, o) {
...

As you’ve guessed, I’m already interested in this component as it has a setVolume member function, which we will likely patch.

So how do we find a reference to this component without knowing anything other in our context than that document exists? The DOM elements have to reference React in some way, so let’s find one of the components in the DOM and see if we can get a reference that way.

Let’s open React Dev Tools, and try searching for Volume. This will lead us to the following node:

Right clicking this, you can get lead yourself to the DOM element of that corresponding component.

This was a bit of trial and error, but essentially, if you do Object.keys(element), the ones which are linked to a React component will have a key starting with __react, such as __reactInternalInstance$fsm4q4hcqls.

element[key] will point to the React component JS object.

Playing around with this, I found the following way to get a reference to the volume controller:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const findVolumeController = () => {
const element = document.querySelector(
'div.PlayerControls--button-control-row'
)
for (const key in element) {
if (key.startsWith('__react')) {
const e = element[key]
if (e._renderedChildren && e._renderedChildren['.$volume']) {
return e._renderedChildren['.$volume']._instance
}
}
}
return null
}

const VolumeController = findVolumeController()

Now that we’ve got a reference, let’s have a look at VolumeController.setVolume:

1
2
3
4
5
6
7
8
9
10
11
...
setVolume: function(t) {
var e = this.props.player;
0 === t ? (e.setMuted(!0),
e.setVolume(this.oldVolume ? this.oldVolume : 0)) : (e.setMuted(!1),
e.setVolume(t)),
this.setState({
localVolume: t
})
},
...

We can see that the t parameter is the input from the slider, which is a float from range 0 to 1, describing the position of the slider. localVolume looks to be part of the volume controller’s state, where it tracks its own volume independently from the player’s state.

Normally, I’d recommend setting breakpoints in the code and inspecting through Chrome Dev Tools, but because of the size of this file, Chrome will often struggle and crash while doing this. Instead, let’s just rewrite the function and add a simple console.log so we can track what it’s doing.

What we want to do here is is leave the localVolume alone with the original value, making the UI think nothing has changed. We do want to modify the value which is being sent to the player though, so let’s rewrite it into something which brings us closer to our goal:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
VolumeController.setVolume = function (vol) {
const adjustedVol = a * Math.exp(b * vol)
const player = this.props.player
if (vol < 0.001) {
player.setMuted(true)
player.setVolume(this.oldVolume ? this.oldVolume : 0)
} else {
player.setMuted(false)
player.setVolume(adjustedVol > 1 ? 1 : adjustedVol)
this.setState({
localVolume: vol
})
}
console.log(adjustedVol, vol)
}

I’ve attached the console.log there for now as well, so we can track our modified volume next to the original one.

You’re also probably wondering why I’ve changed if (t === 0) to if (vol < 0.001), since this is the original value being compared and none of the logic should change. It’s just a personal preference, I don’t think === should ever be used on floating point values (there’s a joke about JS not having integers in here somewhere). I’m sure Netflix developers are doing their due diligence and ensuring the value is exactly 0 at the point where it needs to mute and reaches this function.

Let’s overwrite this function with the above code and see what happens afterwards.

After trying this out, it kind of works, and we can see we’re modifying the values correctly based on the console.log output, but the slider keeps jumping up and down whilst using it. It’s clearly not usable yet this way. Looks like the slider’s position while being rendered is also dependant on querying what the player thinks about the volume.

Let’s have another look into the source code, and see if we can find the function which is responsible for this.

Going on intuition again, searching for getVolume: leads us to two function definitions, one of them being part of cadmiumPlayerEngine.jsx. The other reference is in html5PlayerEngine.jsx, which I would imagine is the fallback player for compatibility issues, although I’m not sure at this moment.

Looking at the source code of cadmiumPlayerEngine.getVolume, it’s quite a simple function:

1
2
3
4
5
...
getVolume: function() {
return this.player ? this.player.getVolume() : void 0
},
...

If you’re confused about the void 0, in JS, void is an operator, which always returns undefined, regardless of the argument supplied. Typing void 0 is the same as typing undefined, and I suspect the minifier figured that the former has three less characters, so used that instead. You can often see similar minifier patterns, like false and true being typed as !1 and !0, respectively.

This queries the player and returns its volume. With a codebase of this size, it’s sometimes more efficient to just trial and error, as opposed to checking every single part of the code where this function is referenced and making sure this is the one we want to change.

First, in order to overwrite this, we need to gain a reference to the component which has this function. Using React Dev Tools again, we can easily find the DOM node and write the following function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const findVideoContainer = () => {
const element = document.querySelector('div.VideoContainer')
for (const key in element) {
if (key.startsWith('__react')) {
const e = element[key]
if (e._currentElement && e._currentElement._owner) {
return e._currentElement._owner._instance
}
}
}
return null
}

const VideoContainer = findVideoContainer()

Now, we have to think about what VideoContainer.getVolume does before we modify it. It queries the player’s internal state.volume, and returns the value. If you remember though, we patched the volume slider to lie to the player about its own volume, so the returned value will not be the one we want from the context of any UI element. We have to do the opposite of what we did earlier, and derive the volume value which is appropriate for the UI taking the inverse of the original function.

Since we all know web developers are bad at math, let’s query our Discord bot for the inverse of f(x) = a * e^(b * x):

Now we know, the inverse function we want is f(x) = log(a / x) * b. Let’s rewrite getVolume to return this value instead:

1
2
3
4
5
6
7
8
9
VideoContainer.getVolume = function () {
if (!this.player) {
return undefined
}
const currentVolume = this.player.getVolume()
// Math.log is base e, Math.log10 is base 10
const fakeVol = Math.log(currentVolume / a) / b
return fakeVol > 1 ? 1 : fakeVol
}

After trying this out, it looks to be working flawlessly.

One more thing we need to do – Netflix remembers the volume’s state after closing the page and resuming watching at a later time. We need to set the volume slider to the correct position before patching VideoContainer.getVolume. We’ll insert the following code after we patch VolumeController.setVolume, but before VideoContainer.getVolume is patched:

1
VolumeController.setVolume(VideoContainer.getVolume())

Finally, it seems like we’ve got a useable solution.

Chrome Extension

There would be no point in doing this, if we had to open the console every time and paste this code in, so let’s modify this into a Chrome extension.

I’ve never made one before, but it’s fairly simple after following the guide on Google’s developer pages. We’ll use something called a content script, which can be declaratively injected into any page based on a regex match. We’ll write the manifest.json as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"name": "Netflix Volume Slider Fix",
"version": "1.0",
"author": "Boris Popik",
"description": "Fixes the Netflix Volume slider, making the response logarithmic rather than linear.",
"manifest_version": 2,
"content_scripts": [{
"matches": ["*://*.netflix.com/*"],
"js": ["inject.js"]
}],
"permissions": [
"activeTab"
],
"web_accessible_resources": [
"main.js"
]
}

If you notice, we’re not running the actual script directly through the extension, we’re running another script which injects the script into the DOM and runs it in the same context as the page.

The reason for this, is that although Chrome content scripts have access to the page’s DOM, they run in a separate JS context, which is confusing. If we don’t inject the script into the page as an additional element, we can still find the DOM elements with document.querySelector, but cannot access their keys in order to find the React parents. We have to inject the script by creating a new <script> element on the page and run the code inside of it, in order to ensure it’s run in the same context.

Here’s the code of inject.js which ensures main.js is appended to the page’s DOM and run in the same context:

1
2
3
4
5
6
const script = document.createElement('script')
script.src = chrome.extension.getURL('main.js')
script.onload = function () {
this.remove()
}
;(document.head || document.documentElement).appendChild(script)

Another problem I faced is the components are not immediately available. They get dynamically created once the video has loaded, and we need to somehow wait for them to exist before we get a handle to them.

One solution I’ve seen in other extensions while having a look around, is to make a recurring timer with setInterval for example every 100ms, and wait until they’re found. I don’t like this one too much as it feels hacky.

The more ideal option which I’ve found, is to use something called a MutationObserver. This allows us to watch for changes in the DOM and react to them, which is what we want. We’ll observe document.body and its children deeply, as one of these events will be the creation of the volume controller and video container components.

The final code for the extension’s main.js is as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
;(() => {
const findVolumeController = () => {
const element = document.querySelector(
'div.PlayerControls--button-control-row'
)
for (const key in element) {
if (key.startsWith('__react')) {
const e = element[key]
if (e._renderedChildren && e._renderedChildren['.$volume']) {
return e._renderedChildren['.$volume']._instance
}
}
}
return null
}

const findVideoContainer = () => {
const element = document.querySelector('div.VideoContainer')
for (const key in element) {
if (key.startsWith('__react')) {
const e = element[key]
if (e._currentElement && e._currentElement._owner) {
return e._currentElement._owner._instance
}
}
}
return null
}

const DOMObserver = new MutationObserver(mutations => {
window.location.href.indexOf('/watch/') !== -1 &&
mutations.forEach(mutation => {
if (mutation.addedNodes.length) {
const VideoContainer = findVideoContainer()
const VolumeController = findVolumeController()
if (VideoContainer && VolumeController) {
patchVolume(VideoContainer, VolumeController)
}
}
})
})
const observerConfig = {
attributes: false,
childList: true,
subtree: true
}

DOMObserver.observe(document.body, observerConfig)

const patchVolume = (VideoContainer, VolumeController) => {
if (VideoContainer.__patched === true) {
return
}
/**
* volume = a * e^(b * sliderPos); volume, sliderPos ∈ <0, 1>
* See https://www.dr-lex.be/info-stuff/volumecontrols.html for more information
*/
const a = 3.1623e-3
const b = 5.757

VolumeController.setVolume = function (vol) {
const adjustedVol = a * Math.exp(b * vol)
const player = this.props.player

if (vol < 0.001) {
player.setMuted(true)
player.setVolume(this.oldVolume ? this.oldVolume : 0)
} else {
player.setMuted(false)
player.setVolume(adjustedVol > 1 ? 1 : adjustedVol)
this.setState({
localVolume: vol
})
}
}

// On initial load, before patching the VideoContainer.getVolume function, update the slider first
VolumeController.setVolume(VideoContainer.getVolume())

VideoContainer.getVolume = function () {
if (!this.player) {
return 0
}
const currentVolume = this.player.getVolume()
const fakeVol = Math.log(currentVolume / a) / b
return fakeVol > 1 ? 1 : fakeVol
}

Object.defineProperty(VideoContainer, '__patched', {
value: true
})
}
})()

GitHub
Chrome Web Store

Other offenders

Too many to list. Pretty much every modern web application which supplies media with sound. One that affects me particularly is Discord, with the popularity of its music bots which can enable multiple people to listen to the same playlist at once in the voice channels. You will likely rage internally while trying to adjust the volume between 1% and 3%, trying to hear your friends with the background music playing at the same time. Since it’s made with Electron, there shouldn’t be too many issues making a similar fix.