Last active
December 19, 2025 20:41
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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