Last active
January 3, 2026 00:20
-
-
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'; | |
| // 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