Skip to content

Instantly share code, notes, and snippets.

@vogler
Last active November 30, 2023 00:47
Show Gist options
  • Save vogler/b7c66088cd22f899496f353d5295b7ad to your computer and use it in GitHub Desktop.
Save vogler/b7c66088cd22f899496f353d5295b7ad to your computer and use it in GitHub Desktop.
Tampermonkey: video: scroll=seek
// ==UserScript==
// @name Video: scroll=seek
// @namespace https://gist.github.com/vogler
// @downloadURL https://gist.github.com/vogler/b7c66088cd22f899496f353d5295b7ad/raw/video-scroll-seek.tamper.js
// @version 0.26
// @description Scrolling a video seeks back/forward
// @author Ralf Vogler
// @match *://*/*
// @grant none
// ==/UserScript==
const parseDuration = str => str.split(':').toReversed().reduce((a,x,i) => a + parseInt(x) * 60**i, 0); // to seconds
(function() {
'use strict';
// seek back/forward `step` seconds
const step = 3;
// If an element over the video snags scroll events (e.g. a div for video controls), we have to listen on it instead.
// If there is no manual rule for which element that is, we try to be smart and find something that works better than `v => v`.
// Unused: here we get the topmost element at the top-left position of the video element -> did not work for fm4.orf.at
const eventElementFromPosition = v => {
const b = v.getBoundingClientRect();
// const es = document.elementsFromPoint(b.x+b.width/2, b.y+b.height/2);
const es = document.elementsFromPoint(b.x+10, b.y+10);
console.log(es);
return es[0];
};
// Used alternative: as long as the parent element still has the same dimensions, it will likely be fine to use for events.
const eventElementParent = e => {
const p = e.parentElement;
const be = e.getBoundingClientRect();
const bp = p.getBoundingClientRect();
// console.log(be, bp);
return (bp.width == be.width && bp.height == be.height) ? eventElementParent(p) : e;
};
// manual override
const eventElement = [
// ['coursera.org', v => v.parentElement],
['ted.com', v => v.closest('#ted-player')],
['netflix.com', v => document.querySelector('.watch-video--player-view')], // setting video.currentTime results in error "Sorry, we're having trouble with your request."
['waipu.tv', v => v.closest('.player-wrapper')],
['joyn.de', v => document.querySelector('main')],
['video.sat1.de', v => document.querySelector('.video-container')],
['account.ring.com/account/activity-history', v => v.closest('div')],
['store.epicgames.com', v => v.parentElement],
// ['fm4.orf.at', v => v.parentElement],
].reduce((af, [u,f]) => location.href.includes(u) ? f : af, eventElementParent); // v => v
const f = el => {
// console.log('f on', el);
if (!el.getElementsByTagName) return; // Angular adds some weird #comment and #text elements which don't have this function...
const vs = el.tagName == 'VIDEO' ? [el] : [...el.getElementsByTagName('video')]; // find videos in element
if (vs.length < 1) return;
// console.log('video-scroll-seek: found', vs.length, 'video(s)', vs, 'in', el);
vs.forEach(v => {
const e = eventElement(v); // element that gets the event
// console.log(v, e);
if (e.added) return; // avoid adding multiple listeners to the same video! YouTube added 7 b/c of mutations...
e.added = true;
// ring.com uses WebRTC where the video element does not have a .src but .srcObject with a MediaStream which does not allow to change .currentTime or .playbackRate (.duration is Inf).
// Disabling WebRTC (media.peerconnection.enabled in Firefox) disables ring.com video playback.
// However, their JS adds some streamedPlaybackControls with .play(), .pause(), .seek() etc.
let controls;
if (location.href.includes('ring.com')) {
const el = document.querySelector('main > div > div'); // react element with the JS object we want, e.g. div.styled__VideoContainer-sc-ff7bebee-15
const reactPropsField = Object.keys(el).find(x => x.includes('reactProps')); // e.g. __reactProps$sjvdkfurj7s
controls = el[reactPropsField].children.props.streamedPlaybackControls;
console.log('WebRTC controls:', controls);
controls.time = 0;
v.muted = false; // also unmute video
}
e.addEventListener('wheel', ev => { // add listener for each video
// console.log('wheel', ev.wheelDeltaY > 0 ? 'up' : 'down');
const scale = ev.altKey ? 10 : 1;
// on the trackpad we can scroll vertically or horizontally -> use the direction with the biggest delta
const scroll = Math.abs(ev.wheelDeltaY) > Math.abs(ev.wheelDeltaX) ? ev.wheelDeltaY : ev.wheelDeltaX;
const jump = step * scale * -1 * scroll/120;
// * -1 means back = left = up; forward = right = down
// had Math.sign(scroll) instead of scroll/120 before, but then scroll acceleration is ignored - min. scroll with wheel is 120, but may go up to 600 when scrolling faster
// on trackpad values are generally smaller, but fired more often, so scrolling speed is similar
if (location.href.includes('netflix.com')) { // does not allow setting video.currentTime, so we have to click their backward/forward buttons...
document.querySelector(scroll > 0 ? 'button[aria-label="Seek Forward"]' : 'button[aria-label="Seek Back"]').click();
} else if (location.href.includes('ring.com')) {
const duration = parseDuration(document.querySelector('[role="progressbar"] + div').innerText); // since v.duration is Inf
// seek to `v.currentTime + ..` did not work since it did not update fast enough
controls.time = Math.abs(controls.time + jump) % duration;
controls.seek(controls.time, true); // true to not pause
} else {
v.currentTime += jump;
}
ev.preventDefault();
});
console.log('video-scroll-seek: added handler for video', v, 'to element', e); // v.baseURI
});
};
f(document.body); // handle all videos
// setTimeout(() => f(document.body), 3000); // if our wheel event listener gets removed, we add it again after 3s
// we also want to react to dynamically added videos, i.e., call f on any nodes added to document.body
(new MutationObserver((ms, ob) => ms.forEach(m => m.addedNodes.forEach(f)))).observe(document.body, { subtree: true , childList: true });
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment