// ==UserScript==
// @name Twitch Seeking
// @namespace https://greasyfork.org/users/45933
// @version 0.4.6
// @author Fizzfaldt
// @description Keyboard shortcuts to seek more easily in Twitch VODs
// @run-at document-idle
// @grant none
// @noframes
// @match *://*.twitch.tv/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=twitch.tv
// @license MIT
// ==/UserScript==
// Useful things:
//&& e.altKey
//&& e.shiftKey
//&& e.ctrlKey
var player;
var seek;
var enabled = false;
var seekPopup;
var movePopup;
// by chatgpt
function formatTime(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = (seconds % 60).toFixed(1);
return `${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}:${secs.padStart(4, "0")}`;
}
// by chatgpt
function createSeekPopup() {
seekPopup = document.createElement("div");
seekPopup.style.position = "fixed";
seekPopup.style.top = "10px";
seekPopup.style.left = "50%";
seekPopup.style.transform = "translateX(-50%)";
seekPopup.style.padding = "10px 20px";
seekPopup.style.backgroundColor = "rgba(0, 0, 0, 0.7)";
seekPopup.style.color = "white";
seekPopup.style.fontSize = "18px";
seekPopup.style.borderRadius = "5px";
seekPopup.style.zIndex = "10000";
seekPopup.style.opacity = "0";
seekPopup.style.transition = "opacity 0.25s ease-out";
document.body.appendChild(seekPopup);
}
// by chatgpt
function createMovePopup() {
movePopup = document.createElement("div");
movePopup.style.position = "fixed";
movePopup.style.top = "50px";
movePopup.style.left = "50%";
movePopup.style.transform = "translateX(-50%)";
movePopup.style.padding = "10px 20px";
movePopup.style.backgroundColor = "rgba(0, 0, 0, 0.7)";
movePopup.style.color = "white";
movePopup.style.fontSize = "18px";
movePopup.style.borderRadius = "5px";
movePopup.style.zIndex = "10000";
movePopup.style.opacity = "0";
movePopup.style.transition = "opacity 0.25s ease-out";
document.body.appendChild(movePopup);
}
var hide_popup = 250;
// by chatgpt
function showChangeSeekPopup() {
if (!seekPopup) {
createSeekPopup(); // Ensure popup exists
}
// Set the text to the current seek amount
seekPopup.textContent = `Seek: ${seek}s`;
// Show the popup with a fade-in effect
seekPopup.style.opacity = "1";
// Hide the popup after 0.5 seconds (adjustable)
setTimeout(() => {
seekPopup.style.opacity = "0";
}, hide_popup);
}
// by chatgpt
function showSeekPopup(amount, direction, targetTime) {
if (!seekPopup) {
createSeekPopup();
}
const symbol = direction === "forward" ? ">>" : "<<";
const formattedTime = formatTime(targetTime);
seekPopup.textContent = `${symbol} ${amount}s to ${formattedTime}`;
seekPopup.style.opacity = "1";
setTimeout(() => {
seekPopup.style.opacity = "0";
}, hide_popup);
}
// by chatgpt
function showMovePopup(targetTime, percentage) {
if (!movePopup) {
createMovePopup();
}
const formattedTime = formatTime(targetTime);
movePopup.textContent = `${formattedTime} (${percentage}%)`;
movePopup.style.opacity = "1";
setTimeout(() => {
movePopup.style.opacity = "0";
}, 250);
}
function ensure_player() {
'use strict';
if (!player) {
// Initialize only once
console.log("Finding video player for seeking");
player = document.querySelector('video');
if (!player) {
alert("Failed to initialize video player for seeking");
return;
}
}
if (!player) {
alert("Video player lost after initializations");
return;
}
}
function ensure_seeking() {
'use strict';
ensure_player();
if (typeof(player.currentTime) !== 'number') {
alert("Cannot find current time in player");
return;
}
}
function ensure_percent_seeking() {
'use strict';
ensure_seeking();
if (typeof(player.duration) !== 'number') {
alert("Cannot find duration in player");
return;
}
}
function increase_seek() {
'use strict';
ensure_player();
const increases = {
1 : 5,
5 : 10,
10 : 30,
30 : 60,
60 : 300,
300 : 600,
600 : 1800,
1800 : 3600,
3600 : 3600,
};
if (seek in increases) {
seek = increases[seek];
} else {
alert("Cannot find " + seek + " in increase dictionary");
seek = 60;
}
console.log("Seek amount is now " + seek);
showChangeSeekPopup(); // Display the seek amount visually
}
function decrease_seek() {
'use strict';
ensure_player();
const decreases = {
1 : 1,
5 : 1,
10 : 5,
30 : 10,
60 : 30,
300 : 60,
600 : 300,
1800 : 600,
3600 : 1800,
};
if (seek in decreases) {
seek = decreases[seek];
} else {
alert("Cannot find " + seek + " in decrease dictionary");
seek = 60;
}
console.log("Seek amount is now " + seek);
showChangeSeekPopup(); // Display the seek amount visually
}
function seek_forwards() {
'use strict';
ensure_seeking();
const before = player.currentTime;
const targetTime = before + seek;
console.log(`Seeking ${seek} seconds forward from ${formatTime(before)} to ${formatTime(targetTime)}`);
player.currentTime = targetTime;
showSeekPopup(seek, "forward", targetTime);
}
function seek_backwards() {
'use strict';
ensure_seeking();
const before = player.currentTime;
const targetTime = before - seek;
console.log(`Seeking ${seek} seconds backward from ${formatTime(before)} to ${formatTime(targetTime)}`);
player.currentTime = targetTime;
showSeekPopup(seek, "backward", targetTime);
}
function seek_percent(n) {
'use strict';
ensure_percent_seeking();
const targetTime = player.duration * n * 0.1;
const percentage = n * 10;
player.currentTime = targetTime;
showMovePopup(targetTime, percentage);
}
function seek_callback(e) {
'use strict';
if (!enabled) {
return;
}
if (!e.ctrlKey) {
// Not holding control; quit
return;
}
if (e.shiftKey) {
switch (e.code) {
case "ArrowUp":
increase_seek();
break;
case "ArrowDown":
decrease_seek();
break;
case "Digit0":
case "Digit1":
case "Digit2":
case "Digit3":
case "Digit4":
case "Digit5":
case "Digit6":
case "Digit7":
case "Digit8":
case "Digit9":
seek_percent(Number(e.code[5]));
break;
default:
break;
}
} else {
switch (e.code) {
case "ArrowRight":
seek_forwards();
break;
case "ArrowLeft":
seek_backwards();
break;
default:
break;
}
}
}
function enable_twitch_seeking() {
player = null;
// default to 60s
seek = 60;
enabled = true;
// This is too early to find the video player, so delay initialization till key presses.
// FIXME: Find out how to delay this till it's available
document.addEventListener('keyup', seek_callback, false);
console.log("enabling twitch seeking for " + window.location);
}
function disable_twitch_seeking() {
// Clear player
player = null;
// default to 60s
seek = 60;
enabled = false;
document.removeEventListener('keyup', seek_callback, false);
console.log("disabling twitch seeking for " + window.location);
}
// @match *://*.twitch.tv/video/*
// @match *://*.twitch.tv/videos/*
// @match *://*.twitch.tv/*/video/*
// @match *://*.twitch.tv/*/videos/*
// @match *://*.twitch.tv/clip/*
// @match *://*.twitch.tv/*/clip/*
// @match *://clips.twitch.tv/*
function handleNavigate() {
const pathname = window.location.pathname;
const hostname = window.location.hostname;
// Define regular expressions for each pattern
const matchPatterns = [
// Matches "/video[s]/*"
/^\/videos?\//,
// Matches "/<user>/video[s]/*"
/^\/[^/]+\/videos?\//,
// Matches "/clip[s]/*"
/^\/clips?\//,
// Matches "/<user>/clip[s]/*"
/^\/[^/]+\/clips?\//,
];
// Check if we're on the main twitch.tv domain and if the pathname matches any of the patterns
const isTwitchMatch = matchPatterns.some(pattern => pattern.test(pathname));
// Check if we're on the clips.twitch.tv subdomain (we look at the full URL here)
const isClipMatch = hostname === 'clips.twitch.tv';
// Apply the logic
if (isTwitchMatch || isClipMatch) {
enable_twitch_seeking();
} else {
disable_twitch_seeking();
}
}
(function() {
'use strict';
// Initial load
handleNavigate();
//window.addEventListener('popstate', handleNavigate);
//window.addEventListener('pushState', handleNavigate);
/* Prioritize navigation api if it exists. As of 2024-12-13 this does not work in firefox
* See
* https://developer.mozilla.org/en-US/docs/Web/API/Navigation/navigatesuccess_event#browser_compatibility
* and
* https://bugzilla.mozilla.org/show_bug.cgi?id=1777171
*/
if (self.navigation) {
self.navigation.addEventListener('navigatesuccess', handleNavigate);
} else {
let u = location.href;
new MutationObserver(() => u !== (u = location.href) && handleNavigate())
.observe(document, {subtree: true, childList: true});
}
})();