// ==UserScript==
// @name Netflix keyboard controls
// @namespace netflix.keyboard
// @version 1.3
// @description Use similar controls as on YouTube when watching Netflix (f for full screen, k to play/pause, c for captions, j and l to go back and forward 10 seconds, a to change audio, p for picture-in-picture, and a LOT more)
// @match https://netflix.com/*
// @match https://www.netflix.com/*
// @grant none
// @author https://github.com/nicolasff
// ==/UserScript==
(function() {
/* global netflix */
'use strict';
const debug = false; // set this to true to get debug information as the script processes events.
// change these constants if you prefer to use different keys (or change the letter to uppercase if you want the shortcut to require the use of Shift)
// to disable a particular feature, set the value to null.
const PLAY_PAUSE_KEY = 'k';
const PICTURE_IN_PICTURE_KEY = 'p'; // turns picture-in-picture on or off
const SCRUB_FORWARD_KEY = 'l'; // skips ahead by `SCRUB_DELTA_SECONDS` (10s by default)
const SCRUB_BACKWARD_KEY = 'j'; // goes back `SCRUB_DELTA_SECONDS` (10s by default)
const ONE_FRAME_FORWARD_KEY = '.'; // when paused, moves ahead by one frame
const ONE_FRAME_BACKWARD_KEY = ','; // when paused, moves back by one frame
const SUBTITLES_ON_OFF_KEY = 'c'; // turns subtitles on or off. see `DEFAULT_SUBTITLES_LANGUAGE` below for a way to pick the language of your choice
const SUBTITLES_SIZE_KEY = 's'; // changes the size of subtitles (Netflix has 3 options: small/medium/large – this cycles between them)
const SUBTITLES_NEXT_LANGUAGE_KEY = 'v'; // selects the next subtitles track
const NEXT_AUDIO_TRACK_KEY = 'a'; // switches audio to the next track
const MUTE_UNMUTE_KEY = null; // Netflix sets mute/unmute to 'm'. You can use a different key here.
const VOLUME_UP_KEY = 'PageUp'; // we can't use 'ArrowUp' here since Netflix already handles this event themselves.
const VOLUME_DOWN_KEY = 'PageDown'; // we can't use 'ArrowDown' here since Netflix already handles this event themselves.
const NUMBER_KEYS_ENABLED = true; // press key 0 to jump to 0% (start of the video), 1 for 10%… up to 9 for 90%
// these constants control the behavior of the shortcut keys above
const SCRUB_DELTA_SECONDS = 10; // how much to skip forward/backward when using the forward/backward keys
const VOLUME_DELTA = 0.05; // how much to increase/decrease the volume by (range is 0.0 to 1.0 so 0.05 is 5%)
const DEFAULT_SUBTITLES_LANGUAGE = 'English'; // change this to have the subtitles key pick a different language. Example values you can use: 'en', 'fr', 'es', 'zh-Hans', 'zh-Hant'...
const FRAMES_PER_SECOND = 60; // how many frames the script considers to be in one second (this is used for the "next frame"/"previous frame" shortcut which seeks by one second over this amount)
/***************************************************************************************************************************************************************************************************/
/**
* Gets a nested property inside an object
*/
function getDeepProperty(obj, props) {
var cur = obj;
for (var key of props.split('.')) {
const isFunction = key.endsWith('()');
const attrName = isFunction ? key.substring(0, key.length-2) : key;
if (!cur[attrName]) {
return null;
}
cur = cur[attrName];
if (isFunction && typeof cur === 'function') {
cur = cur();
}
}
return cur;
}
/**
* Returns the "Player" object used by the Netflix web app to control video playback.
*/
function getPlayer() {
// uses the `netflix` object, a global variable exposed by the web app
const videoPlayer = getDeepProperty(netflix, 'appContext.state.playerApp.getAPI().videoPlayer');
if (videoPlayer && videoPlayer.getVideoPlayerBySessionId && videoPlayer.getAllPlayerSessionIds) {
return videoPlayer.getVideoPlayerBySessionId(videoPlayer.getAllPlayerSessionIds()[0]);
}
return null;
}
/**
* Returns the `<video>` tag for playing media.
*/
function getVideoTag() {
const videos = document.getElementsByTagName('video');
return (videos && videos.length === 1 ? videos[0] : null);
}
function isBoolean(b) {
return b === true || b === false;
}
/**
* Returns the subtitles track for a given language.
* Matches full name (e.g. "English") or a BCP 47 language code (e.g. "en")
*/
function findSubtitlesTrack(player, language) {
const tracks = player.getTimedTextTrackList();
var selectedTrack = null;
for (var i = 0; i < tracks.length; i++) {
if ((tracks[i].displayName === language || tracks[i].bcp47 === language) && tracks[i].trackType === 'PRIMARY') {
return tracks[i];
}
}
return null;
}
/**
* Returns the next size for subtitles
*/
function nextSubtitlesSize(currentSize) {
switch(currentSize) {
case 'SMALL': return 'MEDIUM';
case 'MEDIUM': return 'LARGE';
case 'LARGE': return 'SMALL';
default: // not found somehow
return 'MEDIUM';
}
}
var lastSelectedTextTrack = null; // caches the last non-"Off" language to have the `c` key switch between "Off" and that language.
var preferredTextTrack = null; // caches the preferred language track
function switchSubtitles(player) {
// select preferred language, once
if (preferredTextTrack === null) {
preferredTextTrack = findPreferredTextTrack(player);
debug && console.log('Found preferred text track:', preferredTextTrack);
}
// first, get current track to see if subtitles are currently visible
const currentTrack = player.getTimedTextTrack();
const disabledTrack = findSubtitlesTrack(player, 'Off');
const currentlyDisabled = (currentTrack !== null && disabledTrack !== null && currentTrack.displayName === disabledTrack.displayName);
// flip
if (currentlyDisabled) {
// do we have a last selected track? if so, switch back to it.
if (lastSelectedTextTrack && lastSelectedTextTrack.displayName !== 'Off') { // avoid switching from "Off" to "Off"
player.setTimedTextTrack(lastSelectedTextTrack);
} else if (preferredTextTrack) { // otherwise, switch to preferred language
player.setTimedTextTrack(preferredTextTrack);
} else {
console.warn("No last selected subtitles track to go back to, and couldn't find subtitles in the preferred language,", DEFAULT_SUBTITLES_LANGUAGE);
}
} else { // currently enabled, so we're switching to "Off".
player.setTimedTextTrack(disabledTrack);
}
lastSelectedTextTrack = currentTrack; // and remember what we just switched from
}
function findPreferredTextTrack(player) {
var chosenTrack = findSubtitlesTrack(player, DEFAULT_SUBTITLES_LANGUAGE);
if (!chosenTrack) {
console.warn('Could not find subtitles in ' + DEFAULT_SUBTITLES_LANGUAGE + (DEFAULT_SUBTITLES_LANGUAGE !== 'English' ? ', defaulting to English' : ''));
chosenTrack = findSubtitlesTrack(player, 'English');
if (!chosenTrack) {
DEFAULT_SUBTITLES_LANGUAGE !== 'English' && console.warn('Could not find subtitles in English either :-/');
}
}
return chosenTrack; // might be null
}
/**
* Selects the next track in the list of available audio tracks.
*/
function selectNextAudioTrack(player) {
const trackList = player.getAudioTrackList();
const currentTrack = player.getAudioTrack();
if (!trackList || !currentTrack) {
console.warn('Could not find the current audio track or the list of audio tracks');
}
for (var i = 0; i < trackList.length; i++) {
if (currentTrack.displayName === trackList[i].displayName) { // found!
const nextTrack = trackList[(i+1) % trackList.length];
debug && console.log('Switching audio track to ' + nextTrack.displayName);
player.setAudioTrack(nextTrack);
return;
}
}
}
/**
* Selects the next track in the list of available subtitles tracks.
*/
function selectNextSubtitlesTrack(player) {
const trackList = player.getTimedTextTrackList();
const currentTrack = player.getTimedTextTrack();
if (!trackList || !currentTrack) {
console.warn('Could not find the current subtitles track or the list of subtitles tracks');
}
for (var i = 0; i < trackList.length; i++) {
if (currentTrack.trackId === trackList[i].trackId) { // found!
const nextTrack = trackList[(i+1) % trackList.length];
debug && console.log('Switching subtitles track to ' + nextTrack.displayName);
player.setTimedTextTrack(nextTrack);
return;
}
}
}
/* Frame-by-frame skips & more precise scrubbing */
var lastExpectedTimeMillis = null; // tracks where we believe we seek()'d to the last time we moved frame by frame
function moveToPosition(player, timeMillis) {
player.seek(timeMillis);
lastExpectedTimeMillis = timeMillis;
}
function skipFrame(player, factor) {
const currentTime = lastExpectedTimeMillis; // use the cached variable that we set when we entered the "paused" state
const newPosition = Math.max(0, Math.min(player.getDuration(), currentTime + factor * 1000.0 / FRAMES_PER_SECOND)); // factor is +1 or -1
debug && console.log('Seek ' + (factor > 0 ? '>' : '<') + ' to:', newPosition);
moveToPosition(player, newPosition);
}
/* Play/Pause state change detection. We need this for frame skips to work, since `getCurrentTime()` might not update with a very short seek() so we keep track of the actual time in `lastExpectedTimeMillis`. */
/* Called when the video resumes playing (from being paused) */
function onPlaybackResumes(player) {
lastExpectedTimeMillis = null; // clear the current time
}
/* Called when the video is paused (from having been playing) */
function onPlaybackPauses(player) {
lastExpectedTimeMillis = player.getCurrentTime(); // when entering paused state, mark where we think we are and use this variable rather than `getCurrentTime()` to accurately keep track of the position.
}
/**
* Find the `<video>` tag and installs "onPlay" and "onPause" callbacks if needed.
* This is called repeatedly in case the `<video>` tag is replaced (e.g. the next episode starts playing)
*/
function installPlayPauseCallbacks() {
const video = getVideoTag();
const player = getPlayer();
if (!video || !player || video._nkcInstalled) { // nkc for Netflix Keyboard Controls
return;
}
video.addEventListener('play', function() { onPlaybackResumes(player); });
video.addEventListener('pause', function() { onPlaybackPauses(player); });
video._nkcInstalled = true;
debug && console.log('Play/pause callbacks installed');
}
setInterval(installPlayPauseCallbacks, 1000); // called once a second, returns immediately if already installed
addEventListener("keydown", function(e) { // we need `keydown` instead of `keypress` to catch arrow presses
if (e.ctrlKey || e.altKey) { // return early if any modifier key like Control or Alt is part of the key press
return;
}
const KEYCODE_ZERO = 48; // keycode for character '0'
const video = getVideoTag();
const player = getPlayer();
debug && console.log('Key press:', e);
if (e.key === PICTURE_IN_PICTURE_KEY) {
if (document.pictureInPictureElement) {
document.exitPictureInPicture();
} else if (video) {
video.requestPictureInPicture();
} else {
console.error('Could not find a <video> tag to start PiP');
}
} else if (!player) {
console.error('/!\\ No player object found, please update this script or report the issue if you are using the latest version');
return;
}
// from now own, we know we have a `player` instance
if (e.key === PLAY_PAUSE_KEY) {
player.getPaused() ? player.play() : player.pause();
} else if (e.key === SCRUB_FORWARD_KEY) {
moveToPosition(player, Math.min(player.getDuration(), player.getCurrentTime() + SCRUB_DELTA_SECONDS * 1000.0));
} else if (e.key === SCRUB_BACKWARD_KEY) {
moveToPosition(player, Math.max(0, player.getCurrentTime() - SCRUB_DELTA_SECONDS * 1000.0));
} else if (e.key === ONE_FRAME_FORWARD_KEY && player.getPaused()) {
skipFrame(player, +1);
} else if (e.key === ONE_FRAME_BACKWARD_KEY && player.getPaused()) {
skipFrame(player, -1);
} else if (e.key === VOLUME_UP_KEY) {
if (VOLUME_UP_KEY === 'ArrowUp') {
console.warn('Netflix already raises the volume with "arrow up", we can\'t disable their handling');
} else {
player.setVolume(Math.min(1.0, player.getVolume() + VOLUME_DELTA));
}
} else if (e.key === VOLUME_DOWN_KEY) {
if (VOLUME_UP_KEY === 'ArrowDown') {
console.warn('Netflix already lowers the volume with "arrow down", we can\'t disable their handling');
} else {
player.setVolume(Math.max(0.0, player.getVolume() - VOLUME_DELTA));
}
} else if (e.key === MUTE_UNMUTE_KEY) {
if (MUTE_UNMUTE_KEY === 'm') {
console.warn('Netflix already mutes with "m"');
} else {
const muteState = player.getMuted();
if (isBoolean(muteState)) { // make sure we got a valid state back
player.setMuted(!muteState);
}
}
} else if (e.key === NEXT_AUDIO_TRACK_KEY) {
selectNextAudioTrack(player);
} else if (e.key === SUBTITLES_NEXT_LANGUAGE_KEY) {
selectNextSubtitlesTrack(player);
} else if (NUMBER_KEYS_ENABLED && e.keyCode >= KEYCODE_ZERO && e.keyCode <= KEYCODE_ZERO + 9) {
player.seek((e.keyCode - KEYCODE_ZERO) * (player.getDuration() / 10.0));
} else if (e.key === SUBTITLES_ON_OFF_KEY) {
switchSubtitles(player); // extracted for readability
} else if (e.key === SUBTITLES_SIZE_KEY) {
const currentSettings = player.getTimedTextSettings();
if (currentSettings && currentSettings.size) {
player.setTimedTextSettings({size: nextSubtitlesSize(currentSettings.size)});
} else {
console.warn('Unable to find current subtitles size');
}
}
});
})();