Better-Native-Video

Add keyboard support to native HTML5 video player.

// ==UserScript==
// @name         Better-Native-Video
// @namespace    https://tribbe.dev
// @version      1.0.1
// @description  Add keyboard support to native HTML5 video player.
// @author       Tribbe
// @include http://*/*
// @include https://*/*
// ==/UserScript==

"use strict";

const videoAttribute = "betterHtml5VideoType",
      timeoutAttribute = "betterHtml5VideoClickTimeout";

let toggleChecked, toggleEnabled, observer, dirVideo, settings = {
	firstClick:      "focus",
	dblFullScreen:   true,
	clickDelay:      0.3,
	skipNormal:      5,
	skipShift:       10,
	skipCtrl:        1,
	allowWOControls: false,
};

const shortcutFuncs = {
	toggleCaptions: function(v){
		const validTracks = [];
		for(let i = 0; i < v.textTracks.length; ++i){
			const tt = v.textTracks[i];
			if(tt.mode === "showing"){
				tt.mode = "disabled";
				if(v.textTracks.addEventListener){
					// If text track event listeners are supported
					// (they are on the most recent Chrome), add
					// a marker to remember the old track. Use a
					// listener to delete it if a different track
					// is selected.
					v.cbhtml5vsLastCaptionTrack = tt.label;
					function cleanup(e){
						for(let i = 0; i < v.textTracks.length; ++i){
							const ott = v.textTracks[i];
							if(ott.mode === "showing"){
								delete v.cbhtml5vsLastCaptionTrack;
								v.textTracks.removeEventListener("change", cleanup);
								return;
							}
						}
					}
					v.textTracks.addEventListener("change", cleanup);
				}
				return;
			}else if(tt.mode !== "hidden"){
				validTracks.push(tt);
			}
		}
		// If we got here, none of the tracks were selected.
		if(validTracks.length === 0){
			return true; // Do not prevent default if no UI activated
		}
		// Find the best one and select it.
		validTracks.sort(function(a, b){

			if(v.cbhtml5vsLastCaptionTrack){
				const lastLabel = v.cbhtml5vsLastCaptionTrack;

				if(a.label === lastLabel && b.label !== lastLabel){
					return -1;
				}else if(b.label === lastLabel && a.label !== lastLabel){
					return 1;
				}
			}

			const aLang = a.language.toLowerCase(),
			      bLang = b.language.toLowerCase(),
			      navLang = navigator.language.toLowerCase();

			if(aLang === navLang && bLang !== navLang){
				return -1;
			}else if(bLang === navLang && aLang !== navLang){
				return 1;
			}

			const aPre = aLang.split("-")[0],
			      bPre = bLang.split("-")[0],
			      navPre = navLang.split("-")[0];

			if(aPre === navPre && bPre !== navPre){
				return -1;
			}else if(bPre === navPre && aPre !== navPre){
				return 1;
			}

			return 0;
		})[0].mode = "showing";
	},

	togglePlay: function(v){
		if (v.paused) {
			v.play();
        } else {
			v.pause();
        }
	},

	toStart: function(v){
		v.currentTime = 0;
	},

	toEnd: function(v){
		v.currentTime = v.duration;
	},

	skipLeft: function(v,key,shift,ctrl){
		if (shift) {
			v.currentTime -= settings.skipShift;
		} else if(ctrl) {
			v.currentTime -= settings.skipCtrl;
		} else {
			v.currentTime -= settings.skipNormal;
        }
	},

	skipRight: function(v,key,shift,ctrl){
		if (shift) {
			v.currentTime += settings.skipShift;
		} else if (ctrl) {
			v.currentTime += settings.skipCtrl;
		} else {
			v.currentTime += settings.skipNormal;
        }
	},

	increaseVol: function(v){
		if (v.volume <= 0.9) v.volume += 0.1;
		else v.volume = 1;
	},

	decreaseVol: function(v){
		if (v.volume >= 0.1) v.volume -= 0.1;
		else v.volume = 0;
	},

	toggleMute: function(v){
		v.muted = !v.muted;
	},

	toggleFS: function(v){
		if (document.webkitFullscreenElement) {
			document.webkitExitFullscreen();
        } else {
			v.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT);
        }
	},

	reloadVideo: function(v){
		const currTime = v.currentTime;
		v.load();
		v.currentTime = currTime;
	},

	slowOrPrevFrame: function(v,key,shift){
		if (shift) { // Less-Than
			v.playbackRate -= 0.25;
        } else { // Comma
			v.currentTime -= 1/60;
        }
	},

	fastOrNextFrame: function(v,key,shift){
		if (shift) { // Greater-Than
			v.playbackRate += 0.25;
        } else { // Period
			v.currentTime += 1/60;
        }
	},

	normalSpeed: function(v,key,shift){
		if(shift) { // ?
			v.playbackRate = v.defaultPlaybackRate;
        }
	},

	toPercentage: function(v,key){
		v.currentTime = v.duration * (key - 48) / 10.0;
	},
};

const keyFuncs = {
    32 : shortcutFuncs.togglePlay,//       Space
	75 : shortcutFuncs.togglePlay, //      K
	35 : shortcutFuncs.toEnd,//            End
	48 : shortcutFuncs.toStart,//          0
	36 : shortcutFuncs.toStart,//          Home
	37 : shortcutFuncs.skipLeft,//         Left arrow
	74 : shortcutFuncs.skipLeft,//         J
	39 : shortcutFuncs.skipRight,//        Right arrow
	76 : shortcutFuncs.skipRight,//        L
	38 : shortcutFuncs.increaseVol,//      Up arrow
	40 : shortcutFuncs.decreaseVol,//      Down arrow
	77 : shortcutFuncs.toggleMute,//       M
	70 : shortcutFuncs.toggleFS,//         F
	67 : shortcutFuncs.toggleCaptions,//   C
	82 : shortcutFuncs.reloadVideo,//      R
	188: shortcutFuncs.slowOrPrevFrame,//  Comma or Less-Than
	190: shortcutFuncs.fastOrNextFrame,//  Period or Greater-Than
	191: shortcutFuncs.normalSpeed,//      Forward slash or ?
	49 : shortcutFuncs.toPercentage,//     1
	50 : shortcutFuncs.toPercentage,//     2
	51 : shortcutFuncs.toPercentage,//     3
	52 : shortcutFuncs.toPercentage,//     4
	53 : shortcutFuncs.toPercentage,//     5
	54 : shortcutFuncs.toPercentage,//     6
	55 : shortcutFuncs.toPercentage,//     7
	56 : shortcutFuncs.toPercentage,//     8
	57 : shortcutFuncs.toPercentage,//     9
};

function registerDirectVideo(v, force){
	ignoreAllIndirectVideos();
	if(dirVideo){
		ignoreDirectVideo();
	}
	if(force !== undefined ? force : v.hasAttribute("controls")){
		dirVideo = v;
		v.dataset[videoAttribute] = "direct";
	}else{
		v.dataset[videoAttribute] = "";
	}
}

function ignoreDirectVideo(reregister){
	if(reregister && document.body.contains(dirVideo)){
		registerVideo(dirVideo);
		dirVideo.focus();
	}else{
		dirVideo.dataset[videoAttribute] = "";
	}
	dirVideo = undefined;
}

function registerVideo(v, force){
	v.dataset[videoAttribute] =
		(force !== undefined ? force : v.hasAttribute("controls")) ?
		"normal" : "";
}

function ignoreVideo(v){
	v.dataset[videoAttribute] = "";
}

function registerAllNewVideos(vs){
	for(let i = vs.length - 1; i >= 0; --i){
		if(vs[i].dataset[videoAttribute] === undefined){
			registerVideo(vs[i]);
		}
	}
}

function ignoreAllIndirectVideos(){
	const rv = document.getElementsByTagName("video");
	for(let i = rv.length - 1; i >= 0; --i){
		if(rv[i] !== dirVideo) ignoreVideo(rv[i]);
	}
}

function isValidTarget(el){
	return (
		(dirVideo && (el === dirVideo
		           || el === document.body
		           || el === document.documentElement))
		|| (el.dataset && el.dataset[videoAttribute])
	);
}

function handleClick(e){
	if(!isValidTarget(e.target)){
		return true; // Do not prevent default
	}
	const v = dirVideo || e.target;
	if(settings.firstClick === "play" || dirVideo || document.activeElement === v){
		if(v.dataset[timeoutAttribute]){
			clearTimeout(v.dataset[timeoutAttribute]|0);
			delete v.dataset[timeoutAttribute];
		}
		if(settings.dblFullScreen && settings.clickDelay > 0){
			v.dataset[timeoutAttribute] = setTimeout(function(){
				shortcutFuncs.togglePlay(v);
				delete v.dataset[timeoutAttribute];
			}, settings.clickDelay * 1000);
		}else{
			shortcutFuncs.togglePlay(v);
		}
	}
	v.focus();
	e.preventDefault();
	e.stopPropagation();
	return false
}

