Skip to content

Instantly share code, notes, and snippets.

@aphix
Created April 29, 2025 02:41
Show Gist options
  • Select an option

  • Save aphix/4af9d9c05e4e97661c436341272270a9 to your computer and use it in GitHub Desktop.

Select an option

Save aphix/4af9d9c05e4e97661c436341272270a9 to your computer and use it in GitHub Desktop.
// ==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;">&lt;&lt;</button>
<button id="tm-forward-10" style="width: 50px;">&gt;&gt;</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);
})();
@peopleincourt
Copy link

peopleincourt commented Jul 26, 2025

Best script syncing a movie with the full length commentary. No notes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment