Skip to content

Instantly share code, notes, and snippets.

@jimhester
Last active December 19, 2025 20:41
Show Gist options
  • Select an option

  • Save jimhester/c14d10890e39527ef5e209802eea6020 to your computer and use it in GitHub Desktop.

Select an option

Save jimhester/c14d10890e39527ef5e209802eea6020 to your computer and use it in GitHub Desktop.
Enhanced video controls and simple line annotations for tennis channel and youtube videos.
// ==UserScript==
// @name Universal Video Controls
// @match *://*.tennischannel.com/*
// @match *://*.youtube.com/*
// @match *://app.coachiq.io/*
// @grant none
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
function init() {
if (!document.body) {
setTimeout(init, 100);
return;
}
// Hide CoachIQ play overlay and thumbnail so paused frames are visible
if (location.hostname.includes('coachiq.io')) {
const style = document.createElement('style');
style.textContent = `
.video_overlay__TA5aF { display: none !important; }
.video_video__D07tR > img { display: none !important; }
.video_asset__7R3_I { opacity: 1 !important; }
`;
document.head.appendChild(style);
}
// Speed indicator
const indicator = document.createElement('div');
indicator.id = 'uvc-speed-indicator';
indicator.style.cssText = `
position: fixed;
top: 80px;
right: 20px;
background: rgba(0, 0, 0, 0.8);
color: #00ff88;
font-family: monospace;
font-size: 18px;
font-weight: bold;
padding: 8px 16px;
border-radius: 6px;
z-index: 2147483647;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
`;
// Loop indicator
const loopIndicator = document.createElement('div');
loopIndicator.id = 'uvc-loop-indicator';
loopIndicator.style.cssText = `
position: fixed;
top: 120px;
right: 20px;
background: rgba(0, 0, 0, 0.8);
color: #ff8800;
font-family: monospace;
font-size: 14px;
font-weight: bold;
padding: 6px 12px;
border-radius: 6px;
z-index: 2147483647;
display: none;
pointer-events: none;
`;
// Timer display
const timerDisplay = document.createElement('div');
timerDisplay.id = 'uvc-timer';
timerDisplay.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: #00ffff;
font-family: monospace;
font-size: 24px;
font-weight: bold;
padding: 8px 16px;
border-radius: 6px;
z-index: 2147483647;
display: none;
pointer-events: none;
`;
// Drawing canvas
const canvas = document.createElement('canvas');
canvas.id = 'uvc-draw-canvas';
canvas.style.cssText = `
position: fixed;
top: 0;
left: 0;
z-index: 2147483646;
pointer-events: none;
display: none;
`;
const ctx = canvas.getContext('2d');
// Preview canvas for line drawing
const previewCanvas = document.createElement('canvas');
previewCanvas.id = 'uvc-preview-canvas';
previewCanvas.style.cssText = `
position: fixed;
top: 0;
left: 0;
z-index: 2147483645;
pointer-events: none;
display: none;
`;
const previewCtx = previewCanvas.getContext('2d');
// Drawing mode indicator
const drawModeIndicator = document.createElement('div');
drawModeIndicator.id = 'uvc-draw-mode';
drawModeIndicator.style.cssText = `
position: fixed;
top: 20px;
left: 20px;
background: rgba(255, 0, 0, 0.8);
color: #fff;
font-family: monospace;
font-size: 12px;
font-weight: bold;
padding: 6px 12px;
border-radius: 6px;
z-index: 2147483647;
display: none;
pointer-events: none;
line-height: 1.6;
`;
// Controls help
const controls = document.createElement('div');
controls.id = 'uvc-controls-help';
controls.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.85);
color: #fff;
font-family: monospace;
font-size: 12px;
padding: 12px 16px;
border-radius: 8px;
z-index: 2147483647;
line-height: 1.8;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
`;
function createLine(text) {
const div = document.createElement('div');
div.textContent = text;
return div;
}
function createTitle(text) {
const div = document.createElement('div');
div.textContent = text;
div.style.cssText = 'margin-bottom: 6px; color: #00ff88; font-weight: bold;';
return div;
}
controls.appendChild(createTitle('🎬 Video Controls'));
controls.appendChild(createLine('Space Play/Pause'));
controls.appendChild(createLine('← → Skip 3s | ⇧← ⇧→ Skip 10s'));
controls.appendChild(createLine(', . Frame step'));
controls.appendChild(createLine('[ ] Speed ±0.25x | \\ Reset'));
controls.appendChild(createLine('A B C Loop start/end/clear'));
controls.appendChild(createLine('M Mirror | T Stopwatch | D Draw'));
controls.appendChild(createLine('H Toggle help'));
document.body.appendChild(indicator);
document.body.appendChild(loopIndicator);
document.body.appendChild(timerDisplay);
document.body.appendChild(previewCanvas);
document.body.appendChild(canvas);
document.body.appendChild(drawModeIndicator);
document.body.appendChild(controls);
// Fullscreen handling
document.addEventListener('fullscreenchange', () => {
const fsElement = document.fullscreenElement;
const elements = [indicator, loopIndicator, timerDisplay, previewCanvas, canvas, drawModeIndicator, controls];
elements.forEach(el => {
(fsElement || document.body).appendChild(el);
});
resizeCanvas();
});
let hideTimeout;
function showSpeed(rate) {
indicator.textContent = rate.toFixed(2) + 'x';
indicator.style.opacity = '1';
clearTimeout(hideTimeout);
hideTimeout = setTimeout(() => indicator.style.opacity = '0', 1500);
}
function showMessage(msg) {
indicator.textContent = msg;
indicator.style.opacity = '1';
clearTimeout(hideTimeout);
hideTimeout = setTimeout(() => indicator.style.opacity = '0', 1500);
}
function formatTime(seconds) {
const m = Math.floor(seconds / 60);
const s = (seconds % 60).toFixed(1);
return m + ':' + s.padStart(4, '0');
}
function formatTimeMs(seconds) {
const sign = seconds < 0 ? '-' : '+';
seconds = Math.abs(seconds);
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
const ms = Math.floor((seconds % 1) * 1000);
return sign + m.toString().padStart(2, '0') + ':' +
s.toString().padStart(2, '0') + '.' +
ms.toString().padStart(3, '0');
}
// A-B Loop state
let loopA = null;
let loopB = null;
let loopingVideo = null;
function updateLoopIndicator() {
if (loopA !== null && loopB !== null) {
loopIndicator.textContent = '🔁 ' + formatTime(loopA) + ' → ' + formatTime(loopB);
loopIndicator.style.display = 'block';
} else if (loopA !== null) {
loopIndicator.textContent = '🅰️ ' + formatTime(loopA) + ' → ?';
loopIndicator.style.display = 'block';
} else {
loopIndicator.style.display = 'none';
}
}
function loopHandler() {
if (loopA !== null && loopB !== null && loopingVideo) {
if (loopingVideo.currentTime >= loopB || loopingVideo.currentTime < loopA) {
loopingVideo.currentTime = loopA;
}
}
}
function clearLoop() {
if (loopingVideo) {
loopingVideo.removeEventListener('timeupdate', loopHandler);
}
loopA = null;
loopB = null;
loopingVideo = null;
updateLoopIndicator();
}
// Timer state
let timerActive = false;
let timerVideo = null;
let timerStartTime = 0;
function timerHandler() {
if (timerActive && timerVideo) {
const elapsed = timerVideo.currentTime - timerStartTime;
timerDisplay.textContent = formatTimeMs(elapsed);
}
}
function toggleTimer(video) {
if (!timerActive) {
timerActive = true;
timerVideo = video;
timerStartTime = video.currentTime;
video.addEventListener('timeupdate', timerHandler);
timerDisplay.style.display = 'block';
timerHandler();
} else {
timerActive = false;
if (timerVideo) {
timerVideo.removeEventListener('timeupdate', timerHandler);
}
timerDisplay.style.display = 'none';
timerVideo = null;
}
}
// Drawing state
let drawMode = false;
let isDrawing = false;
let startX = 0;
let startY = 0;
let lastX = 0;
let lastY = 0;
let drawColor = '#ff0000';
let lineMode = 'free';
const colors = ['#ff0000', '#00ff00', '#0088ff', '#ffff00', '#ff00ff'];
function resizeCanvas() {
canvas.width = previewCanvas.width = window.innerWidth;
canvas.height = previewCanvas.height = window.innerHeight;
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
function updateDrawModeIndicator() {
let modeText = '🖊️ DRAW MODE';
if (lineMode === 'free') modeText += ' [Freehand]';
else if (lineMode === 'line') modeText += ' [Lines]';
else if (lineMode === 'arrow') modeText += ' [Arrows]';
modeText += '\n⇧=snap H/V | 1-5=color | X=clear';
modeText += '\nF=free L=line R=arrow';
drawModeIndicator.textContent = modeText;
drawModeIndicator.style.whiteSpace = 'pre';
}
function snapAngle(x1, y1, x2, y2) {
const dx = x2 - x1;
const dy = y2 - y1;
const angle = Math.atan2(dy, dx);
const snapAngles = [0, Math.PI/4, Math.PI/2, 3*Math.PI/4, Math.PI, -3*Math.PI/4, -Math.PI/2, -Math.PI/4];
let closestAngle = snapAngles[0];
let minDiff = Math.abs(angle - snapAngles[0]);
for (const snap of snapAngles) {
const diff = Math.abs(angle - snap);
if (diff < minDiff) {
minDiff = diff;
closestAngle = snap;
}
}
const length = Math.sqrt(dx*dx + dy*dy);
return { x: x1 + Math.cos(closestAngle) * length, y: y1 + Math.sin(closestAngle) * length };
}
function drawArrow(context, x1, y1, x2, y2) {
const headLength = 15;
const angle = Math.atan2(y2 - y1, x2 - x1);
context.beginPath();
context.moveTo(x1, y1);
context.lineTo(x2, y2);
context.stroke();
context.beginPath();
context.moveTo(x2, y2);
context.lineTo(x2 - headLength * Math.cos(angle - Math.PI/6), y2 - headLength * Math.sin(angle - Math.PI/6));
context.moveTo(x2, y2);
context.lineTo(x2 - headLength * Math.cos(angle + Math.PI/6), y2 - headLength * Math.sin(angle + Math.PI/6));
context.stroke();
}
function startDraw(e) {
if (!drawMode) return;
isDrawing = true;
startX = lastX = e.clientX;
startY = lastY = e.clientY;
}
function draw(e) {
if (!isDrawing || !drawMode) return;
let endX = e.clientX;
let endY = e.clientY;
if (lineMode === 'free') {
ctx.beginPath();
ctx.moveTo(lastX, lastY);
ctx.lineTo(endX, endY);
ctx.strokeStyle = drawColor;
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.stroke();
lastX = endX;
lastY = endY;
} else {
if (e.shiftKey) {
const snapped = snapAngle(startX, startY, endX, endY);
endX = snapped.x;
endY = snapped.y;
}
previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
previewCtx.strokeStyle = drawColor;
previewCtx.lineWidth = 3;
previewCtx.lineCap = 'round';
previewCtx.setLineDash([5, 5]);
if (lineMode === 'line') {
previewCtx.beginPath();
previewCtx.moveTo(startX, startY);
previewCtx.lineTo(endX, endY);
previewCtx.stroke();
} else if (lineMode === 'arrow') {
drawArrow(previewCtx, startX, startY, endX, endY);
}
previewCtx.setLineDash([]);
}
}
function stopDraw(e) {
if (!isDrawing) return;
if (lineMode !== 'free' && isDrawing) {
let endX = e.clientX;
let endY = e.clientY;
if (e.shiftKey) {
const snapped = snapAngle(startX, startY, endX, endY);
endX = snapped.x;
endY = snapped.y;
}
ctx.strokeStyle = drawColor;
ctx.lineWidth = 3;
ctx.lineCap = 'round';
if (lineMode === 'line') {
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY);
ctx.stroke();
} else if (lineMode === 'arrow') {
drawArrow(ctx, startX, startY, endX, endY);
}
previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
}
isDrawing = false;
}
canvas.addEventListener('mousedown', startDraw);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', stopDraw);
canvas.addEventListener('mouseout', stopDraw);
function toggleDrawMode() {
drawMode = !drawMode;
canvas.style.display = drawMode ? 'block' : 'none';
previewCanvas.style.display = drawMode ? 'block' : 'none';
canvas.style.pointerEvents = drawMode ? 'auto' : 'none';
drawModeIndicator.style.display = drawMode ? 'block' : 'none';
document.body.style.cursor = drawMode ? 'crosshair' : '';
if (drawMode) updateDrawModeIndicator();
}
function clearDrawing() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
}
// Mirror state
let isMirrored = false;
function getVideo() {
// YouTube regular video
const ytVideo = document.querySelector('video.html5-main-video');
if (ytVideo && ytVideo.src) return ytVideo;
// YouTube Shorts - find the visible/playing one
const shortsVideos = document.querySelectorAll('ytd-reel-video-renderer video.html5-main-video');
for (const v of shortsVideos) {
if (!v.paused || v.currentTime > 0) return v;
}
if (shortsVideos.length > 0) return shortsVideos[0];
// CoachIQ (2-minute-tennis)
const coachiqVideo = document.querySelector('video.video_asset__7R3_I');
if (coachiqVideo) return coachiqVideo;
// Tennis Channel
const tcVideo = document.querySelector('video[src]') || document.getElementById('sravvpl_video-element--0');
if (tcVideo) return tcVideo;
// Generic fallback
const videos = Array.from(document.querySelectorAll('video'));
const playing = videos.find(v => !v.paused && !v.ended && v.readyState > 2);
if (playing) return playing;
const withSrc = videos.find(v => v.src || v.currentSrc);
return withSrc || videos[0];
}
const FRAME_TIME = 1 / 30;
function handleKey(e) {
// Draw mode specific keys
if (drawMode) {
if (e.key >= '1' && e.key <= '5') {
drawColor = colors[parseInt(e.key) - 1];
showMessage('Color: ' + drawColor);
e.stopImmediatePropagation();
e.preventDefault();
return;
}
if (e.key === 'x' || e.key === 'X') {
clearDrawing();
showMessage('Drawing cleared');
e.stopImmediatePropagation();
e.preventDefault();
return;
}
if (e.key === 'f' || e.key === 'F') {
lineMode = 'free';
updateDrawModeIndicator();
showMessage('Freehand mode');
e.stopImmediatePropagation();
e.preventDefault();
return;
}
if (e.key === 'l' || e.key === 'L') {
lineMode = 'line';
updateDrawModeIndicator();
showMessage('Line mode (⇧ to snap)');
e.stopImmediatePropagation();
e.preventDefault();
return;
}
if (e.key === 'r' || e.key === 'R') {
lineMode = 'arrow';
updateDrawModeIndicator();
showMessage('Arrow mode (⇧ to snap)');
e.stopImmediatePropagation();
e.preventDefault();
return;
}
}
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) {
return;
}
const video = getVideo();
if (e.key === 'd' || e.key === 'D') {
toggleDrawMode();
showMessage(drawMode ? 'Draw mode ON' : 'Draw mode OFF');
e.stopImmediatePropagation();
e.preventDefault();
return;
}
if (e.key === 'h' || e.key === 'H') {
controls.style.display = controls.style.display === 'none' ? 'block' : 'none';
e.stopImmediatePropagation();
e.preventDefault();
return;
}
if (!video) return;
let handled = false;
if (e.key === 't' || e.key === 'T') {
toggleTimer(video);
showMessage(timerActive ? 'Stopwatch started' : 'Stopwatch stopped');
handled = true;
}
if (e.key === ' ') {
if (video.paused) {
video.play();
} else {
video.pause();
}
handled = true;
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
const skipTime = e.shiftKey ? 10 : 3;
if (e.key === 'ArrowLeft') {
video.currentTime = Math.max(0, video.currentTime - skipTime);
} else {
video.currentTime = Math.min(video.duration, video.currentTime + skipTime);
}
handled = true;
}
if (e.key === ',') {
video.pause();
video.currentTime = Math.max(0, video.currentTime - FRAME_TIME);
handled = true;
} else if (e.key === '.') {
video.pause();
video.currentTime = Math.min(video.duration, video.currentTime + FRAME_TIME);
handled = true;
}
if (e.key === '[') {
video.playbackRate = Math.max(0.25, video.playbackRate - 0.25);
showSpeed(video.playbackRate);
handled = true;
} else if (e.key === ']') {
video.playbackRate = Math.min(4, video.playbackRate + 0.25);
showSpeed(video.playbackRate);
handled = true;
} else if (e.key === '\\') {
video.playbackRate = 1;
showSpeed(video.playbackRate);
handled = true;
}
if (e.key === 'a' || e.key === 'A') {
loopA = video.currentTime;
loopB = null;
if (loopingVideo) loopingVideo.removeEventListener('timeupdate', loopHandler);
loopingVideo = video;
updateLoopIndicator();
showMessage('Loop A: ' + formatTime(loopA));
handled = true;
} else if (e.key === 'b' || e.key === 'B') {
if (loopA !== null) {
loopB = video.currentTime;
if (loopB < loopA) [loopA, loopB] = [loopB, loopA];
loopingVideo = video;
video.addEventListener('timeupdate', loopHandler);
video.currentTime = loopA;
updateLoopIndicator();
showMessage('Looping ' + formatTime(loopA) + ' → ' + formatTime(loopB));
} else {
showMessage('Set point A first!');
}
handled = true;
} else if (e.key === 'c' || e.key === 'C') {
clearLoop();
showMessage('Loop cleared');
handled = true;
}
if (e.key === 'm' || e.key === 'M') {
isMirrored = !isMirrored;
video.style.transform = isMirrored ? 'scaleX(-1)' : '';
showMessage(isMirrored ? 'Mirrored' : 'Normal');
handled = true;
}
if (handled) {
e.stopImmediatePropagation();
e.preventDefault();
}
}
document.addEventListener('keydown', handleKey, true);
window.addEventListener('keydown', handleKey, true);
// Hook into YouTube players (both regular and Shorts)
function hookPlayer(player) {
if (player && !player.dataset.uvcHooked) {
player.dataset.uvcHooked = 'true';
player.addEventListener('keydown', handleKey, true);
}
}
const observer = new MutationObserver(() => {
// Regular YouTube player
hookPlayer(document.getElementById('movie_player'));
// YouTube Shorts player
hookPlayer(document.getElementById('shorts-player'));
// Hook all shorts players (multiple may exist)
document.querySelectorAll('#shorts-player').forEach(hookPlayer);
});
observer.observe(document.body, { childList: true, subtree: true });
// Check immediately
hookPlayer(document.getElementById('movie_player'));
hookPlayer(document.getElementById('shorts-player'));
}
init();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment