Last active
February 16, 2026 01:00
-
-
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; | |
| `; | |
| // Bisector indicator | |
| const bisectorIndicator = document.createElement('div'); | |
| bisectorIndicator.id = 'uvc-bisector-indicator'; | |
| bisectorIndicator.style.cssText = ` | |
| position: fixed; | |
| top: 20px; | |
| left: 20px; | |
| background: rgba(0, 100, 255, 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; | |
| `; | |
| // 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); | |
| cursor: move; | |
| user-select: none; | |
| `; | |
| 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 (drag to move)')); | |
| 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('I(n) O(ut) P(urge) Loop')); | |
| controls.appendChild(createLine('M Mirror | F Fullscreen | D Draw')); | |
| controls.appendChild(createLine('B Bisect angle | ⇧B Recalibrate')); | |
| controls.appendChild(createLine('T Stopwatch | 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(bisectorIndicator); | |
| document.body.appendChild(controls); | |
| // Make controls help draggable | |
| let isDraggingHelp = false; | |
| let helpDragOffsetX = 0; | |
| let helpDragOffsetY = 0; | |
| controls.addEventListener('mousedown', (e) => { | |
| isDraggingHelp = true; | |
| helpDragOffsetX = e.clientX - controls.getBoundingClientRect().left; | |
| helpDragOffsetY = e.clientY - controls.getBoundingClientRect().top; | |
| e.preventDefault(); | |
| }); | |
| document.addEventListener('mousemove', (e) => { | |
| if (!isDraggingHelp) return; | |
| // Clear bottom/right positioning and switch to top/left | |
| controls.style.bottom = 'auto'; | |
| controls.style.right = 'auto'; | |
| controls.style.left = (e.clientX - helpDragOffsetX) + 'px'; | |
| controls.style.top = (e.clientY - helpDragOffsetY) + 'px'; | |
| }); | |
| document.addEventListener('mouseup', () => { | |
| isDraggingHelp = false; | |
| }); | |
| 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-i" id="uvc-loop-i-btn">I(n)</button> | |
| <button class="uvc-touch-btn" data-action="loop-o" id="uvc-loop-o-btn">O(ut)</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> | |
| <button class="uvc-touch-btn" data-action="bisector" id="uvc-bisect-btn">Bisect</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-i-btn'); | |
| const loopBBtn = touchPanel.querySelector('#uvc-loop-o-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); | |
| const bisectBtn = touchPanel.querySelector('#uvc-bisect-btn'); | |
| if (bisectBtn) bisectBtn.classList.toggle('active', bisectorMode); | |
| } | |
| 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-i': | |
| loopA = video.currentTime; | |
| loopB = null; | |
| if (loopingVideo) loopingVideo.removeEventListener('timeupdate', loopHandler); | |
| loopingVideo = video; | |
| updateLoopIndicator(); | |
| showMessage('Loop In: ' + formatTime(loopA)); | |
| break; | |
| case 'loop-o': | |
| 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 I(n) 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 'bisector': | |
| bisectorMode = !bisectorMode; | |
| if (bisectorMode) { | |
| if (drawMode) { | |
| drawMode = false; | |
| touchPanel.querySelector('#uvc-draw-btn').classList.remove('active'); | |
| } | |
| canvas.style.display = 'block'; | |
| previewCanvas.style.display = 'block'; | |
| canvas.style.pointerEvents = 'auto'; | |
| if (bisectorCorners.length < 4) { | |
| bisectorCalibrating = true; | |
| showMessage('Tap 4 court corners'); | |
| } else { | |
| showMessage('Bisector ON: tap opponent'); | |
| renderBisector(); | |
| } | |
| } else { | |
| bisectorCalibrating = false; | |
| bisectorOpponent = null; | |
| previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height); | |
| if (!drawMode) { | |
| canvas.style.display = 'none'; | |
| previewCanvas.style.display = 'none'; | |
| canvas.style.pointerEvents = 'none'; | |
| } | |
| showMessage('Bisector OFF'); | |
| } | |
| updateBisectorIndicator(); | |
| 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, bisectorIndicator, 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']; | |
| // Angle bisector state | |
| let bisectorMode = false; | |
| let bisectorCorners = []; // [{x, y}, ...] - 4 calibrated court corners: near-left, near-right, far-left, far-right | |
| let bisectorCalibrating = false; | |
| let bisectorOpponent = null; // {x, y} or null | |
| function resizeCanvas() { | |
| canvas.width = previewCanvas.width = window.innerWidth; | |
| canvas.height = previewCanvas.height = window.innerHeight; | |
| if (bisectorMode && bisectorCorners.length > 0) { | |
| renderBisector(); | |
| } | |
| } | |
| 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 || bisectorMode) 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)); | |
| }); | |
| // Bisector interaction state | |
| const CORNER_GRAB_RADIUS = 20; | |
| let draggingCornerIndex = -1; | |
| function findNearestCorner(x, y) { | |
| let minDist = CORNER_GRAB_RADIUS; | |
| let index = -1; | |
| for (let i = 0; i < bisectorCorners.length; i++) { | |
| const dx = x - bisectorCorners[i].x; | |
| const dy = y - bisectorCorners[i].y; | |
| const dist = Math.sqrt(dx * dx + dy * dy); | |
| if (dist < minDist) { | |
| minDist = dist; | |
| index = i; | |
| } | |
| } | |
| return index; | |
| } | |
| // Bisector mouse handlers | |
| canvas.addEventListener('mousedown', (e) => { | |
| if (!bisectorMode) return; | |
| if (bisectorCalibrating) { | |
| bisectorCorners.push({ x: e.clientX, y: e.clientY }); | |
| if (bisectorCorners.length >= 4) { | |
| bisectorCalibrating = false; | |
| showMessage('Court calibrated! Click opponent position.'); | |
| } else { | |
| showMessage('Corner ' + bisectorCorners.length + '/4 set'); | |
| } | |
| updateBisectorIndicator(); | |
| renderBisector(); | |
| return; | |
| } | |
| if (bisectorCorners.length >= 4) { | |
| // Check if clicking near a corner to drag it | |
| const nearCorner = findNearestCorner(e.clientX, e.clientY); | |
| if (nearCorner >= 0) { | |
| draggingCornerIndex = nearCorner; | |
| return; | |
| } | |
| // Otherwise place opponent | |
| bisectorOpponent = { x: e.clientX, y: e.clientY }; | |
| renderBisector(); | |
| } | |
| }); | |
| canvas.addEventListener('mousemove', (e) => { | |
| if (!bisectorMode || draggingCornerIndex < 0) return; | |
| bisectorCorners[draggingCornerIndex] = { x: e.clientX, y: e.clientY }; | |
| renderBisector(); | |
| }); | |
| canvas.addEventListener('mouseup', () => { | |
| draggingCornerIndex = -1; | |
| }); | |
| // Bisector touch handlers | |
| canvas.addEventListener('touchstart', (e) => { | |
| if (!bisectorMode || drawMode) return; | |
| e.preventDefault(); | |
| const touch = e.touches[0]; | |
| if (bisectorCalibrating) { | |
| bisectorCorners.push({ x: touch.clientX, y: touch.clientY }); | |
| if (bisectorCorners.length >= 4) { | |
| bisectorCalibrating = false; | |
| showMessage('Court calibrated! Tap opponent position.'); | |
| } else { | |
| showMessage('Corner ' + bisectorCorners.length + '/4 set'); | |
| } | |
| updateBisectorIndicator(); | |
| renderBisector(); | |
| return; | |
| } | |
| if (bisectorCorners.length >= 4) { | |
| const nearCorner = findNearestCorner(touch.clientX, touch.clientY); | |
| if (nearCorner >= 0) { | |
| draggingCornerIndex = nearCorner; | |
| return; | |
| } | |
| bisectorOpponent = { x: touch.clientX, y: touch.clientY }; | |
| renderBisector(); | |
| } | |
| }, { passive: false }); | |
| canvas.addEventListener('touchmove', (e) => { | |
| if (!bisectorMode || draggingCornerIndex < 0) return; | |
| e.preventDefault(); | |
| const touch = e.touches[0]; | |
| bisectorCorners[draggingCornerIndex] = { x: touch.clientX, y: touch.clientY }; | |
| renderBisector(); | |
| }, { passive: false }); | |
| canvas.addEventListener('touchend', (e) => { | |
| if (!bisectorMode) return; | |
| if (draggingCornerIndex >= 0) { | |
| draggingCornerIndex = -1; | |
| return; | |
| } | |
| }, { passive: false }); | |
| function toggleDrawMode() { | |
| drawMode = !drawMode; | |
| const showCanvas = drawMode || bisectorMode; | |
| canvas.style.display = showCanvas ? 'block' : 'none'; | |
| previewCanvas.style.display = showCanvas ? 'block' : 'none'; | |
| canvas.style.pointerEvents = showCanvas ? '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); | |
| } | |
| function renderBisector() { | |
| // Clear preview canvas and redraw bisector visualization | |
| previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height); | |
| // Draw corner markers (small circles, subtle) | |
| if (bisectorCorners.length > 0) { | |
| previewCtx.fillStyle = 'rgba(255, 255, 255, 0.5)'; | |
| for (const corner of bisectorCorners) { | |
| previewCtx.beginPath(); | |
| previewCtx.arc(corner.x, corner.y, 5, 0, Math.PI * 2); | |
| previewCtx.fill(); | |
| } | |
| } | |
| if (!bisectorOpponent || bisectorCorners.length < 4) return; | |
| const opp = bisectorOpponent; | |
| const nearLeft = bisectorCorners[0]; | |
| const nearRight = bisectorCorners[1]; | |
| const farLeft = bisectorCorners[2]; | |
| const farRight = bisectorCorners[3]; | |
| // Determine which side the opponent is on by comparing distance to near vs far midpoints | |
| const nearMidY = (nearLeft.y + nearRight.y) / 2; | |
| const farMidY = (farLeft.y + farRight.y) / 2; | |
| const distToNear = Math.abs(opp.y - nearMidY); | |
| const distToFar = Math.abs(opp.y - farMidY); | |
| // Target corners are on the opposite side from the opponent | |
| let targetLeft, targetRight; | |
| if (distToFar < distToNear) { | |
| // Opponent is on far side, target is near corners | |
| targetLeft = nearLeft; | |
| targetRight = nearRight; | |
| } else { | |
| // Opponent is on near side, target is far corners | |
| targetLeft = farLeft; | |
| targetRight = farRight; | |
| } | |
| // Extend lines to canvas edge | |
| function extendToEdge(fromX, fromY, toX, toY) { | |
| const dx = toX - fromX; | |
| const dy = toY - fromY; | |
| const len = Math.sqrt(dx * dx + dy * dy); | |
| if (len === 0) return { x: toX, y: toY }; | |
| const scale = Math.max(previewCanvas.width, previewCanvas.height) * 2 / len; | |
| return { x: fromX + dx * scale, y: fromY + dy * scale }; | |
| } | |
| const extLeft = extendToEdge(opp.x, opp.y, targetLeft.x, targetLeft.y); | |
| const extRight = extendToEdge(opp.x, opp.y, targetRight.x, targetRight.y); | |
| // Draw angle lines from opponent through target corners | |
| previewCtx.strokeStyle = drawColor; | |
| previewCtx.lineWidth = 3; | |
| previewCtx.lineCap = 'round'; | |
| previewCtx.setLineDash([]); | |
| previewCtx.beginPath(); | |
| previewCtx.moveTo(opp.x, opp.y); | |
| previewCtx.lineTo(extLeft.x, extLeft.y); | |
| previewCtx.stroke(); | |
| previewCtx.beginPath(); | |
| previewCtx.moveTo(opp.x, opp.y); | |
| previewCtx.lineTo(extRight.x, extRight.y); | |
| previewCtx.stroke(); | |
| // Draw baseline between target corners | |
| previewCtx.beginPath(); | |
| previewCtx.moveTo(targetLeft.x, targetLeft.y); | |
| previewCtx.lineTo(targetRight.x, targetRight.y); | |
| previewCtx.stroke(); | |
| // Draw shaded region (using extended points) | |
| const r = parseInt(drawColor.slice(1, 3), 16); | |
| const g = parseInt(drawColor.slice(3, 5), 16); | |
| const b = parseInt(drawColor.slice(5, 7), 16); | |
| previewCtx.fillStyle = `rgba(${r}, ${g}, ${b}, 0.2)`; | |
| previewCtx.beginPath(); | |
| previewCtx.moveTo(opp.x, opp.y); | |
| previewCtx.lineTo(extLeft.x, extLeft.y); | |
| previewCtx.lineTo(extRight.x, extRight.y); | |
| previewCtx.closePath(); | |
| previewCtx.fill(); | |
| // Compute true angle bisector direction (unit vector averaging) | |
| const dxL = targetLeft.x - opp.x; | |
| const dyL = targetLeft.y - opp.y; | |
| const lenL = Math.sqrt(dxL * dxL + dyL * dyL); | |
| const dxR = targetRight.x - opp.x; | |
| const dyR = targetRight.y - opp.y; | |
| const lenR = Math.sqrt(dxR * dxR + dyR * dyR); | |
| const bisectDirX = dxL / lenL + dxR / lenR; | |
| const bisectDirY = dyL / lenL + dyR / lenR; | |
| // Find where bisector intersects the baseline (line between target corners) | |
| // Ray: P = opp + t * bisectDir | |
| // Segment: Q = targetLeft + s * (targetRight - targetLeft) | |
| const blDx = targetRight.x - targetLeft.x; | |
| const blDy = targetRight.y - targetLeft.y; | |
| const denom = bisectDirX * blDy - bisectDirY * blDx; | |
| let bisectorEnd; | |
| if (Math.abs(denom) > 0.001) { | |
| const t = ((targetLeft.x - opp.x) * blDy - (targetLeft.y - opp.y) * blDx) / denom; | |
| bisectorEnd = { x: opp.x + t * bisectDirX, y: opp.y + t * bisectDirY }; | |
| } else { | |
| // Fallback: bisector parallel to baseline, just use midpoint | |
| bisectorEnd = { x: (targetLeft.x + targetRight.x) / 2, y: (targetLeft.y + targetRight.y) / 2 }; | |
| } | |
| // Draw bisector line (dashed, white) from opponent to canvas edge | |
| const extBisect = extendToEdge(opp.x, opp.y, bisectorEnd.x, bisectorEnd.y); | |
| previewCtx.strokeStyle = '#ffffff'; | |
| previewCtx.lineWidth = 3; | |
| previewCtx.setLineDash([8, 6]); | |
| previewCtx.beginPath(); | |
| previewCtx.moveTo(opp.x, opp.y); | |
| previewCtx.lineTo(extBisect.x, extBisect.y); | |
| previewCtx.stroke(); | |
| previewCtx.setLineDash([]); | |
| // Draw "stand here" marker where bisector meets baseline | |
| previewCtx.fillStyle = 'rgba(0, 0, 0, 0.4)'; | |
| previewCtx.beginPath(); | |
| previewCtx.arc(bisectorEnd.x, bisectorEnd.y, 5, 0, Math.PI * 2); | |
| previewCtx.fill(); | |
| // Draw opponent marker | |
| previewCtx.fillStyle = drawColor; | |
| previewCtx.beginPath(); | |
| previewCtx.arc(opp.x, opp.y, 7, 0, Math.PI * 2); | |
| previewCtx.fill(); | |
| } | |
| function updateBisectorIndicator() { | |
| if (!bisectorMode) { | |
| bisectorIndicator.style.display = 'none'; | |
| return; | |
| } | |
| bisectorIndicator.style.display = 'block'; | |
| if (bisectorCalibrating) { | |
| bisectorIndicator.textContent = '📐 Click corner ' + (bisectorCorners.length + 1) + ' of 4: ' + | |
| ['near-left', 'near-right', 'far-left', 'far-right'][bisectorCorners.length]; | |
| } else if (bisectorCorners.length < 4) { | |
| bisectorIndicator.textContent = '📐 BISECTOR: Press B to calibrate corners'; | |
| } else { | |
| bisectorIndicator.textContent = '📐 BISECTOR: Click opponent position | ⇧B recalibrate | X clear'; | |
| } | |
| bisectorIndicator.style.whiteSpace = 'pre'; | |
| } | |
| // 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) { | |
| if ((drawMode || bisectorMode) && (e.key === 'x' || e.key === 'X')) { | |
| clearDrawing(); | |
| bisectorOpponent = null; | |
| if (bisectorMode) renderBisector(); // Re-renders corner markers only | |
| showMessage('Drawing cleared'); | |
| e.stopImmediatePropagation(); | |
| e.preventDefault(); | |
| return; | |
| } | |
| // Draw mode specific keys | |
| if (drawMode || bisectorMode) { | |
| if (e.key >= '1' && e.key <= '5') { | |
| drawColor = colors[parseInt(e.key) - 1]; | |
| if (bisectorMode) renderBisector(); | |
| showMessage('Color: ' + drawColor); | |
| 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 === 'b' || e.key === 'B') { | |
| if (e.shiftKey) { | |
| // Shift+B: re-calibrate corners | |
| if (!bisectorMode) { | |
| bisectorMode = true; | |
| canvas.style.display = 'block'; | |
| previewCanvas.style.display = 'block'; | |
| canvas.style.pointerEvents = 'auto'; | |
| } | |
| bisectorCorners = []; | |
| bisectorCalibrating = true; | |
| bisectorOpponent = null; | |
| previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height); | |
| updateBisectorIndicator(); | |
| showMessage('Re-calibrating: click 4 court corners'); | |
| } else { | |
| // B: toggle bisector mode | |
| bisectorMode = !bisectorMode; | |
| if (bisectorMode) { | |
| // Turn off draw mode if active | |
| if (drawMode) { | |
| drawMode = false; | |
| drawModeIndicator.style.display = 'none'; | |
| document.body.style.cursor = ''; | |
| } | |
| canvas.style.display = 'block'; | |
| previewCanvas.style.display = 'block'; | |
| canvas.style.pointerEvents = 'auto'; | |
| if (bisectorCorners.length < 4) { | |
| bisectorCalibrating = true; | |
| showMessage('Click 4 court corners: near-left, near-right, far-left, far-right'); | |
| } else { | |
| showMessage('Bisector ON: click opponent position'); | |
| renderBisector(); | |
| } | |
| } else { | |
| bisectorCalibrating = false; | |
| bisectorOpponent = null; | |
| previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height); | |
| // Only hide canvas if draw mode is also off | |
| if (!drawMode) { | |
| canvas.style.display = 'none'; | |
| previewCanvas.style.display = 'none'; | |
| canvas.style.pointerEvents = 'none'; | |
| } | |
| showMessage('Bisector OFF'); | |
| } | |
| updateBisectorIndicator(); | |
| } | |
| 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 === ' ') { | |
| // Skip space handling on YouTube - YouTube handles space natively and our handler conflicts | |
| // Use 'k' for play/pause on YouTube instead (YouTube's native shortcut) | |
| if (!location.hostname.includes('youtube.com')) { | |
| 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 === 'i' || e.key === 'I') { | |
| loopA = video.currentTime; | |
| loopB = null; | |
| if (loopingVideo) loopingVideo.removeEventListener('timeupdate', loopHandler); | |
| loopingVideo = video; | |
| updateLoopIndicator(); | |
| showMessage('Loop In: ' + formatTime(loopA)); | |
| handled = true; | |
| } else if (e.key === 'o' || e.key === 'O') { | |
| 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 I(n) first!'); | |
| } | |
| handled = true; | |
| } else if (e.key === 'p' || e.key === 'P') { | |
| 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 (e.key === 'f' || e.key === 'F') { | |
| // Toggle fullscreen | |
| if (document.fullscreenElement || document.webkitFullscreenElement) { | |
| if (document.exitFullscreen) { | |
| document.exitFullscreen(); | |
| } else if (document.webkitExitFullscreen) { | |
| document.webkitExitFullscreen(); | |
| } | |
| } else { | |
| if (video.requestFullscreen) { | |
| video.requestFullscreen(); | |
| } else if (video.webkitRequestFullscreen) { | |
| video.webkitRequestFullscreen(); | |
| } else if (document.documentElement.requestFullscreen) { | |
| document.documentElement.requestFullscreen(); | |
| } else if (document.documentElement.webkitRequestFullscreen) { | |
| document.documentElement.webkitRequestFullscreen(); | |
| } | |
| } | |
| 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