Created
April 29, 2025 02:41
-
-
Save aphix/4af9d9c05e4e97661c436341272270a9 to your computer and use it in GitHub Desktop.
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 YouTube Commentary Synchronizer | |
| // @namespace http://tampermonkey.net/ | |
| // @version 0.1 | |
| // @description Synchronize YouTube commentary with a source video from a given URL | |
| // @author Aphix | |
| // @match https://www.youtube.com/watch* | |
| // @grant GM_xmlhttpRequest | |
| // @grant GM_addStyle | |
| // @grant unsafeWindow | |
| // @run-at document-start | |
| // ==/UserScript== | |
| (function() { | |
| function _loadExternalScript(scriptUrl) { | |
| GM_xmlhttpRequest({ | |
| method : "GET", | |
| // from other domain than the @match one (.org / .com): | |
| url : scriptUrl, | |
| onload : (ev) => | |
| { | |
| let e = document.createElement('script'); | |
| e.innerText = ev.responseText; | |
| document.head.appendChild(e); | |
| } | |
| }); | |
| } | |
| function _loadExternalStylesheet(stylesheetUrl) { | |
| GM_xmlhttpRequest({ | |
| method : "GET", | |
| url : stylesheetUrl, | |
| onload : (ev) => { | |
| let e = document.createElement('style'); | |
| e.innerText = ev.responseText; | |
| document.head.appendChild(e); | |
| } | |
| }); | |
| } | |
| _loadExternalStylesheet('https://unpkg.com/video.js/dist/video-js.min.css') | |
| _loadExternalScript('https://unpkg.com/video.js/dist/video.min.js') | |
| const INITIAL_LOAD_WAIT = 10000; | |
| async function _raf(fn) { | |
| return new Promise((resolve, reject) => { | |
| requestAnimationFrame(() => { | |
| try { | |
| fn(); | |
| resolve() | |
| } catch (err) { | |
| reject(err) | |
| } | |
| }) | |
| }); | |
| } | |
| function _launch() { | |
| function log(msg) { | |
| console.log(`[YouTubeSync]: ${msg}`); | |
| } | |
| log('Tampermonkey script loaded: YouTube Commentary Synchronizer with Logging'); | |
| // Adding controls to YouTube page | |
| const controlsHTML = ` | |
| <div id="tm-sync-container" style="padding: 10px; background-color: #fff; border: 1px solid #ccc; margin-top: 5px;"> | |
| <input type="text" id="tm-video-url" placeholder="Video URL" style="width: calc(100% - 120px);"> | |
| <input type="text" id="tm-audio-url" placeholder="Audio URL (Optional)" style="width: calc(100% - 120px);"> | |
| <input type="text" id="tm-offset" placeholder="Offset (sec)" style="width: 60px;"> | |
| <button id="tm-load-video" style="width: 50px;">Load</button> | |
| <button id="tm-sync-video" style="width: 50px;">Sync</button> | |
| <button id="tm-backward-10" style="width: 50px;"><<</button> | |
| <button id="tm-forward-10" style="width: 50px;">>></button> | |
| <video id="tm-source-video" controls preload="metadata" style="width: 100%; margin-top: 5px; display: none;"></video> | |
| <video id="tm-source-audio" controls preload="metadata" style="width: 100%; margin-top: 2px; display: none;"></video> | |
| </div> | |
| `; | |
| const referenceNode = document.getElementById('secondary'); | |
| referenceNode.insertAdjacentHTML('afterbegin', controlsHTML); | |
| const commentaryPlayer = document.querySelector('video.html5-main-video'); | |
| const sourceVideo = document.getElementById('tm-source-video'); | |
| let sourceAudio | |
| let vjsPlayer; | |
| const forgivableAllowanceMs = 250; // Allow microseconds range for floating point sync issues | |
| let lastSync = Date.now(); | |
| let offsetInSeconds = 0; | |
| let isSeeking = false; | |
| let shouldSyncPlay = false; | |
| let probablyPaused = false; | |
| function onPlay() { | |
| if (!isSeeking) { | |
| log('Detected play event.'); | |
| syncPlayers(false); | |
| } | |
| probablyPaused = false; | |
| } | |
| function onPause() { | |
| if (!isSeeking) { | |
| syncPlayers(true); | |
| } | |
| probablyPaused = true; | |
| } | |
| function syncPlayers(pause = false, self = sourceVideo) { | |
| if (lastSync >= Date.now() - 100) return; | |
| const targetPlayer = (self === sourceVideo) | |
| ? commentaryPlayer | |
| : (sourceAudio !== undefined && self === sourceAudio) | |
| ? sourceAudio | |
| : sourceVideo; | |
| const discrepancy = Math.abs((targetPlayer.currentTime - self.currentTime - offsetInSeconds) * 1000); | |
| if (pause) { | |
| commentaryPlayer.pause(); | |
| sourceVideo.pause(); | |
| if (sourceAudio) { | |
| sourceAudio.pause() | |
| } | |
| log('Both videos should be paused now.'); | |
| } else { | |
| commentaryPlayer.play(); | |
| sourceVideo.play(); | |
| if (sourceAudio) { | |
| sourceAudio.play() | |
| } | |
| } | |
| if (discrepancy > forgivableAllowanceMs) { | |
| if (self !== sourceVideo) { | |
| targetPlayer.currentTime = Math.round((commentaryPlayer.currentTime - offsetInSeconds) * 100) / 100; // might break things | |
| } else { | |
| targetPlayer.currentTime = Math.round((sourceVideo.currentTime + offsetInSeconds) * 100) / 100; // might break things | |
| } | |
| log('Synced players to rounded seconds.'); | |
| lastSync = Date.now() | |
| } | |
| if (sourceAudio) { | |
| sourceAudio.currentTime = sourceVideo.currentTime | |
| } | |
| } | |
| function onSeeking() { | |
| isSeeking = true; | |
| syncPlayers(true, this); | |
| } | |
| function onSeeked() { | |
| isSeeking = false; | |
| syncPlayers(false, this); | |
| } | |
| document.getElementById('tm-load-video').addEventListener('click', () => { | |
| let writtenValue = document.getElementById('tm-offset').value; | |
| let currentVideoOffset = commentaryPlayer.currentTime; | |
| if (writtenValue === '' && currentVideoOffset !== 0) { | |
| currentVideoOffset; | |
| offsetInSeconds = currentVideoOffset; | |
| } else { | |
| offsetInSeconds = Number(writtenValue) || 0; | |
| } | |
| if (Number.isNaN(offsetInSeconds)) { | |
| alert('Offset in seconds is borked!'); | |
| } | |
| document.getElementById('tm-offset').value = offsetInSeconds; | |
| var sourceVideoUrl = document.getElementById('tm-video-url').value; | |
| if (sourceVideoUrl.includes('m3u8')) { | |
| //var trackSourceNode = document.createElement('source') | |
| //trackSourceNode.src = sourceVideoUrl; | |
| //trackSourceNode.setAttribute('type','application/x-mpegURL'); | |
| //vjsPlayer.src({type:'application/x-mpegURL',src:sourceVideoUrl}); | |
| //sourceVideo.appendChild(trackSourceNode); | |
| } else { | |
| sourceVideo.src = sourceVideoUrl; | |
| } | |
| sourceVideo.style.display = 'block'; | |
| vjsPlayer = videojs('tm-source-video') | |
| if (sourceVideoUrl.includes('m3u8')) { | |
| //var trackSourceNode = document.createElement('source') | |
| //trackSourceNode.src = sourceVideoUrl; | |
| //trackSourceNode.setAttribute('type','application/x-mpegURL'); | |
| vjsPlayer.src({type:'application/x-mpegURL',src:sourceVideoUrl}); | |
| //sourceVideo.appendChild(trackSourceNode); | |
| } else { | |
| //sourceVideo.src = sourceVideoUrl; | |
| } | |
| let audioSourceUrl = document.getElementById('tm-audio-url').value; | |
| if (audioSourceUrl && audioSourceUrl.length) { | |
| sourceAudio = document.getElementById('tm-source-audio'); | |
| sourceAudio.src = document.getElementById('tm-audio-url').value; | |
| sourceAudio.style.display = 'block'; | |
| } | |
| log(`Loaded videos with offset ${offsetInSeconds} seconds.`); | |
| log(`Main video: ${sourceVideo.src}`); | |
| sourceVideo.addEventListener('play', onPlay); | |
| sourceVideo.addEventListener('pause', onPause); | |
| sourceVideo.addEventListener('seeking', onSeeking); | |
| sourceVideo.addEventListener('seeked', onSeeked); | |
| //sourceAudio.addEventListener('play', onPlay); | |
| //sourceAudio.addEventListener('pause', onPause); | |
| //sourceAudio.addEventListener('seeking', onSeeking); | |
| //sourceAudio.addEventListener('seeked', onSeeked); | |
| commentaryPlayer.addEventListener('play', onPlay); | |
| commentaryPlayer.addEventListener('pause', onPause); | |
| commentaryPlayer.addEventListener('seeking', onSeeking); | |
| commentaryPlayer.addEventListener('seeked', onSeeked); | |
| syncPlayers(true, sourceVideo); | |
| }); | |
| document.getElementById('tm-sync-video').addEventListener('click', () => { | |
| syncPlayers(true, sourceVideo); | |
| }); | |
| document.getElementById('tm-backward-10').addEventListener('click', () => { | |
| offsetInSeconds=offsetInSeconds-0.05; | |
| log(`Adjusting to offset ${offsetInSeconds} seconds.`); | |
| document.getElementById('tm-offset').value = offsetInSeconds; | |
| isSeeking=true; | |
| syncPlayers(probablyPaused, sourceVideo); | |
| }); | |
| document.getElementById('tm-forward-10').addEventListener('click', () => { | |
| offsetInSeconds=offsetInSeconds+0.05; | |
| log(`Adjusting to offset ${offsetInSeconds} seconds.`); | |
| document.getElementById('tm-offset').value = offsetInSeconds; | |
| isSeeking=true; | |
| syncPlayers(probablyPaused, sourceVideo); | |
| }); | |
| log('The custom controls have been added to the page.'); | |
| } | |
| setTimeout(() => _launch(), INITIAL_LOAD_WAIT); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Best script syncing a movie with the full length commentary. No notes.