Skip to content

Instantly share code, notes, and snippets.

@olmokramer
Last active July 3, 2018 12:12
Show Gist options
  • Save olmokramer/96a2b4daac2d1e6c2120352f0baaf653 to your computer and use it in GitHub Desktop.
Save olmokramer/96a2b4daac2d1e6c2120352f0baaf653 to your computer and use it in GitHub Desktop.
Video controls for qutebrowser
// ==UserScript==
// @name VideoControls
// @namespace https://github.com/olmokramer
// @description Add mouse and keyboard controls to video elements
// @include *
// @version 8
// @author Olmo Kramer
// ==/UserScript==
// Controls:
//
// click toggle play/pause
// double click toggle fullscreen
//
// shift + scroll down/up seek forward/backward (handled by chromium)
// alt + scroll down/up change volume down/up
//
// arrow left/right seek backward/forward (handled by chromium)
// arrow down/up change volume down/up (handled by chromium)
// [ decrease playback rate
// ] increase playback rate
// m toggle mute
// Space toggle play/pause (handled by chromium)
// p toggle play/pause
// r toggle repeat
// Escape quit fullscreen
// f toggle fullscreen
//
// To get hints on videos, use the url group. The 'normal' hint target toggles
// the paused state on the video, while other targets act on the video url.
//
// :hint url [target]
(function IIFE() {
'use strict';
const controlHideTimeout = 1000;
const doubleClickTimeout = 300;
const volumeStep = .05;
const seekStep = 5;
const rateStep = .25;
let focusedVideo = null;
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function togglePlayPause(video) {
video.focus();
if (video.paused) {
video.play();
} else {
video.pause();
}
}
function toggleFullscreen(video) {
if (document.fullscreenElement || document.webkitFullscreenElement) {
if (document.exitFullscreen) {
document.exitFullscreen();
} else {
document.webkitExitFullscreen();
}
} else {
if (video.requestFullScreen) {
video.requestFullScreen();
} else {
video.webkitRequestFullScreen();
}
}
}
function seekVideo(video, delta) {
video.currentTime = clamp(video.currentTime + delta, 0, video.duration);
}
function changeVolume(video, delta) {
video.volume = clamp(video.volume + delta, 0, 1);
}
function toggleMute(video) {
video.muted = !video.muted;
}
function toggleLoop(video) {
video.loop = !video.loop
}
function changePlaybackRate(video, delta) {
video.playbackRate = clamp(video.playbackRate + delta, 0, 5);
}
function addClickControls(video) {
let timeout = null;
video.addEventListener('click', function onClick(event) {
event.preventDefault();
if (timeout == null) {
timeout = setTimeout(function onTimeout() {
timeout = null;
togglePlayPause(video);
}, doubleClickTimeout);
} else {
clearTimeout(timeout);
timeout = null;
toggleFullscreen(video);
}
}, true);
}
function addWheelControls(video) {
video.addEventListener('wheel', function onScroll(event) {
if (event.altKey) {
event.preventDefault();
if (event.deltaX < 0) {
changeVolume(video, volumeStep);
} else if (event.deltaX > 0) {
changeVolume(video, -volumeStep);
}
}
}, true);
}
function addKeyControls(video) {
document.addEventListener('keyup', function onKeyUp(event) {
if (focusedVideo != video) {
return;
}
// The cases that check the document.activeElement are
// already handled by chrome *if* the video is the
// activeElement, so we don't need to handle them. It
// is a bit unclear when chromium considers the video
// to be focused, and it appears to unfocus videos
// whenever the controls are hidden.
switch (event.key) {
case 'ArrowLeft':
if (document.activeElement != video) {
return;
}
seekVideo(video, -seekStep);
break;
case 'ArrowRight':
if (document.activeElement == video) {
return;
}
seekVideo(video, seekStep);
break;
case 'ArrowUp':
if (document.activeElement == video) {
return;
}
changeVolume(video, volumeStep);
break;
case 'ArrowDown':
if (document.activeElement == video) {
return;
}
changeVolume(video, -volumeStep);
break;
case '[':
changePlaybackRate(video, -rateStep);
break;
case ']':
changePlaybackRate(video, rateStep);
break;
case 'm':
toggleMute(video);
break;
case ' ':
if (document.activeElement == video) {
return;
}
// no break
case 'p':
togglePlayPause(video);
break;
case 'r':
toggleLoop(video);
break;
case 'Escape':
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
return;
}
// no break
case 'f':
toggleFullscreen(video);
break;
default:
return;
}
event.preventDefault();
}, true);
}
function autoHideControls(video) {
if (video.id == '') {
const r = Math.round(Math.random() * Number.MAX_SAFE_INTEGER);
video.id = `__qb_video__${r}`;
}
let style = document.createElement('style');
style.innerHTML = `
video#${video.id} {
cursor: pointer;
}
video#${video.id}:not([controls]) {
cursor: none;
}
video#${video.id}:not([controls])::-webkit-media-controls {
display: none !important;
}
`;
document.head.appendChild(style);
let timeout = null;
const origControls = video.getAttribute('controls');
video.addEventListener('mousemove', function() {
if (timeout != null) {
clearTimeout(timeout);
timeout = null;
}
video.setAttribute('controls', origControls);
timeout = setTimeout(function onTimeout() {
timeout = null;
video.removeAttribute('controls');
video.focus();
}, controlHideTimeout);
});
}
function addControls(video) {
if (video.__qb_controls__ || !video.controls) {
return;
}
video.__qb_controls__ = true;
addClickControls(video);
addWheelControls(video);
addKeyControls(video);
autoHideControls(video);
}
new MutationObserver(function onMutate(mutations) {
for (let mutation of mutations) {
for (let node of mutation.addedNodes) {
if (node instanceof HTMLVideoElement) {
addControls(node);
}
}
}
}).observe(document.body, {
childList: true,
subtree: true,
});
document.addEventListener('focus', function onFocus(event) {
focusedVideo = event.target.closest('video');
}, true);
for (let video of Array.from(document.querySelectorAll('video'))) {
addControls(video);
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment