Skip to content

Instantly share code, notes, and snippets.

@jimhester
Last active January 3, 2026 00:20
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';
// Detect touch device
const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
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);
const FRAME_TIME = 1 / 30;
// Touch controls panel (for mobile devices)
let touchPanel = null;
let touchPanelExpanded = false;
if (isTouchDevice) {
// Hide keyboard controls help on touch devices
controls.style.display = 'none';
touchPanel = document.createElement('div');
touchPanel.id = 'uvc-touch-panel';
touchPanel.innerHTML = `
<style>
#uvc-touch-panel {
position: fixed;
top: 80px;
right: 20px;
z-index: 2147483647;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
}
#uvc-touch-toggle {
width: 50px;
height: 50px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.8);
color: #00ff88;
border: 2px solid #00ff88;
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
#uvc-touch-controls {
display: none;
position: absolute;
top: 60px;
right: 0;
background: rgba(0, 0, 0, 0.9);
border-radius: 12px;
padding: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
min-width: 200px;
max-height: 70vh;
overflow-y: auto;
}
#uvc-touch-controls.expanded {
display: block;
}
.uvc-touch-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
justify-content: center;
}
.uvc-touch-row:last-child {
margin-bottom: 0;
}
.uvc-touch-btn {
min-width: 44px;
height: 44px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.15);
color: #fff;
border: none;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0 12px;
transition: background 0.15s;
}
.uvc-touch-btn:active {
background: rgba(255, 255, 255, 0.3);
}
.uvc-touch-btn.active {
background: rgba(0, 255, 136, 0.3);
color: #00ff88;
}
.uvc-touch-label {
color: #888;
font-size: 11px;
text-align: center;
margin-bottom: 4px;
text-transform: uppercase;
}
.uvc-touch-section {
border-bottom: 1px solid rgba(255,255,255,0.1);
padding-bottom: 8px;
margin-bottom: 8px;
}
.uvc-touch-section:last-child {
border-bottom: none;
padding-bottom: 0;
margin-bottom: 0;
}
#uvc-speed-display {
color: #00ff88;
font-weight: bold;
min-width: 50px;
text-align: center;
}
.uvc-color-btn {
border: 2px solid transparent !important;
}
.uvc-color-btn.active {
border: 2px solid #fff !important;
box-shadow: 0 0 8px rgba(255,255,255,0.5);
}
/* Landscape mode - horizontal compact layout */
@media (orientation: landscape) {
#uvc-touch-panel {
top: auto;
bottom: 10px;
right: 10px;
left: 10px;
}
#uvc-touch-toggle {
position: absolute;
right: 0;
bottom: 0;
width: 40px;
height: 40px;
font-size: 18px;
}
#uvc-touch-controls {
position: relative;
top: auto;
right: auto;
bottom: auto;
left: auto;
max-height: none;
overflow-y: visible;
overflow-x: auto;
display: none;
flex-direction: row;
flex-wrap: nowrap;
gap: 8px;
padding: 8px;
padding-right: 50px;
border-radius: 8px;
min-width: auto;
white-space: nowrap;
}
#uvc-touch-controls.expanded {
display: flex;
}
.uvc-touch-section {
border-bottom: none;
border-right: 1px solid rgba(255,255,255,0.1);
padding-bottom: 0;
padding-right: 8px;
margin-bottom: 0;
margin-right: 0;
flex-shrink: 0;
}
.uvc-touch-section:last-child {
border-right: none;
padding-right: 0;
}
.uvc-touch-label {
display: none;
}
.uvc-touch-row {
margin-bottom: 0;
gap: 4px;
}
.uvc-touch-btn {
min-width: 36px;
height: 36px;
padding: 0 8px;
font-size: 14px;
}
#uvc-speed-display {
min-width: 40px;
font-size: 12px;
}
.uvc-color-btn {
min-width: 28px !important;
height: 28px !important;
}
#uvc-color-row {
gap: 2px !important;
}
}
</style>
<div id="uvc-touch-controls">
<div class="uvc-touch-section">
<div class="uvc-touch-label">Playback</div>
<div class="uvc-touch-row">
<button class="uvc-touch-btn" data-action="skip-back-10">⏪</button>
<button class="uvc-touch-btn" data-action="skip-back">◀◀</button>
<button class="uvc-touch-btn" data-action="play-pause" id="uvc-play-btn">▶</button>
<button class="uvc-touch-btn" data-action="skip-fwd">▶▶</button>
<button class="uvc-touch-btn" data-action="skip-fwd-10">⏩</button>
</div>
</div>
<div class="uvc-touch-section">
<div class="uvc-touch-label">Frame Step</div>
<div class="uvc-touch-row">
<button class="uvc-touch-btn" data-action="frame-back">◀|</button>
<button class="uvc-touch-btn" data-action="frame-fwd">|▶</button>
</div>
</div>
<div class="uvc-touch-section">
<div class="uvc-touch-label">Speed</div>
<div class="uvc-touch-row">
<button class="uvc-touch-btn" data-action="speed-down">−</button>
<span id="uvc-speed-display">1.00x</span>
<button class="uvc-touch-btn" data-action="speed-up">+</button>
<button class="uvc-touch-btn" data-action="speed-reset">1x</button>
</div>
</div>
<div class="uvc-touch-section">
<div class="uvc-touch-label">Loop</div>
<div class="uvc-touch-row">
<button class="uvc-touch-btn" data-action="loop-a" id="uvc-loop-a-btn">A</button>
<button class="uvc-touch-btn" data-action="loop-b" id="uvc-loop-b-btn">B</button>
<button class="uvc-touch-btn" data-action="loop-clear">Clear</button>
</div>
</div>
<div class="uvc-touch-section">
<div class="uvc-touch-label">Tools</div>
<div class="uvc-touch-row">
<button class="uvc-touch-btn" data-action="mirror" id="uvc-mirror-btn">Mirror</button>
<button class="uvc-touch-btn" data-action="timer" id="uvc-timer-btn">Timer</button>
<button class="uvc-touch-btn" data-action="fullscreen" id="uvc-fs-btn">Full</button>
</div>
</div>
<div class="uvc-touch-section">
<div class="uvc-touch-label">Draw</div>
<div class="uvc-touch-row">
<button class="uvc-touch-btn" data-action="draw-toggle" id="uvc-draw-btn">Draw</button>
<button class="uvc-touch-btn" data-action="draw-mode" id="uvc-draw-mode-btn">Line</button>
<button class="uvc-touch-btn" data-action="draw-snap" id="uvc-snap-btn">Snap</button>
<button class="uvc-touch-btn" data-action="draw-clear">Clear</button>
</div>
<div class="uvc-touch-row" id="uvc-color-row">
<button class="uvc-touch-btn uvc-color-btn active" data-action="color" data-color="#ff0000" style="background:#ff0000;min-width:36px;"></button>
<button class="uvc-touch-btn uvc-color-btn" data-action="color" data-color="#00ff00" style="background:#00ff00;min-width:36px;"></button>
<button class="uvc-touch-btn uvc-color-btn" data-action="color" data-color="#0088ff" style="background:#0088ff;min-width:36px;"></button>
<button class="uvc-touch-btn uvc-color-btn" data-action="color" data-color="#ffff00" style="background:#ffff00;min-width:36px;"></button>
<button class="uvc-touch-btn uvc-color-btn" data-action="color" data-color="#ff00ff" style="background:#ff00ff;min-width:36px;"></button>
</div>
</div>
</div>
<button id="uvc-touch-toggle">🎬</button>
`;
document.body.appendChild(touchPanel);
// Toggle panel expansion
const toggleBtn = touchPanel.querySelector('#uvc-touch-toggle');
const controlsDiv = touchPanel.querySelector('#uvc-touch-controls');
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
touchPanelExpanded = !touchPanelExpanded;
controlsDiv.classList.toggle('expanded', touchPanelExpanded);
toggleBtn.textContent = touchPanelExpanded ? '✕' : '🎬';
});
// Close panel when tapping outside
document.addEventListener('click', (e) => {
if (touchPanelExpanded && !touchPanel.contains(e.target)) {
touchPanelExpanded = false;
controlsDiv.classList.remove('expanded');
toggleBtn.textContent = '🎬';
}
});
// Handle touch control actions
function updateTouchUI(video) {
if (!video) return;
const playBtn = touchPanel.querySelector('#uvc-play-btn');
const speedDisplay = touchPanel.querySelector('#uvc-speed-display');
const loopABtn = touchPanel.querySelector('#uvc-loop-a-btn');
const loopBBtn = touchPanel.querySelector('#uvc-loop-b-btn');
const mirrorBtn = touchPanel.querySelector('#uvc-mirror-btn');
const timerBtn = touchPanel.querySelector('#uvc-timer-btn');
playBtn.textContent = video.paused ? '▶' : '⏸';
speedDisplay.textContent = video.playbackRate.toFixed(2) + 'x';
loopABtn.classList.toggle('active', loopA !== null);
loopBBtn.classList.toggle('active', loopB !== null);
mirrorBtn.classList.toggle('active', isMirrored);
timerBtn.classList.toggle('active', timerActive);
const drawBtn = touchPanel.querySelector('#uvc-draw-btn');
drawBtn.classList.toggle('active', drawMode);
}
touchPanel.addEventListener('click', (e) => {
const btn = e.target.closest('.uvc-touch-btn');
if (!btn) return;
e.stopPropagation();
const action = btn.dataset.action;
const video = getVideo();
if (!video && action !== 'play-pause') return;
switch (action) {
case 'play-pause':
if (video) {
if (video.paused) video.play();
else video.pause();
}
break;
case 'skip-back':
video.currentTime = Math.max(0, video.currentTime - 3);
break;
case 'skip-fwd':
video.currentTime = Math.min(video.duration, video.currentTime + 3);
break;
case 'skip-back-10':
video.currentTime = Math.max(0, video.currentTime - 10);
break;
case 'skip-fwd-10':
video.currentTime = Math.min(video.duration, video.currentTime + 10);
break;
case 'frame-back':
video.pause();
video.currentTime = Math.max(0, video.currentTime - FRAME_TIME);
break;
case 'frame-fwd':
video.pause();
video.currentTime = Math.min(video.duration, video.currentTime + FRAME_TIME);
break;
case 'speed-down':
video.playbackRate = Math.max(0.25, video.playbackRate - 0.25);
showSpeed(video.playbackRate);
break;
case 'speed-up':
video.playbackRate = Math.min(4, video.playbackRate + 0.25);
showSpeed(video.playbackRate);
break;
case 'speed-reset':
video.playbackRate = 1;
showSpeed(video.playbackRate);
break;
case 'loop-a':
loopA = video.currentTime;
loopB = null;
if (loopingVideo) loopingVideo.removeEventListener('timeupdate', loopHandler);
loopingVideo = video;
updateLoopIndicator();
showMessage('Loop A: ' + formatTime(loopA));
break;
case 'loop-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!');
}
break;
case 'loop-clear':
clearLoop();
showMessage('Loop cleared');
break;
case 'mirror':
isMirrored = !isMirrored;
video.style.transform = isMirrored ? 'scaleX(-1)' : '';
showMessage(isMirrored ? 'Mirrored' : 'Normal');
break;
case 'timer':
toggleTimer(video);
showMessage(timerActive ? 'Stopwatch started' : 'Stopwatch stopped');
break;
case 'fullscreen':
// Use native iOS fullscreen for true fullscreen experience
if (video.webkitEnterFullscreen) {
video.webkitEnterFullscreen();
} else if (video.requestFullscreen) {
video.requestFullscreen();
} else if (document.documentElement.webkitRequestFullscreen) {
document.documentElement.webkitRequestFullscreen();
}
break;
case 'draw-toggle':
toggleDrawMode();
showMessage(drawMode ? 'Draw mode ON' : 'Draw mode OFF');
break;
case 'draw-mode':
// Cycle through line modes: line -> arrow -> line
if (lineMode === 'line') {
lineMode = 'arrow';
} else {
lineMode = 'line';
}
touchPanel.querySelector('#uvc-draw-mode-btn').textContent = lineMode === 'line' ? 'Line' : 'Arrow';
showMessage(lineMode === 'line' ? 'Line mode' : 'Arrow mode');
break;
case 'draw-snap':
snapEnabled = !snapEnabled;
touchPanel.querySelector('#uvc-snap-btn').classList.toggle('active', snapEnabled);
showMessage(snapEnabled ? 'Snap ON' : 'Snap OFF');
break;
case 'draw-clear':
clearDrawing();
showMessage('Drawing cleared');
break;
case 'color':
drawColor = btn.dataset.color;
touchPanel.querySelectorAll('.uvc-color-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
break;
}
updateTouchUI(video);
});
// Update UI periodically
setInterval(() => {
const video = getVideo();
if (video && touchPanelExpanded) {
updateTouchUI(video);
}
}, 250);
}
// Fullscreen handling - supports standard, webkit, and iOS video fullscreen
function handleFullscreenChange() {
const fsElement = document.fullscreenElement || document.webkitFullscreenElement;
const elements = [indicator, loopIndicator, timerDisplay, previewCanvas, canvas, drawModeIndicator, controls];
if (touchPanel) elements.push(touchPanel);
elements.forEach(el => {
(fsElement || document.body).appendChild(el);
});
resizeCanvas();
}
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
// For iOS Safari video fullscreen (native player) - our controls won't show in this mode
// but we can track state for when user exits
function setupVideoFullscreenListeners(video) {
if (!video || video.dataset.uvcFsListeners) return;
video.dataset.uvcFsListeners = 'true';
video.addEventListener('webkitbeginfullscreen', () => {
// Video entered native iOS fullscreen - our controls won't be visible
});
video.addEventListener('webkitendfullscreen', () => {
// Video exited native iOS fullscreen - restore our controls
handleFullscreenChange();
});
}
// Setup listeners on any video found
const videoObserver = new MutationObserver(() => {
const video = getVideo();
if (video) setupVideoFullscreenListeners(video);
});
videoObserver.observe(document.body, { childList: true, subtree: true });
// Initial setup
setTimeout(() => {
const video = getVideo();
if (video) setupVideoFullscreenListeners(video);
}, 1000);
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 = isTouchDevice ? 'line' : 'free'; // Default to line mode on touch devices
let snapEnabled = false; // For touch devices (desktop uses shift key)
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 || snapEnabled) {
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 || snapEnabled) {
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);
// Touch event handlers for drawing
function getTouchPos(e) {
const touch = e.touches[0] || e.changedTouches[0];
return { clientX: touch.clientX, clientY: touch.clientY, shiftKey: false };
}
canvas.addEventListener('touchstart', (e) => {
if (!drawMode) return;
e.preventDefault();
startDraw(getTouchPos(e));
}, { passive: false });
canvas.addEventListener('touchmove', (e) => {
if (!drawMode) return;
e.preventDefault();
draw(getTouchPos(e));
}, { passive: false });
canvas.addEventListener('touchend', (e) => {
if (!drawMode) return;
e.preventDefault();
stopDraw(getTouchPos(e));
}, { passive: false });
canvas.addEventListener('touchcancel', (e) => {
if (!drawMode) return;
stopDraw(getTouchPos(e));
});
function toggleDrawMode() {
drawMode = !drawMode;
canvas.style.display = drawMode ? 'block' : 'none';
previewCanvas.style.display = drawMode ? 'block' : 'none';
canvas.style.pointerEvents = drawMode ? 'auto' : 'none';
// Only show draw mode indicator on desktop (touch devices have the panel)
if (!isTouchDevice) {
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];
}
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