// ==UserScript==
// @name Instagram Video Speed control (YouTube-like)
// @namespace https://yourscripts.example/instagram-speed
// @version 1.2.0
// @description Add YouTube-style speed controls & shortcuts to Instagram videos (feed, reels, profiles).
// @author X0john
// @match https://www.instagram.com/*
// @match https://m.instagram.com/*
// @run-at document-idle
// @grant none
// ==/UserScript==
(function () {
'use strict';
// ----- Config -----
const STEP = 0.25;
const MIN_RATE = 0.1;
const MAX_RATE = 16;
const STORAGE_KEY = 'ig_speed_rate';
const UI_CLASS = 'ig-speed-control';
const UI_ATTR = 'data-ig-speed-bound';
const ACTIVE_ATTR = 'data-ig-active-video';
const TOAST_ID = 'ig-speed-toast';
// ----- Utilities -----
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
const round2 = v => Math.round(v * 100) / 100;
const getSavedRate = () => {
const v = parseFloat(localStorage.getItem(STORAGE_KEY));
return Number.isFinite(v) ? clamp(v, MIN_RATE, MAX_RATE) : 1.0;
};
const saveRate = r => localStorage.setItem(STORAGE_KEY, String(r));
const isTypingInInput = () => {
const a = document.activeElement;
if (!a) return false;
const tag = a.tagName?.toLowerCase();
return (
a.isContentEditable ||
tag === 'input' ||
tag === 'textarea' ||
tag === 'select'
);
};
const createStyle = () => {
if (document.getElementById('ig-speed-style')) return;
const css = `
.${UI_CLASS} {
position: absolute;
bottom: 8px;
left: 8px;
z-index: 99999;
display: inline-flex;
gap: 6px;
align-items: center;
background: rgba(0,0,0,0.55);
backdrop-filter: blur(2px);
border-radius: 14px;
padding: 6px 8px;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
font-size: 12px;
line-height: 1;
color: #fff;
user-select: none;
opacity: 0;
transform: translateY(4px);
transition: opacity .15s ease, transform .15s ease;
pointer-events: auto;
}
video:hover + .${UI_CLASS},
.${UI_CLASS}:hover,
[${ACTIVE_ATTR}="true"] + .${UI_CLASS} {
opacity: 1;
transform: translateY(0);
}
.${UI_CLASS} button {
border: none;
padding: 6px 8px;
border-radius: 10px;
background: rgba(255,255,255,0.12);
color: #fff;
font-weight: 600;
cursor: pointer;
}
.${UI_CLASS} button:active { transform: scale(0.96); }
.${UI_CLASS} .rate {
min-width: 34px;
text-align: center;
font-weight: 700;
letter-spacing: .2px;
}
/* Toast */
#${TOAST_ID}{
position: fixed;
left: 50%;
bottom: 8%;
transform: translateX(-50%);
z-index: 999999;
background: rgba(0,0,0,0.75);
color: #fff;
padding: 10px 14px;
border-radius: 12px;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
font-size: 14px;
pointer-events: none;
opacity: 0;
transition: opacity .12s ease;
}
#${TOAST_ID}.show { opacity: 1; }
`;
const style = document.createElement('style');
style.id = 'ig-speed-style';
style.textContent = css;
document.head.appendChild(style);
};
const showToast = (msg) => {
let t = document.getElementById(TOAST_ID);
if (!t) {
t = document.createElement('div');
t.id = TOAST_ID;
document.body.appendChild(t);
}
t.textContent = msg;
t.classList.add('show');
clearTimeout(showToast._timer);
showToast._timer = setTimeout(() => t.classList.remove('show'), 700);
};
// Keep track of current "active" video (last interacted/visible)
let globalRate = getSavedRate();
let lastActiveVideo = null;
const setVideoRate = (video, rate, {silent=false} = {}) => {
const r = clamp(round2(rate), MIN_RATE, MAX_RATE);
if (!video) return;
if (video.playbackRate !== r) video.playbackRate = r;
const ui = video.nextSibling;
if (ui?.classList?.contains(UI_CLASS)) {
const rateEl = ui.querySelector('.rate');
if (rateEl) rateEl.textContent = `${r}×`;
}
if (!silent) showToast(`${r}×`);
};
const setGlobalRate = (rate, opts={}) => {
const r = clamp(round2(rate), MIN_RATE, MAX_RATE);
globalRate = r;
saveRate(r);
// Apply to all videos currently in DOM
document.querySelectorAll('video').forEach(v => setVideoRate(v, r, {silent:true}));
if (!opts.silent) showToast(`${r}×`);
};
const buildUI = (video) => {
if (!video || video[UI_ATTR]) return;
// Some IG containers don't have position context; insert right after video and rely on :hover video + UI
const ui = document.createElement('div');
ui.className = UI_CLASS;
ui.innerHTML = `
<button class="dec" title="Slow down (Shift+,)">–</button>
<div class="rate" title="Current speed">${round2(video.playbackRate || globalRate)}×</div>
<button class="inc" title="Speed up (Shift+.)">+</button>
<button class="reset" title="Reset speed (Shift+Backspace)">1x</button>
`;
const handler = (delta) => {
const current = video.playbackRate || 1.0;
setVideoRate(video, current + delta);
setGlobalRate(current + delta, {silent:true});
};
ui.querySelector('.dec').addEventListener('click', e => { e.stopPropagation(); handler(-STEP); });
ui.querySelector('.inc').addEventListener('click', e => { e.stopPropagation(); handler(+STEP); });
ui.querySelector('.reset').addEventListener('click', e => { e.stopPropagation(); setVideoRate(video, 1.0); setGlobalRate(1.0, {silent:true}); });
// Scroll to change speed when hovering the control
ui.addEventListener('wheel', (e) => {
e.preventDefault();
const dir = e.deltaY > 0 ? -STEP : +STEP;
handler(dir);
}, { passive: false });
// mark as bound
video.setAttribute(UI_ATTR, '1');
// mark active video on hover/focus/play
const markActive = () => {
if (lastActiveVideo && lastActiveVideo !== video) {
lastActiveVideo.removeAttribute(ACTIVE_ATTR);
}
lastActiveVideo = video;
video.setAttribute(ACTIVE_ATTR, 'true');
};
['mouseenter','play','pointerdown','touchstart','focus'].forEach(ev =>
video.addEventListener(ev, markActive, { passive: true })
);
// Insert right after the video so CSS sibling selector works
video.parentElement?.insertBefore(ui, video.nextSibling);
// Ensure initial rate reflects global preference
setVideoRate(video, video.playbackRate || globalRate, {silent:true});
};
const bindVideo = (video) => {
if (!(video instanceof HTMLVideoElement)) return;
// Skip tiny/hidden placeholders
const rect = video.getBoundingClientRect();
if (rect.width < 60 || rect.height < 60) {
// still bind so it updates when sized later
}
// Apply saved/global rate
video.playbackRate = globalRate;
// Add UI overlay
buildUI(video);
// When a video starts playing, sync rate and mark active
video.addEventListener('play', () => {
setVideoRate(video, globalRate, {silent:true});
if (lastActiveVideo && lastActiveVideo !== video) {
lastActiveVideo.removeAttribute(ACTIVE_ATTR);
}
lastActiveVideo = video;
video.setAttribute(ACTIVE_ATTR, 'true');
}, { passive: true });
// If IG swaps the source, re-apply speed
video.addEventListener('loadeddata', () => setVideoRate(video, globalRate, {silent:true}), { passive: true });
};
const scan = (root = document) => {
root.querySelectorAll('video').forEach(bindVideo);
};
const observe = () => {
const mo = new MutationObserver((muts) => {
for (const m of muts) {
if (m.type === 'childList') {
m.addedNodes.forEach(n => {
if (n.nodeType !== 1) return;
if (n.tagName === 'VIDEO') {
bindVideo(n);
} else {
// search within
const vids = n.querySelectorAll?.('video');
vids?.forEach(bindVideo);
}
});
} else if (m.type === 'attributes' && m.target?.tagName === 'VIDEO') {
// Re-apply if IG toggles attributes/sources
const v = m.target;
setVideoRate(v, globalRate, {silent:true});
}
}
});
mo.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src', 'poster']
});
};
const onKeydown = (e) => {
// Match YouTube: Shift + . (faster), Shift + , (slower)
if (isTypingInInput()) return;
// Some IG overlays capture keys; use capture to intercept earlier via addEventListener below.
if (e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
const key = e.key;
if (key === '.' || key === '>') {
e.preventDefault();
setGlobalRate(globalRate + STEP);
return;
}
if (key === ',' || key === '<') {
e.preventDefault();
setGlobalRate(globalRate - STEP);
return;
}
if (key === 'Backspace') {
e.preventDefault();
setGlobalRate(1.0);
return;
}
}
};
// ----- Init -----
const init = () => {
createStyle();
globalRate = getSavedRate();
scan();
observe();
// Capture early in the chain
window.addEventListener('keydown', onKeydown, true);
// Heuristic: keep the "most visible" video as active
const visObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const v = entry.target;
if (entry.isIntersecting && entry.intersectionRatio > 0.6) {
if (lastActiveVideo && lastActiveVideo !== v) {
lastActiveVideo.removeAttribute(ACTIVE_ATTR);
}
lastActiveVideo = v;
v.setAttribute(ACTIVE_ATTR, 'true');
setVideoRate(v, globalRate, {silent:true});
}
});
}, { threshold: [0.6] });
document.querySelectorAll('video').forEach(v => visObserver.observe(v));
// Periodic safety net in case IG tears down nodes aggressively
setInterval(() => {
document.querySelectorAll('video').forEach(v => {
if (!v.hasAttribute(UI_ATTR)) bindVideo(v);
if (Math.abs((v.playbackRate || 1) - globalRate) > 0.001) {
setVideoRate(v, globalRate, {silent:true});
}
});
}, 2000);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();