Adds a seekable progress bar to Instagram videos and Reels.
// ==UserScript==
// @name [INSTAGRAM] PROGRESS BAR
// @namespace http://tampermonkey.net/
// @version 2.0
// @description Adds a seekable progress bar to Instagram videos and Reels.
// @author Emree.el on Instagram
// @match *://*.instagram.com/*
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const CLASS_NAME = 'emree-instagram-progress-bar';
const FILL_CLASS_NAME = 'emree-instagram-progress-fill';
// Keeps track of videos we've already processed.
const progressBars = new WeakMap();
function getVideoContainer(video) {
/*
* Instagram changes its DOM structure constantly.
* Instead of assuming video.parentElement is always correct,
* walk upward and find a reasonably sized container.
*/
let element = video.parentElement;
for (let i = 0; element && i < 6; i++) {
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
if (
rect.width >= video.clientWidth &&
rect.height >= video.clientHeight &&
style.display !== 'contents'
) {
return element;
}
element = element.parentElement;
}
return video.parentElement;
}
function isValidDuration(video) {
return Number.isFinite(video.duration) && video.duration > 0;
}
function createProgressBar(video) {
if (!(video instanceof HTMLVideoElement)) return;
// Already processed and still connected to the DOM.
const existing = progressBars.get(video);
if (existing && existing.bar.isConnected) {
return;
}
const container = getVideoContainer(video);
if (!container) return;
// Avoid creating duplicate bars inside the same container.
const existingBar = container.querySelector(
`:scope > .${CLASS_NAME}`
);
if (existingBar) {
progressBars.set(video, {
bar: existingBar,
fill: existingBar.querySelector(`.${FILL_CLASS_NAME}`)
});
return;
}
// Make sure the progress bar can be positioned correctly.
const containerStyle = window.getComputedStyle(container);
if (containerStyle.position === 'static') {
container.style.position = 'relative';
}
// Create progress bar.
const progressBar = document.createElement('div');
progressBar.className = CLASS_NAME;
Object.assign(progressBar.style, {
position: 'absolute',
left: '0',
bottom: '5px',
width: '100%',
height: '6px',
background: 'rgba(0, 0, 0, 0.5)',
cursor: 'pointer',
zIndex: '999999',
borderRadius: '999px',
overflow: 'hidden',
touchAction: 'none',
userSelect: 'none'
});
// Create fill.
const progressFill = document.createElement('div');
progressFill.className = FILL_CLASS_NAME;
Object.assign(progressFill.style, {
width: '0%',
height: '100%',
background: '#ff0000',
pointerEvents: 'none',
borderRadius: 'inherit',
willChange: 'width'
});
progressBar.appendChild(progressFill);
container.appendChild(progressBar);
progressBars.set(video, {
bar: progressBar,
fill: progressFill,
container
});
function updateProgress() {
if (!progressBar.isConnected) return;
if (!isValidDuration(video)) {
progressFill.style.width = '0%';
return;
}
const progress = Math.max(
0,
Math.min(100, (video.currentTime / video.duration) * 100)
);
progressFill.style.width = `${progress}%`;
}
// Update on normal playback events.
video.addEventListener('timeupdate', updateProgress, {
passive: true
});
video.addEventListener('loadedmetadata', updateProgress, {
passive: true
});
video.addEventListener('durationchange', updateProgress, {
passive: true
});
video.addEventListener('seeked', updateProgress, {
passive: true
});
// Smooth progress updates.
let animationFrame = null;
function animationLoop() {
if (!video.isConnected || !progressBar.isConnected) {
animationFrame = null;
return;
}
updateProgress();
animationFrame = requestAnimationFrame(animationLoop);
}
animationFrame = requestAnimationFrame(animationLoop);
function seek(event) {
if (!isValidDuration(video)) return;
const rect = progressBar.getBoundingClientRect();
if (!rect.width) return;
const position = Math.max(
0,
Math.min(1, (event.clientX - rect.left) / rect.width)
);
try {
video.currentTime = position * video.duration;
updateProgress();
} catch (error) {
// Instagram may temporarily replace the video source.
// Silently ignore invalid seek attempts.
}
}
/*
* Pointer events work with mouse, trackpads and touch input.
* stopPropagation is important because Instagram often attaches
* its own gesture handlers to parent elements.
*/
progressBar.addEventListener('pointerdown', (event) => {
event.preventDefault();
event.stopPropagation();
try {
progressBar.setPointerCapture(event.pointerId);
} catch (error) {}
seek(event);
});
progressBar.addEventListener('pointermove', (event) => {
if (event.buttons === 1 || event.pressure > 0) {
event.preventDefault();
event.stopPropagation();
seek(event);
}
});
progressBar.addEventListener('pointerup', (event) => {
event.preventDefault();
event.stopPropagation();
seek(event);
try {
progressBar.releasePointerCapture(event.pointerId);
} catch (error) {}
});
// Prevent Instagram click handlers from interfering.
['click', 'dblclick'].forEach((eventName) => {
progressBar.addEventListener(eventName, (event) => {
event.stopPropagation();
});
});
updateProgress();
}
function scanForVideos(root = document) {
if (root instanceof HTMLVideoElement) {
createProgressBar(root);
}
if (root.querySelectorAll) {
root.querySelectorAll('video').forEach(createProgressBar);
}
}
/*
* Instagram is a single-page app and dynamically replaces content.
* Continuously scan added DOM nodes for new videos.
*/
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof Element)) continue;
scanForVideos(node);
}
}
});
function start() {
scanForVideos();
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
// Backup scan for cases where Instagram reuses an existing DOM node.
setInterval(() => {
scanForVideos();
}, 2000);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start, {
once: true
});
} else {
start();
}
})();