// ==UserScript==
// @name Add Instagram Video Progressbar (Improved)
// @namespace https://greasyfork.org/en/users/1521486-budget2540
// @version 2.0.2
// @license GNU AGPLv3
// @author budget2540
// @description Improved fork of "Add Instagram Video Progressbar" (original: jcunews). Adds keyboard seek & focus fixes, throttled pointer handling for better perf, robust DOM cleanup, persistent progressbar-height setting with menu commands, and improved unmute/story handling.
// @match *://www.instagram.com/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
//===== CONFIGURATION BEGIN =====
const CONFIG = {
progressbar: {
height: 6, // in pixels. set to zero to hide
color: '#fff', // background color
elapsedColor: '#f00', // elapsed time color
opacity: 0.66, // progressbar opacity
hudOpacity: 0.95 // HUD tooltip opacity
},
video: {
disableLoop: true, // disable video looping
unmute: true // automatically unmute videos
},
keyboard: {
seekStep: 3 // seconds to seek per arrow key press (default)
},
ui: {
updateInterval: 100, // milliseconds between progress updates
hudFadeDelay: 250, // milliseconds before HUD fades out
}
};
//===== CONFIGURATION END =====
// ===== CORE STATE MANAGEMENT =====
const State = {
videoElementMap: new WeakMap(), // maps video elements to their elapsed bar elements
containerMap: new Map(), // tracks all progressbar containers
activeVideo: null, // currently focused/hovered video for keyboard controls
keyHandlerInstalled: false, // global keydown handler flag
globalPointerHandlerInstalled: false, // global pointermove handler flag
lastHoveredContainer: null, // last hovered container for optimization
pointerMoveScheduled: false, // rAF throttling flag
lastPointerEvent: null, // cached pointer event
domObserver: null, // MutationObserver for cleanup
videoObserver: null, // MutationObserver for new videos
originalAddEventListener: null, // original HTMLVideoElement.addEventListener
scrollHandlerInstalled: false, // scroll handler flag
scrollUpdateScheduled: false, // throttling flag for scroll updates
nextButton: null,
loopObservers: new WeakMap() // per-video mutation observers to guard against loop attr re-adding
};
// ===== UTILITY FUNCTIONS =====
const Utils = {
// Persistent settings with GM_getValue/GM_setValue fallback
getProgressbarHeight() {
try {
if (typeof GM_getValue === 'function') {
return Number(GM_getValue('aivp_height', CONFIG.progressbar.height));
}
} catch (ex) {
console.warn('[AIVP] Failed to get height from storage:', ex);
}
return CONFIG.progressbar.height;
},
setProgressbarHeight(height) {
try {
if (typeof GM_setValue === 'function') {
GM_setValue('aivp_height', height);
}
} catch (ex) {
console.warn('[AIVP] Failed to save height to storage:', ex);
}
CONFIG.progressbar.height = height;
this.applyHeightToExisting(height);
},
applyHeightToExisting(height) {
const progressHeight = Number(height);
const hitAreaHeight = progressHeight;
document.querySelectorAll('div[id^="aivp"]').forEach(container => {
try {
container.style.height = `${hitAreaHeight}px`;
const progressBar = container.querySelector('.aivp-elapsed');
if (progressBar) {
progressBar.style.height = `${progressHeight}px`;
}
const background = container.querySelector('.aivp-bg');
if (background) {
background.style.height = `${progressHeight}px`;
}
const hud = container.querySelector('.aivp-hud');
if (hud) {
hud.style.bottom = `${progressHeight + 6}px`;
}
const leftPreview = container.querySelector('.aivp-left');
if (leftPreview) {
leftPreview.style.bottom = `${progressHeight + 6}px`;
}
} catch (ex) {
console.warn('[AIVP] Failed to update container height:', ex);
}
});
},
// Format seconds to HH:MM:SS or MM:SS
formatTime(seconds) {
if (!isFinite(seconds) || seconds < 0) return '0:00';
const totalSeconds = Math.floor(seconds);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const secs = totalSeconds % 60;
const minutesStr = (minutes < 10 && hours > 0) ? `0${minutes}` : String(minutes);
const secondsStr = secs < 10 ? `0${secs}` : String(secs);
return hours > 0
? `${hours}:${minutesStr}:${secondsStr}`
: `${minutesStr}:${secondsStr}`;
},
// Generate unique ID for containers
generateId() {
return `aivp${Date.now()}`;
},
// Safe element query
safeQuerySelector(element, selector) {
try {
return element?.querySelector(selector);
} catch (ex) {
return null;
}
},
// Safe element query all
safeQuerySelectorAll(element, selector) {
try {
return element?.querySelectorAll(selector) || [];
} catch (ex) {
return [];
}
},
// Persistent seek-step (seconds) with GM_getValue/GM_setValue fallback
getSeekStep() {
try {
if (typeof GM_getValue === 'function') {
const v = Number(GM_getValue('aivp_seek_step', CONFIG.keyboard.seekStep));
if (isFinite(v) && v > 0) return v;
}
} catch (ex) {
console.warn('[AIVP] Failed to get seek step from storage:', ex);
}
return CONFIG.keyboard.seekStep;
},
setSeekStep(seconds) {
try {
const s = Number(seconds);
if (!isFinite(s) || s <= 0) return;
if (typeof GM_setValue === 'function') {
GM_setValue('aivp_seek_step', s);
}
CONFIG.keyboard.seekStep = s;
} catch (ex) {
console.warn('[AIVP] Failed to save seek step to storage:', ex);
}
},
// Debugging aid: log structured data
logData(label, data) {
console.log(`[AIVP] ${label}:`, JSON.stringify(data, null, 2));
}
};
// ===== MENU COMMANDS =====
const MenuCommands = {
register() {
try {
if (typeof GM_registerMenuCommand !== 'function') return;
GM_registerMenuCommand('Set AIVP progressbar height (px)', () => {
const current = Utils.getProgressbarHeight();
const input = prompt('Set progress bar height in pixels (0 to hide):', String(current));
if (input === null) return;
const value = parseInt(input, 10);
if (isNaN(value) || value < 0) {
alert('Invalid height value. Please enter a number >= 0.');
return;
}
Utils.setProgressbarHeight(value);
alert(`Progress bar height set to ${value}px`);
});
GM_registerMenuCommand('Reset AIVP progressbar height to default', () => {
const defaultHeight = 6;
Utils.setProgressbarHeight(defaultHeight);
alert(`Progress bar height reset to default (${defaultHeight}px`);
});
// New commands for seek step configuration
GM_registerMenuCommand('Set AIVP seek step (seconds)', () => {
const current = Utils.getSeekStep();
const input = prompt('Set seek step in seconds (e.g. 0.5, 1.5, 3):', String(current));
if (input === null) return;
const value = parseFloat(input);
if (!isFinite(value) || value <= 0) {
alert('Invalid seek step value. Please enter a number > 0.');
return;
}
Utils.setSeekStep(value);
alert(`Seek step set to ${value} second(s)`);
});
GM_registerMenuCommand('Reset AIVP seek step to default', () => {
const defaultSeek = 3;
Utils.setSeekStep(defaultSeek);
alert(`Seek step reset to default (${defaultSeek} seconds)`);
});
} catch (ex) {
console.warn('[AIVP] Failed to register menu commands:', ex);
}
}
};
// ===== KEYBOARD CONTROLS =====
const KeyboardControls = {
handleKeydown(event) {
// Only react to left/right arrows and when not typing in an input
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
const activeElement = document.activeElement;
if (activeElement && (
activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
activeElement.isContentEditable
)) {
return;
}
const video = State.activeVideo;
if (!video || !isFinite(video.duration) || video.duration === 0) return;
event.preventDefault();
// Use persisted/configured seek step
const step = Utils.getSeekStep();
const delta = (event.key === 'ArrowLeft') ? -step : step;
const newTime = Math.max(0, Math.min(video.duration, video.currentTime + delta));
try {
video.currentTime = newTime;
// Update visual bar immediately
const elapsedBar = State.videoElementMap.get(video);
if (elapsedBar && elapsedBar.parentNode) {
const container = elapsedBar.parentNode;
const width = container.offsetWidth || 1;
elapsedBar.style.width = `${Math.ceil((newTime / video.duration) * width)}px`;
}
} catch (ex) {
console.warn('[AIVP] Failed to seek video:', ex);
}
},
install() {
if (State.keyHandlerInstalled) return;
document.addEventListener('keydown', this.handleKeydown.bind(this), false);
State.keyHandlerInstalled = true;
}
};
// ===== POINTER/HOVER MANAGEMENT =====
const PointerManager = {
handleGlobalPointerMove(event) {
// Only capture and schedule; actual processing happens in rAF to avoid running at full pointer frequency
State.lastPointerEvent = event;
if (!State.pointerMoveScheduled) {
State.pointerMoveScheduled = true;
requestAnimationFrame(() => this.processPointerMove());
}
},
processPointerMove() {
State.pointerMoveScheduled = false;
const event = State.lastPointerEvent;
State.lastPointerEvent = null;
if (!event) return;
try {
const targetContainer = event.target?.closest?.('[id^="aivp"]');
// Nothing changed, skip
if (targetContainer === State.lastHoveredContainer) return;
// Hide previous hovered container (if not dragging)
if (State.lastHoveredContainer) {
const prevState = State.containerMap.get(State.lastHoveredContainer);
if (prevState && !prevState.dragging && prevState.hideHudDelayed) {
prevState.hideHudDelayed(150);
}
}
// Show new container if any
if (targetContainer) {
const newState = State.containerMap.get(targetContainer);
if (newState?.showHud) {
newState.showHud();
}
State.lastHoveredContainer = targetContainer;
} else {
State.lastHoveredContainer = null;
}
} catch (ex) {
console.warn('[AIVP] Error processing pointer move:', ex);
}
},
install() {
if (State.globalPointerHandlerInstalled) return;
try {
const handler = (e) => this.handleGlobalPointerMove(e);
document.addEventListener('pointermove', handler, { passive: true });
State.globalPointerHandlerInstalled = true;
State.pointerHandler = handler;
} catch (ex) {
console.warn('[AIVP] Failed to install pointer handler:', ex);
}
},
uninstall() {
if (!State.globalPointerHandlerInstalled) return;
try {
if (State.pointerHandler) {
document.removeEventListener('pointermove', State.pointerHandler, { passive: true });
}
State.globalPointerHandlerInstalled = false;
State.lastHoveredContainer = null;
} catch (ex) {
console.warn('[AIVP] Failed to uninstall pointer handler:', ex);
}
}
};
// ===== CONTAINER LIFECYCLE MANAGEMENT =====
const ContainerManager = {
register(container, state) {
State.containerMap.set(container, state);
// Install pointer handler once
PointerManager.install();
// Ensure DOM observer is running to clean up detached containers
this.ensureDOMObserver();
},
unregister(container) {
try {
State.containerMap.delete(container);
} catch (ex) {
console.warn('[AIVP] Failed to unregister container:', ex);
}
// If no containers remain, cleanup
if (State.containerMap.size === 0) {
this.cleanup();
}
},
ensureDOMObserver() {
if (State.domObserver) return;
try {
State.domObserver = new MutationObserver(() => {
this.cleanupDetachedContainers();
});
const root = document.documentElement || document.body || document;
State.domObserver.observe(root, { childList: true, subtree: true });
} catch (ex) {
console.warn('[AIVP] Failed to create DOM observer:', ex);
}
},
cleanupDetachedContainers() {
try {
for (const [container, state] of State.containerMap.entries()) {
if (!document.contains(container)) {
// Call optional cleanup helper on state if provided
if (state?.cleanup) {
try {
state.cleanup();
} catch (ex) {
console.warn('[AIVP] Error in container cleanup:', ex);
}
}
State.containerMap.delete(container);
}
}
// If no containers remain, cleanup observers and handlers
if (State.containerMap.size === 0) {
this.cleanup();
}
} catch (ex) {
console.warn('[AIVP] Error cleaning up detached containers:', ex);
}
},
cleanup() {
try {
if (State.domObserver) {
State.domObserver.disconnect();
State.domObserver = null;
}
PointerManager.uninstall();
ScrollManager.uninstall();
} catch (ex) {
console.warn('[AIVP] Error during cleanup:', ex);
}
}
};
// ===== VIDEO INTERACTION SETUP =====
const VideoInteraction = {
setupVideoFocus(video, container) {
// Make the video/container the active target when hovered or focused
try {
container.tabIndex = 0;
video.tabIndex = 0;
} catch (ex) {
console.warn('[AIVP] Failed to set tabIndex:', ex);
}
const setActive = () => { State.activeVideo = video; };
const clearActive = () => { if (State.activeVideo === video) State.activeVideo = null; };
container.addEventListener('mouseenter', setActive);
container.addEventListener('mouseleave', clearActive);
container.addEventListener('focus', setActive, true);
container.addEventListener('blur', clearActive, true);
video.addEventListener('mouseenter', setActive);
video.addEventListener('mouseleave', clearActive);
},
setupSeekableProgressbar(video, container, elapsedBar) {
const state = {
dragging: false,
hudFadeTimeout: 0
};
let wasPlaying = false;
const hud = container.querySelector('.aivp-hud');
const backgroundBar = container.querySelector('.aivp-bg');
const leftPreview = container.querySelector('.aivp-left');
// HUD management functions
const showHud = () => {
if (!hud) return;
if (state.hudFadeTimeout) {
clearTimeout(state.hudFadeTimeout);
state.hudFadeTimeout = 0;
}
hud.style.opacity = CONFIG.progressbar.hudOpacity;
if (leftPreview) leftPreview.style.opacity = 1;
};
const hideHudDelayed = (delay) => {
if (!hud) return;
if (state.hudFadeTimeout) clearTimeout(state.hudFadeTimeout);
state.hudFadeTimeout = setTimeout(() => {
if (hud) hud.style.opacity = 0;
if (leftPreview) leftPreview.style.opacity = 0;
state.hudFadeTimeout = 0;
}, delay || CONFIG.ui.hudFadeDelay);
};
const updateHudPosition = (percent) => {
if (!hud) return;
const x = Math.ceil(percent * container.offsetWidth);
hud.style.left = `${x}px`;
if (leftPreview) leftPreview.style.left = `${x}px`;
showHud();
};
const seekAtClientX = (clientX) => {
const rect = container.getBoundingClientRect();
const x = clientX - rect.left;
const w = rect.width || container.offsetWidth || 1;
const percent = Math.max(0, Math.min(1, x / w));
if (!isFinite(video.duration) || video.duration === 0) return;
// Apply seek and update UI immediately
video.currentTime = percent * video.duration;
elapsedBar.style.width = `${Math.ceil(percent * container.offsetWidth)}px`;
// Update HUD with formatted time and position
if (hud) {
hud.textContent = Utils.formatTime(video.currentTime);
updateHudPosition(percent);
}
if (leftPreview && isFinite(video.duration) && video.duration > 0) {
leftPreview.textContent = `${Utils.formatTime(video.currentTime)} / ${Utils.formatTime(video.duration)}`;
leftPreview.style.opacity = 1;
}
};
// Event handlers
const onClick = (e) => {
e.stopPropagation();
e.preventDefault();
seekAtClientX(e.clientX);
};
const onMouseDown = (e) => {
e.stopPropagation();
e.preventDefault();
state.dragging = true;
wasPlaying = !video.paused;
seekAtClientX(e.clientX);
document.addEventListener('mousemove', onMouseMove, { passive: false });
document.addEventListener('mouseup', onMouseUp, { passive: false });
showHud();
};
const onMouseMove = (e) => {
if (!state.dragging) return;
e.preventDefault();
seekAtClientX(e.clientX);
};
const onMouseUp = (e) => {
if (!state.dragging) return;
e.preventDefault();
seekAtClientX(e.clientX);
state.dragging = false;
document.removeEventListener('mousemove', onMouseMove, { passive: false });
document.removeEventListener('mouseup', onMouseUp, { passive: false });
if (wasPlaying) {
try { video.play(); } catch (ex) { /* ignore */ }
}
hideHudDelayed(300);
};
// Show HUD on hover and update position
container.addEventListener('mousemove', (e) => {
if (state.dragging) return;
const rect = container.getBoundingClientRect();
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / (rect.width || 1)));
if (hud && video && isFinite(video.duration) && video.duration > 0) {
hud.textContent = Utils.formatTime(percent * video.duration);
updateHudPosition(percent);
}
if (leftPreview && isFinite(video.duration) && video.duration > 0) {
leftPreview.textContent = `${Utils.formatTime(percent * video.duration)} / ${Utils.formatTime(video.duration)}`;
leftPreview.style.opacity = 1;
}
showHud();
});
container.addEventListener('mouseleave', () => {
if (!state.dragging) hideHudDelayed(200);
});
// Wire up events on the progressbar container
container.addEventListener('click', onClick);
container.addEventListener('mousedown', onMouseDown, { passive: false });
// Register this container's state
state.showHud = showHud;
state.hideHudDelayed = hideHudDelayed;
state.hud = hud;
state.leftPreview = leftPreview;
state.cleanup = () => {
try {
if (state.hud && state.hud.parentNode) state.hud.style.opacity = 0;
} catch (ex) { /* ignore */ }
// Disconnect any per-video loop observer attached earlier
try {
const obs = State.loopObservers.get(video);
if (obs) {
obs.disconnect();
State.loopObservers.delete(video);
}
} catch (ex) { /* ignore */ }
// Remove any temporary markers on the video
try {
if (video && video.removeEventListener) {
// We do not track individual handlers here beyond the MutationObserver
}
} catch (ex) { /* ignore */ }
};
ContainerManager.register(container, state);
}
};
// ===== PROGRESSBAR CREATION AND SETUP =====
const ProgressbarSetup = {
// Main setup function called for each video
setupVideo(video) {
if (!video || !video.parentNode) return;
// Handle video loop disabling
if (CONFIG.video.disableLoop && !video.attributes.noloop) {
this.disableLoopForVideo(video);
}
// Create progressbar UI
const { container, elapsedBar } = this.createProgressbarUI(video);
// Map video to its elapsed bar element
State.videoElementMap.set(video, elapsedBar);
// Attach the progressbar to the video's parent to avoid layout issues in some feeds
const parent = video.parentNode;
try {
const computed = parent && parent.nodeType === 1 ? getComputedStyle(parent) : null;
if (computed && computed.position === 'static') {
parent.style.position = 'relative';
}
} catch (ex) { /* ignore */ }
// Ensure the container sits above most IG overlays
try { container.style.zIndex = '2147483647'; } catch (ex) { /* ignore */ }
if (parent && parent.appendChild) {
parent.appendChild(container);
}
// Setup interactions and controls
VideoInteraction.setupVideoFocus(video, container);
VideoInteraction.setupSeekableProgressbar(video, container, elapsedBar);
this.setupVideoEventHandlers(video, elapsedBar);
// Unmute if configured
if (CONFIG.video.unmute && video.muted) {
this.unmuteVideo(video);
}
},
disableLoopForVideo(video) {
try {
// Find next button for stories
State.nextButton = video.parentNode.parentNode?.parentNode?.parentNode?.lastElementChild;
video.setAttribute('noloop', '');
// Immediately ensure the loop attribute/property is removed
try {
video.loop = false;
video.removeAttribute('loop');
} catch (ex) {
// ignore setting loop if browser prevents it
}
// Disconnect previously registered observer if existing
try {
const prevObs = State.loopObservers.get(video);
if (prevObs) {
prevObs.disconnect();
State.loopObservers.delete(video);
}
} catch (ex) {
/* ignore */
}
// Observe the video element for any attempts to re-add the loop attribute
try {
const attrObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'loop') {
try {
// Remove the attribute and ensure property is false
if (video.hasAttribute('loop')) video.removeAttribute('loop');
if (video.loop) video.loop = false;
} catch (ex) {
/* ignore */
}
}
}
});
attrObserver.observe(video, { attributes: true, attributeFilter: ['loop'] });
State.loopObservers.set(video, attrObserver);
} catch (ex) {
console.warn('[AIVP] Failed to observe video loop attribute:', ex);
}
// As a final mitigation, ensure that when the video plays we re-assert loop=false
const ensureNoLoopOnPlay = () => {
try {
if (video.loop) video.loop = false;
if (video.hasAttribute('loop')) video.removeAttribute('loop');
} catch (ex) { /* ignore */ }
};
video.addEventListener('play', ensureNoLoopOnPlay);
// Attach a short timeout to enforce it immediately in case the attribute is set shortly after
setTimeout(() => {
try {
if (video.loop) video.loop = false;
if (video.hasAttribute('loop')) video.removeAttribute('loop');
} catch (ex) { /* ignore */ }
}, 50);
// Find and fix play/pause buttons
const roleElements = Utils.safeQuerySelectorAll(video.parentNode.parentNode, 'div[role]');
roleElements.forEach(element => {
Object.keys(element).some(key => {
if (key.startsWith('__reactProps$')) {
const props = element[key];
if (props?.onClick && String(props.onClick).includes('pause')) {
element.addEventListener('click', () => {
if (video.paused) video.play();
});
return true;
}
}
return false;
});
});
} catch (ex) {
console.warn('[AIVP] Error disabling video loop:', ex);
}
},
createProgressbarUI(video) {
const containerId = Utils.generateId();
const elapsedBarId = `${containerId}bar`;
const progressHeight = Utils.getProgressbarHeight();
const hitAreaHeight = progressHeight;
const container = document.createElement('div');
container.id = containerId;
container.innerHTML = `<style>
#${containerId} {
position: absolute;
opacity: ${CONFIG.progressbar.opacity};
left: 0;
right: 0;
bottom: 0;
height: ${hitAreaHeight}px;
background: transparent;
cursor: pointer;
z-index: 9999;
}
#${elapsedBarId} {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: ${progressHeight}px;
width: 0;
transition: width 100ms linear;
background: ${CONFIG.progressbar.elapsedColor};
}
.aivp-bg {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: ${progressHeight}px;
background: ${CONFIG.progressbar.color};
opacity: 0.25;
}
.aivp-hud {
position: absolute;
left: 0;
transform: translateX(-50%);
bottom: ${progressHeight + 6}px;
background: rgba(0,0,0,0.75);
color: #fff;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
pointer-events: none;
white-space: nowrap;
opacity: 0;
transition: opacity 120ms;
z-index: 10000;
}
.aivp-left {
position: absolute;
left: 0;
transform: translateX(-50%);
bottom: ${progressHeight + 6}px;
background: rgba(0,0,0,0.75);
color: #fff;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
pointer-events: none;
white-space: nowrap;
opacity: 0;
transition: opacity 120ms;
z-index: 10000;
}
</style>
<div class="aivp-bg"></div>
<div id="${elapsedBarId}" class="aivp-elapsed"></div>
<div class="aivp-hud">0:00</div>
<div class="aivp-left">0:00 / 0:00</div>`;
const elapsedBar = container.querySelector(`#${elapsedBarId}`);
return { container, elapsedBar };
},
setupVideoEventHandlers(video, elapsedBar) {
let updateTimer = null;
const updateProgressBar = () => {
if (!isFinite(video.duration) || video.duration === 0) return;
const container = elapsedBar.parentNode;
if (!container) return;
const width = container.offsetWidth || 1;
elapsedBar.style.width = `${Math.ceil((video.currentTime / video.duration) * width)}px`;
};
const startTimer = () => {
// Set this video as the active video for keyboard controls
State.activeVideo = video;
if (CONFIG.video.disableLoop) {
try { video.loop = false; } catch (ex) { /* ignore */ }
}
if (!updateTimer) {
updateTimer = setInterval(updateProgressBar, CONFIG.ui.updateInterval);
}
};
const stopTimer = (event) => {
if (event.type === 'ended') {
try {
// Ensure video does not restart due to loop being set
if (CONFIG.video.disableLoop) {
try { video.pause(); } catch (e) { /* ignore */ }
try { video.loop = false; } catch (e) { /* ignore */ }
try { video.removeAttribute && video.removeAttribute('loop'); } catch (e) { /* ignore */ }
}
elapsedBar.style.width = '100%';
// Advance story/next if available but allow UI to settle briefly
if (CONFIG.video.disableLoop && State.nextButton) {
setTimeout(() => {
try { State.nextButton.click(); } catch (ex) { /* ignore */ }
}, 80);
}
} catch (ex) {
console.warn('[AIVP] Error handling ended event:', ex);
}
}
// Update left preview when timer stopped
try {
const container = elapsedBar.parentNode;
const leftPreview = container?.querySelector('.aivp-left');
if (leftPreview) {
leftPreview.textContent = `${Utils.formatTime(video.currentTime)} / ${Utils.formatTime(video.duration)}`;
}
} catch (ex) { /* ignore */ }
if (updateTimer) {
clearInterval(updateTimer);
updateTimer = null;
}
};
video.addEventListener('play', startTimer);
video.addEventListener('playing', startTimer);
video.addEventListener('waiting', stopTimer);
video.addEventListener('pause', stopTimer);
video.addEventListener('ended', stopTimer);
},
unmuteVideo(video) {
try {
if (location.pathname.startsWith('/stories/')) {
const storyContainer = video.closest('div[style*="width"]')
?.parentNode?.closest('div[style*="width"]')
?.parentNode?.closest('div[style*="width"]');
const muteButton = storyContainer?.querySelector('div[aria-label="Toggle audio"]');
if (muteButton) muteButton.click();
} else {
const buttons = Utils.safeQuerySelectorAll(video.parentNode.parentNode, 'button');
buttons.forEach(button => {
Object.keys(button).some(key => {
if (key.startsWith('__reactProps$')) {
const props = button[key];
if (props?.onClick && String(props.onClick).includes('AUDIO_STATES')) {
button.click();
return true;
}
}
return false;
});
});
}
} catch (ex) {
console.warn('[AIVP] Failed to unmute video:', ex);
}
}
};
// ===== VIDEO DISCOVERY AND MONITORING =====
const VideoDiscovery = {
ensureVideoHasProgressbar(video) {
try {
if (!video || String(video.tagName).toLowerCase() !== 'video') return;
// Check if already initialized
if (video.getAttribute('aivp_done')) {
// If visual elements aren't created yet, and the video is ready, call setup now
if (!State.videoElementMap.get(video) && video.readyState >= 2) {
try {
ProgressbarSetup.setupVideo(video);
} catch (ex) {
console.warn('[AIVP] Error setting up video:', ex);
}
}
return;
}
// Mark as initialized
video.setAttribute('aivp_done', '1');
// If the video is already in a "can play" state, call setup immediately
if (video.readyState >= 2) {
try {
ProgressbarSetup.setupVideo(video);
} catch (ex) {
console.warn('[AIVP] Error setting up video:', ex);
}
} else {
// Wait for canplay event
video.addEventListener('canplay', function onCanPlay() {
try {
ProgressbarSetup.setupVideo(video);
} catch (ex) {
console.warn('[AIVP] Error setting up video on canplay:', ex);
}
video.removeEventListener('canplay', onCanPlay);
}, { once: true });
}
} catch (ex) {
console.warn('[AIVP] Error ensuring video has progressbar:', ex);
}
},
startMonitoring() {
try {
// Initial scan of existing videos
const existingVideos = document.querySelectorAll('video');
existingVideos.forEach(video => this.ensureVideoHasProgressbar(video));
// Observe DOM for newly inserted video elements
State.videoObserver = new MutationObserver((mutations) => {
try {
for (const mutation of mutations) {
if (mutation.addedNodes && mutation.addedNodes.length) {
for (const node of mutation.addedNodes) {
if (!node) continue;
// If the added node is a video
if (node.tagName && String(node.tagName).toLowerCase() === 'video') {
this.ensureVideoHasProgressbar(node);
} else if (node.querySelectorAll) {
// Find any descendant videos
const descendantVideos = node.querySelectorAll('video');
descendantVideos.forEach(video => this.ensureVideoHasProgressbar(video));
}
}
}
}
} catch (ex) {
console.warn('[AIVP] Error in video observer:', ex);
}
});
// Observe large subtree
const root = document.documentElement || document.body || document;
State.videoObserver.observe(root, { childList: true, subtree: true });
} catch (ex) {
console.warn('[AIVP] Failed to start video monitoring:', ex);
}
}
};
// ===== SCROLL MANAGEMENT =====
const ScrollManager = {
handleScroll() {
// Throttle scroll updates to avoid excessive processing
if (!State.scrollUpdateScheduled) {
State.scrollUpdateScheduled = true;
requestAnimationFrame(() => {
this.updateActiveVideoFromScroll();
State.scrollUpdateScheduled = false;
});
}
},
updateActiveVideoFromScroll() {
try {
// Find all videos with progress bars that are in the document
const videos = Array.from(State.videoElementMap.keys()).filter(video => {
return video && document.contains(video) && video.readyState >= 2;
});
if (videos.length === 0) return;
// Find the video closest to the center of the viewport
const viewportCenter = window.innerHeight / 2;
let closestVideo = null;
let minDistance = Infinity;
for (const video of videos) {
const rect = video.getBoundingClientRect();
// Skip videos that are not visible
if (rect.bottom < 0 || rect.top > window.innerHeight) continue;
const videoCenter = rect.top + rect.height / 2;
const distance = Math.abs(videoCenter - viewportCenter);
if (distance < minDistance) {
minDistance = distance;
closestVideo = video;
}
}
// Set the closest visible video as active
if (closestVideo && closestVideo !== State.activeVideo) {
State.activeVideo = closestVideo;
}
} catch (ex) {
console.warn('[AIVP] Error updating active video from scroll:', ex);
}
},
install() {
if (State.scrollHandlerInstalled) return;
try {
const handler = () => this.handleScroll();
window.addEventListener('scroll', handler, { passive: true });
State.scrollHandlerInstalled = true;
State.scrollHandler = handler;
} catch (ex) {
console.warn('[AIVP] Failed to install scroll handler:', ex);
}
},
uninstall() {
if (!State.scrollHandlerInstalled) return;
try {
if (State.scrollHandler) {
window.removeEventListener('scroll', State.scrollHandler, { passive: true });
}
State.scrollHandlerInstalled = false;
} catch (ex) {
console.warn('[AIVP] Failed to uninstall scroll handler:', ex);
}
}
};
// ===== INITIALIZATION =====
const init = () => {
try {
// Override HTMLVideoElement.prototype.addEventListener to hook into video lifecycle
State.originalAddEventListener = HTMLVideoElement.prototype.addEventListener;
HTMLVideoElement.prototype.addEventListener = function(type, ...args) {
const result = State.originalAddEventListener.apply(this, [type, ...args]);
// Ensure progressbar is added when video starts interacting with events
if (!this.getAttribute('aivp_done')) {
VideoDiscovery.ensureVideoHasProgressbar(this);
}
return result;
};
// Register menu commands
MenuCommands.register();
// Initialize keyboard seek step from storage
try {
CONFIG.keyboard.seekStep = Utils.getSeekStep();
} catch (ex) { /* ignore */ }
// Install keyboard controls
KeyboardControls.install();
// Install scroll handler for active video management
ScrollManager.install();
// Start monitoring for videos
VideoDiscovery.startMonitoring();
// Initial update of active video
ScrollManager.updateActiveVideoFromScroll();
console.log('[AIVP] Instagram Video Progressbar initialized successfully');
} catch (ex) {
console.error('[AIVP] Failed to initialize:', ex);
}
};
// Start initialization
init();
})();