function handleDblClick(e){
	if(!(settings.dblFullScreen && isValidTarget(e.target))){
		return true; // Do not prevent default
	}
	const v = dirVideo || e.target;
	if(v.dataset[timeoutAttribute]){
		clearTimeout(v.dataset[timeoutAttribute]|0);
		delete v.dataset[timeoutAttribute];
	}
	shortcutFuncs.toggleFS(v);
	e.preventDefault();
	e.stopPropagation();
	return false
}

function handleKeyDown(e){
	if(!isValidTarget(e.target) || e.altKey || e.metaKey){
		return true; // Do not activate
	}
	const func = keyFuncs[e.keyCode];
	if(func){
		if((func.length < 3 && e.shiftKey) ||
		   (func.length < 4 && e.ctrlKey)){
			return true; // Do not activate
		}
		func(dirVideo || e.target, e.keyCode, e.shiftKey, e.ctrlKey);
		e.preventDefault();
		e.stopPropagation();
		return false;
	}
	return true; // Do not prevent default if no UI activated
}

function handleKeyOther(e){
	if(!isValidTarget(e.target) || e.altKey || e.metaKey){
		return true; // Do not prevent default
	}
	const func = keyFuncs[e.keyCode];
	if(func){
		if((func.length < 3 && e.shiftKey) ||
		   (func.length < 4 && e.ctrlKey)){
			return true; // Do not prevent default
		}
		e.preventDefault();
		e.stopPropagation();
		return false;
	}
	return true; // Do not prevent default if no UI activated
}

function handleFullscreen(){
	if(document.webkitFullscreenElement
	&& document.webkitFullscreenElement.dataset[videoAttribute]){
		document.webkitFullscreenElement.focus();
	}
}

function handleMutationRecords(mrs){
	for(let i = mrs.length - 1; i >= 0; --i){
		if(mrs[i].attributeName === "controls"){
			const t = mrs[i].target;
			if(!t.hasAttribute("controls")){
				switch(t.dataset[videoAttribute]){
				case "direct":
					ignoreDirectVideo(false);
					break;
				case "normal":
					ignoreVideo(t);
					break;
				}
			}else if(t.tagName.toLowerCase() === "video"){
				if(document.body.children.length === 1
				&& document.body.firstElementChild === t){
					registerDirectVideo(t);
				}else{
					registerVideo(t);
					t.focus();
				}
			}
		}else if(mrs[i].type === "childList"){
			if(dirVideo && (document.body.children.length !== 1
			|| document.body.firstElementChild !== dirVideo)){
				ignoreDirectVideo(true);
			}
			if(mrs[i].removedNodes){
				for(let j = mrs[i].removedNodes.length - 1; j >= 0; --j){
					if(mrs[i].removedNodes[j] === dirVideo){
						ignoreDirectVideo();
					}
					// No need to ignore other videos currently,
					// as it's just setting an attribute.
				}
			}
			if(document.body.children.length === 1
			&& document.body.firstElementChild !== dirVideo
			&& document.body.firstElementChild.tagName.toLowerCase() === "video"
			&& document.body.firstElementChild.dataset[videoAttribute] !== ""){
				registerDirectVideo(document.body.firstElementChild);
			}else if(mrs[i].addedNodes){
				for(let j = mrs[i].addedNodes.length - 1; j >= 0; --j){
					const an = mrs[i].addedNodes[j];
					if(an.tagName && an.tagName.toLowerCase() === "video"){
						if(an.dataset[videoAttribute] === undefined){
							registerVideo(an);
						}
					}else if(an.getElementsByTagName){
						registerAllNewVideos(an.getElementsByTagName("video"));
					}
				}
			}
		}
	}
}

function enableExtension(){
	// useCapture: Handler fired while event is bubbling down instead of up
	document.addEventListener("webkitfullscreenchange", handleFullscreen, true);

	document.addEventListener("click", handleClick, true);
	document.addEventListener("dblclick", handleDblClick, true);
	document.addEventListener("keydown", handleKeyDown, true);
	document.addEventListener("keypress", handleKeyOther, true);
	document.addEventListener("keyup", handleKeyOther, true);

	observer = observer || new MutationObserver(handleMutationRecords);
	observer.observe(document.body, {
		childList: true,
		attributes: true,
		attributeFilter: ["controls"],
		subtree: true
	});

	if(document.body.children.length === 1
	&& document.body.firstElementChild.tagName.toLowerCase() === "video"
	&& document.body.firstElementChild.dataset[videoAttribute] !== ""){
		registerDirectVideo(document.body.firstElementChild);
	}else{
		registerAllNewVideos(document.getElementsByTagName("video"));
	}
}

enableExtension();