Instagram Fix

Instagram progress bar — click/drag to seek, with sound, loop, and height controls.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Instagram Fix
// @namespace    https://greasyfork.org/en/users/1521486-budget2540
// @version      3.2.3
// @license      GNU AGPLv3
// @author       budget2540
// @description  Instagram progress bar — click/drag to seek, with sound, loop, and height controls.
// @match        *://www.instagram.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @run-at       document-start
// ==/UserScript==

(function() {
  'use strict';

  const CONFIG = {
    progressbar: {
      height: 6,
      color: '#fff',
      elapsedColor: '#f00',
      opacity: 0.66
    },
    video: {
      disableLoop: true,
      unmute: true
    },
    ui: {
      updateInterval: 100,
      showLength: true
    }
  };

  const State = {
    videoElementMap: new Map(),
    containerMap: new Map(),
    domObserver: null,
    videoObserver: null,
    seekDispatcherInstalled: false,
    soundSyncDispatcherInstalled: false
  };

  const Utils = {
    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 = Math.max(16, progressHeight * 3);
      for (const container of State.containerMap.keys()) {
        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 leftPreview = container.querySelector('.aivp-left');
          if (leftPreview) leftPreview.style.bottom = `${progressHeight + 6}px`;
        } catch (ex) {
          console.warn('[AIVP] Failed to update container height:', ex);
        }
      }
    },
    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}`;
    },
    generateId() {
      return `aivp${Date.now()}${Math.floor(Math.random()*1000)}`;
    },
    safeQuerySelector(element, selector) {
      try {
        return element?.querySelector(selector);
      } catch (ex) {
        return null;
      }
    },
    safeQuerySelectorAll(element, selector) {
      try {
        return element?.querySelectorAll(selector) || [];
      } catch (ex) {
        return [];
      }
    },
    getLoopEnabled() {
      try {
        if (typeof GM_getValue === 'function') {
          const v = GM_getValue('aivp_loop', null);
          if (v !== null && v !== undefined) return !!v;
        }
      } catch (ex) {
        console.warn('[AIVP] Failed to get loop from storage:', ex);
      }
      return false;
    },
    setLoopEnabled(enabled) {
      const val = !!enabled;
      try {
        if (typeof GM_setValue === 'function') GM_setValue('aivp_loop', val);
      } catch (ex) {
        console.warn('[AIVP] Failed to save loop to storage:', ex);
      }
      CONFIG.video.disableLoop = !val;
      this.applyLoopToExisting(val);
    },
    applyLoopToExisting(enabled) {
      try {
        for (const video of State.videoElementMap.keys()) {
          if (!video || !document.contains(video)) { try { State.videoElementMap.delete(video); } catch (e) {} continue; }
          try {
            if (enabled) {
              video.loop = true;
              try { video.setAttribute('loop', ''); } catch (e) {}
            } else {
              video.loop = false;
              try { video.removeAttribute('loop'); } catch (e) {}
            }
          } catch (ex) { /* ignore per-video */ }
        }
      } catch (ex) {
        console.warn('[AIVP] Failed to apply loop to existing:', ex);
      }
    },
    getSoundEnabled() {
      try {
        if (typeof GM_getValue === 'function') {
          const v = GM_getValue('aivp_sound', null);
          if (v !== null && v !== undefined) return !!v;
        }
      } catch (ex) {
        console.warn('[AIVP] Failed to get sound from storage:', ex);
      }
      return true;
    },
    setSoundEnabled(enabled) {
      const val = !!enabled;
      try {
        if (typeof GM_setValue === 'function') GM_setValue('aivp_sound', val);
      } catch (ex) {
        console.warn('[AIVP] Failed to save sound to storage:', ex);
      }
      CONFIG.video.unmute = val;
      this.applySoundToExisting(val);
      try {
        const sw = SettingsPanel.panel && SettingsPanel.panel.querySelector ? SettingsPanel.panel.querySelector('#aivp-sw-sound') : null;
        if (sw) sw.checked = val;
      } catch (e) {}
    },
    applySoundToExisting(enabled) {
      try {
        for (const video of State.videoElementMap.keys()) {
          if (!video || !document.contains(video)) { try { State.videoElementMap.delete(video); } catch (e) {} continue; }
          try {
            try { ProgressbarSetup.setVideoSound(video, enabled); } catch (e) { /* ignore */ }
          } catch (ex) { /* ignore per-video */ }
        }
      } catch (ex) {
        console.warn('[AIVP] Failed to apply sound to existing:', ex);
      }
    },
    getShowLength() {
      try {
        if (typeof GM_getValue === 'function') {
          const v = GM_getValue('aivp_showLength', null);
          if (v !== null && v !== undefined) return !!v;
        }
      } catch (ex) {
        console.warn('[AIVP] Failed to get showLength from storage:', ex);
      }
      return true;
    },
    setShowLength(enabled) {
      const val = !!enabled;
      try {
        if (typeof GM_setValue === 'function') GM_setValue('aivp_showLength', val);
      } catch (ex) {
        console.warn('[AIVP] Failed to save showLength to storage:', ex);
      }
      CONFIG.ui.showLength = val;
      this.applyShowLengthToExisting(val);
    },
    applyShowLengthToExisting(enabled) {
      try {
        for (const container of State.containerMap.keys()) {
          try {
            const left = container.querySelector('.aivp-left');
            if (left) left.style.display = enabled ? '' : 'none';
          } catch (e) { /* ignore per-container */ }
        }
      } catch (ex) {
        console.warn('[AIVP] Failed to apply showLength to existing:', ex);
      }
    }
  };

  const MenuCommands = {
    register() {
      try {
        if (typeof GM_registerMenuCommand !== 'function') return;
        GM_registerMenuCommand('Instagram Fix Settings', () => SettingsPanel.toggle());
      } catch (ex) {
        console.warn('[AIVP] Failed to register menu commands:', ex);
      }
    }
  };

  const ContainerManager = {
    register(container, state) {
      State.containerMap.set(container, state);
      this.ensureDOMObserver();
    },
    unregister(container) {
      try {
        State.containerMap.delete(container);
      } catch (ex) {
        console.warn('[AIVP] Failed to unregister container:', ex);
      }
      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)) {
            if (state?.cleanup) {
              try { state.cleanup(); } catch (ex) { console.warn('[AIVP] Error in container cleanup:', ex); }
            }
            State.containerMap.delete(container);
          }
        }
        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;
        }
      } catch (ex) {
        console.warn('[AIVP] Error during cleanup:', ex);
      }
    }
  };

  const VideoInteraction = {
    hitTest(container, clientX, clientY) {
      try {
        const rect = container.getBoundingClientRect();
        if (!rect || !rect.width) return false;
        return clientX >= rect.left && clientX <= rect.right &&
          clientY >= rect.top && clientY <= rect.bottom;
      } catch (ex) {
        return false;
      }
    },
    setupSeekableProgressbar(video, container, elapsedBar) {
      const state = {};
      state.getVideo = () => {
        try {
          const live = container.parentElement && container.parentElement.querySelector('video');
          if (live && document.contains(live)) return live;
        } catch (ex) { /* ignore */ }
        return document.contains(video) ? video : null;
      };
      state.seek = (clientX) => {
        const v = state.getVideo() || video;
        if (!v || !isFinite(v.duration) || v.duration === 0) return;
        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));
        v.currentTime = percent * v.duration;
        elapsedBar.style.width = `${Math.ceil(percent * container.offsetWidth)}px`;
        ProgressbarSetup.updateLengthLabel(v, elapsedBar);
      };
      ContainerManager.register(container, state);
    },
    installSeekDispatcher() {
      if (State.seekDispatcherInstalled) return;
      State.seekDispatcherInstalled = true;
      let drag = null;
      const overSettingsPanel = (e) => {
        try {
          const panel = SettingsPanel.panel;
          return !!(panel && e.target instanceof Node && panel.contains(e.target));
        } catch (ex) {
          return false;
        }
      };
      const findContainer = (e) => {
        for (const container of State.containerMap.keys()) {
          try {
            if (!document.contains(container)) continue;
            if (VideoInteraction.hitTest(container, e.clientX, e.clientY)) return container;
          } catch (ex) { /* ignore */ }
        }
        return null;
      };
      const onPointerDown = (e) => {
        if (e.button !== 0) return;
        if (overSettingsPanel(e)) return;
        const container = findContainer(e);
        if (!container) return;
        const st = State.containerMap.get(container);
        if (!st) return;
        e.stopPropagation();
        e.preventDefault();
        const v = st.getVideo();
        drag = { st, wasPlaying: v ? !v.paused : false };
        st.seek(e.clientX);
      };
      const onPointerMove = (e) => {
        if (!drag) return;
        e.preventDefault();
        drag.st.seek(e.clientX);
      };
      const onPointerUp = (e) => {
        if (!drag) return;
        e.preventDefault();
        drag.st.seek(e.clientX);
        const v = drag.st.getVideo();
        if (drag.wasPlaying && v) {
          try { v.play(); } catch (ex) { /* ignore */ }
        }
        drag = null;
      };
      const onPointerCancel = () => {
        drag = null;
      };
      const onClick = (e) => {
        if (overSettingsPanel(e)) return;
        const container = findContainer(e);
        if (!container) return;
        e.stopPropagation();
        e.preventDefault();
      };
      try {
        document.addEventListener('pointerdown', onPointerDown, { capture: true, passive: false });
        document.addEventListener('pointermove', onPointerMove, { capture: true, passive: false });
        document.addEventListener('pointerup', onPointerUp, { capture: true, passive: false });
        document.addEventListener('pointercancel', onPointerCancel, true);
        document.addEventListener('click', onClick, true);
      } catch (ex) {
        console.warn('[AIVP] Failed to install seek dispatcher:', ex);
      }
    }
  };

  const ProgressbarSetup = {
    setupVideo(video) {
      if (!video || !video.parentNode) return;
      this.setLoop(video, Utils.getLoopEnabled());
      const { container, elapsedBar } = this.createProgressbarUI(video);
      State.videoElementMap.set(video, elapsedBar);
      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 */ }
      try { container.style.zIndex = '2147483647'; } catch (ex) { /* ignore */ }
      if (parent && parent.appendChild) parent.appendChild(container);
      VideoInteraction.setupSeekableProgressbar(video, container, elapsedBar);
      this.setupVideoEventHandlers(video, elapsedBar);
      this.setVideoSound(video, Utils.getSoundEnabled());
    },
    setLoop(video, enabled) {
      try {
        video.loop = !!enabled;
        if (enabled) {
          try { video.setAttribute('loop', ''); } catch (e) {}
          try { video.removeAttribute('noloop'); } catch (e) {}
        } else {
          try { video.removeAttribute('loop'); } catch (e) {}
          try { video.setAttribute('noloop', ''); } catch (e) {}
        }
      } catch (ex) { /* ignore */ }
    },
    createProgressbarUI(video) {
      const containerId = Utils.generateId();
      const elapsedBarId = `${containerId}bar`;
      const progressHeight = Utils.getProgressbarHeight();
      const hitAreaHeight = Math.max(16, progressHeight * 3);
      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;
  touch-action: none;
}
#${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-left {
  position: absolute;
  left: 8px;
  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: 1;
  transition: opacity 120ms;
  z-index: 10000;
}
</style>
<div class="aivp-bg"></div>
<div id="${elapsedBarId}" class="aivp-elapsed"></div>
<div class="aivp-left">0:00 / 0:00</div>`;
      if (!CONFIG.ui.showLength) {
        const left = container.querySelector('.aivp-left');
        if (left) left.style.display = 'none';
      }
      const elapsedBar = container.querySelector(`#${elapsedBarId}`);
      return { container, elapsedBar };
    },
    updateLengthLabel(video, elapsedBar) {
      if (!CONFIG.ui.showLength) return;
      try {
        const left = elapsedBar.parentNode?.querySelector('.aivp-left');
        if (!left) return;
        const d = Number(video.duration);
        const total = isFinite(d) && d > 0 ? Utils.formatTime(d) : '0:00';
        left.textContent = `${Utils.formatTime(video.currentTime)} / ${total}`;
      } catch (ex) { /* ignore */ }
    },
    setupVideoEventHandlers(video, elapsedBar) {
      let updateTimer = null;
      const updateProgressBar = () => {
        this.updateLengthLabel(video, elapsedBar);
        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 = () => {
        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 {
            if (CONFIG.video.disableLoop) {
              try { video.pause(); } catch (e) { /* ignore */ }
              try { video.loop = false; } catch (e) { /* ignore */ }
              try { if (video.removeAttribute) video.removeAttribute('loop'); } catch (e) { /* ignore */ }
            }
            elapsedBar.style.width = '100%';
          } catch (ex) {
            console.warn('[AIVP] Error handling ended event:', ex);
          }
        }
        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);
      video.addEventListener('timeupdate', () => this.updateLengthLabel(video, elapsedBar));
      video.addEventListener('loadedmetadata', () => this.updateLengthLabel(video, elapsedBar));
      this.updateLengthLabel(video, elapsedBar);
    },
    findAudioToggle(video) {
      try {
        const precise = '[aria-label="Toggle audio"], [aria-label="Mute"], [aria-label="Unmute"]';
        let node = video;
        for (let i = 0; i < 10 && node; i++) {
          const btn = node.querySelector?.(precise);
          if (btn) return btn;
          node = node.parentElement;
        }
        const broad = '[aria-label*="audio" i], [aria-label*="mute" i], [aria-label*="sound" i], [aria-label*="volume" i]';
        node = video;
        for (let i = 0; i < 10 && node; i++) {
          const btn = node.querySelector?.(broad);
          if (btn) return btn;
          node = node.parentElement;
        }
        try {
          const root = video.closest?.('article') || video.closest?.('section') || video.closest?.('div') || document;
          const candidates = root.querySelectorAll('div[role="button"], button, [role="button"]');
          const vRect = video.getBoundingClientRect();
          for (const c of candidates) {
            if (!c.querySelector('svg')) continue;
            const r = c.getBoundingClientRect();
            if (r.width < 16 || r.width > 80 || r.height < 16 || r.height > 80) continue;
            if (r.top >= vRect.top - 20 && r.left >= vRect.left - 20 && r.top <= vRect.bottom && r.left <= vRect.right) return c;
          }
        } catch (e) {}
      } catch (ex) { /* ignore */ }
      return null;
    },
    setVideoSound(video, enabled) {
      try {
        const wantMuted = !enabled;
        const toggle = this.findAudioToggle(video);
        const needsChange = video.muted !== wantMuted;
        let toggleNeedsClick = false;
        if (toggle && !needsChange) {
          const label = (toggle.getAttribute('aria-label') || '').toLowerCase();
          if (enabled && label.includes('unmute')) toggleNeedsClick = true;
          if (!enabled && label.includes('mute') && !label.includes('unmute')) toggleNeedsClick = true;
        }
        if (needsChange || toggleNeedsClick) {
          try { video._aivpSyncing = true; } catch (e) {}
          if (toggle) {
            try { toggle.click(); } catch (e) {}
          }
          try { video.muted = wantMuted; } catch (e) {}
          if (enabled) { try { video.volume = 1; } catch (e) {} }
          setTimeout(() => { try { video._aivpSyncing = false; } catch (e) {} }, 0);
          setTimeout(() => {
            try {
              if (!document.contains(video)) return;
              if (video.muted !== wantMuted) {
                try { video._aivpSyncing = true; } catch (e) {}
                const t2 = this.findAudioToggle(video);
                if (t2) { try { t2.click(); } catch (e) {} }
                try { video.muted = wantMuted; } catch (e) {}
                if (enabled) try { video.volume = 1; } catch (e) {}
                setTimeout(() => { try { video._aivpSyncing = false; } catch (e) {} }, 0);
              }
            } catch (e) {}
          }, 400);
          if (!toggle) {
            setTimeout(() => {
              try {
                if (!document.contains(video)) return;
                const t2 = this.findAudioToggle(video);
                if (t2 && video.muted !== wantMuted) {
                  try { video._aivpSyncing = true; } catch (e) {}
                  try { t2.click(); } catch (e) {}
                  try { video.muted = wantMuted; } catch (e) {}
                  setTimeout(() => { try { video._aivpSyncing = false; } catch (e) {} }, 0);
                }
              } catch (e) {}
            }, 1200);
          }
        } else {
          if (enabled) { try { if (video.volume < 0.5) video.volume = 1; } catch (e) {} }
        }
        // no _aivpSoundEnforced listeners — removed; manual icon clicks sync via delegated dispatcher
      } catch (ex) {
        console.warn('[AIVP] Failed to set video sound:', ex);
      }
    },
    installSoundSyncDispatcher() {
      if (State.soundSyncDispatcherInstalled) return;
      State.soundSyncDispatcherInstalled = true;
      const isAudioToggle = (el) => {
        try {
          if (!el || !(el instanceof Element)) return false;
          const sel = '[aria-label="Toggle audio"], [aria-label="Mute"], [aria-label="Unmute"]';
          if (el.matches && el.matches(sel)) return true;
          if (el.closest && el.closest(sel)) return true;
          // broad fallback like findAudioToggle second pass
          const broad = el.closest ? el.closest('[aria-label*="audio" i], [aria-label*="mute" i], [aria-label*="sound" i], [aria-label*="volume" i]') : null;
          if (broad) return true;
        } catch (e) {}
        return false;
      };
      const findVideoForToggle = (toggleEl) => {
        try {
          // walk up to article/section/div then query video
          let node = toggleEl;
          for (let i = 0; i < 6 && node; i++) {
            const v = node.querySelector ? node.querySelector('video') : null;
            if (v && document.contains(v)) return v;
            // also check parent's videos
            const parent = node.parentElement;
            if (parent) {
              const pv = parent.querySelector ? parent.querySelector('video') : null;
              if (pv && document.contains(pv)) return pv;
            }
            node = node.parentElement;
          }
          // fallback: nearest video in document closest to toggle rect
          const allVideos = document.querySelectorAll ? document.querySelectorAll('video') : [];
          for (const v of allVideos) {
            if (!document.contains(v)) continue;
            // check if toggle is near video rect (like findAudioToggle)
            try {
              const vr = v.getBoundingClientRect();
              const tr = toggleEl.getBoundingClientRect();
              if (tr.width >= 16 && tr.width <= 80 && tr.height >= 16 && tr.height <= 80) {
                if (tr.top >= vr.top - 20 && tr.left >= vr.left - 20 && tr.top <= vr.bottom && tr.left <= vr.right) return v;
              }
            } catch (e) {}
          }
        } catch (e) {}
        return null;
      };
      const onDocClick = (e) => {
        try {
          if (e.button !== 0) return;
          // ignore clicks inside settings panel
          try { if (SettingsPanel.panel && e.target instanceof Node && SettingsPanel.panel.contains(e.target)) return; } catch (e2) {}
          let toggleEl = null;
          const t = e.target;
          // handle Element targets and text nodes (parentElement fallback)
          const startEl = (t instanceof Element) ? t : (t && t.parentElement instanceof Element ? t.parentElement : null);
          if (startEl) {
            if (isAudioToggle(startEl)) toggleEl = startEl;
            else if (startEl.closest) {
              const c = startEl.closest('[aria-label="Toggle audio"], [aria-label="Mute"], [aria-label="Unmute"], [aria-label*="audio" i], [aria-label*="mute" i], [aria-label*="sound" i], [aria-label*="volume" i]');
              if (c) toggleEl = c;
              if (!toggleEl) {
                const btn = startEl.closest('div[role="button"], button, [role="button"]');
                if (btn && btn.querySelector && btn.querySelector('svg')) toggleEl = btn;
              }
            }
            // if startEl itself is svg/path inside button, also try walking up manually
            if (!toggleEl) {
              let p = startEl.parentElement;
              for (let i = 0; i < 4 && p; i++) {
                if (isAudioToggle(p)) { toggleEl = p; break; }
                const c = p.closest ? p.closest('[aria-label="Toggle audio"], [aria-label="Mute"], [aria-label="Unmute"]') : null;
                if (c) { toggleEl = c; break; }
                p = p.parentElement;
              }
            }
          }
          if (!toggleEl) return;
          // resolve video
          let video = findVideoForToggle(toggleEl);
          if (!video) {
            // last resort: most recently registered video near click
            for (const v of State.videoElementMap.keys()) {
              if (document.contains(v)) { video = v; break; }
            }
          }
          if (!video) return;
          // guard programmatic clicks
          try { if (video._aivpSyncing) return; } catch (e) {}
          // IG updates muted async after click; read after microtask + delay
          setTimeout(() => {
            try {
              if (!document.contains(video)) return;
              if (video._aivpSyncing) return;
              const nowEnabled = !video.muted;
              if (nowEnabled === Utils.getSoundEnabled()) return;
              Utils.setSoundEnabled(nowEnabled);
            } catch (e) {}
          }, 160);
        } catch (ex) { /* ignore */ }
      };
      try {
        document.addEventListener('click', onDocClick, true);
      } catch (e) {
        console.warn('[AIVP] Failed to install sound sync dispatcher:', e);
      }
    }
  };

  const VideoDiscovery = {
    ensureVideoHasProgressbar(video) {
      try {
        if (!video || String(video.tagName).toLowerCase() !== 'video') return;
        if (video.getAttribute('aivp_done')) {
          if (!State.videoElementMap.get(video) && video.readyState >= 2) {
            try { ProgressbarSetup.setupVideo(video); } catch (ex) { console.warn('[AIVP] Error setting up video:', ex); }
          }
          return;
        }
        video.setAttribute('aivp_done', '1');
        if (video.readyState >= 2) {
          try { ProgressbarSetup.setupVideo(video); } catch (ex) { console.warn('[AIVP] Error setting up video:', ex); }
        } else {
          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 {
        const existingVideos = document.querySelectorAll('video');
        existingVideos.forEach(video => this.ensureVideoHasProgressbar(video));
        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 (node.tagName && String(node.tagName).toLowerCase() === 'video') {
                    this.ensureVideoHasProgressbar(node);
                  } else if (node.querySelectorAll) {
                    const descendantVideos = node.querySelectorAll('video');
                    descendantVideos.forEach(video => this.ensureVideoHasProgressbar(video));
                  }
                }
              }
            }
          } catch (ex) {
            console.warn('[AIVP] Error in video observer:', ex);
          }
        });
        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);
      }
    }
  };

  const SettingsPanel = {
    panel: null,
    escHandler: null,
    outsideHandler: null,
    toggle() {
      if (this.panel && document.contains(this.panel)) {
        this.close();
        return;
      }
      this.open();
    },
    open() {
      this.close();
      const panel = document.createElement('div');
      panel.id = 'aivp-settings-panel';
      panel.innerHTML = `<style>
#aivp-settings-panel{position:fixed;bottom:16px;right:16px;z-index:2147483647;width:250px;background:#1c1c1e;color:#fff;border-radius:12px;padding:14px;font:13px -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;box-shadow:0 8px 24px rgba(0,0,0,.5)}
#aivp-settings-panel h3{margin:0 0 10px;font-size:14px;display:flex;justify-content:space-between;align-items:center}
#aivp-settings-panel h3 button{background:none;border:none;color:#aaa;font-size:16px;cursor:pointer}
.aivp-row{display:flex;justify-content:space-between;align-items:center;margin:10px 0}
.aivp-switch{position:relative;width:40px;height:22px;-webkit-appearance:none;appearance:none;background:#555;border-radius:11px;cursor:pointer;outline:none;transition:background .15s;flex-shrink:0}
.aivp-switch:checked{background:#3897f0}
.aivp-switch::before{content:'';position:absolute;top:2px;left:2px;width:18px;height:18px;background:#fff;border-radius:50%;transition:transform .15s}
.aivp-switch:checked::before{transform:translateX(18px)}
#aivp-settings-panel input[type=number]{width:56px;background:#333;border:1px solid #555;color:#fff;border-radius:6px;padding:4px 6px}
#aivp-settings-panel .aivp-reset{background:none;border:none;color:#3897f0;cursor:pointer;font-size:12px}
</style>
<h3>Instagram Fix <button title=\"Close\">✕</button></h3>
<div class=\"aivp-row\"><span>Loop video</span><input type=\"checkbox\" class=\"aivp-switch\" id=\"aivp-sw-loop\"></div>
<div class=\"aivp-row\"><span>Sound</span><input type=\"checkbox\" class=\"aivp-switch\" id=\"aivp-sw-sound\"></div>
<div class=\"aivp-row\"><span>Show video length</span><input type=\"checkbox\" class=\"aivp-switch\" id=\"aivp-sw-length\"></div>
<div class=\"aivp-row\"><span>Progressbar height</span><span><input type=\"number\" id=\"aivp-in-height\" min=\"0\" max=\"100\"> <button class=\"aivp-reset\">Reset</button></span></div>`;
      document.body.appendChild(panel);
      this.panel = panel;

      const loopSw = panel.querySelector('#aivp-sw-loop');
      const soundSw = panel.querySelector('#aivp-sw-sound');
      const heightIn = panel.querySelector('#aivp-in-height');
      loopSw.checked = Utils.getLoopEnabled();
      soundSw.checked = Utils.getSoundEnabled();
      heightIn.value = Utils.getProgressbarHeight();

      loopSw.addEventListener('change', () => Utils.setLoopEnabled(loopSw.checked));
      soundSw.addEventListener('change', () => Utils.setSoundEnabled(soundSw.checked));
      const lengthSw = panel.querySelector('#aivp-sw-length');
      lengthSw.checked = Utils.getShowLength();
      lengthSw.addEventListener('change', () => Utils.setShowLength(lengthSw.checked));
      const onHeightChange = () => {
        let v = parseInt(heightIn.value, 10);
        if (isNaN(v)) { heightIn.value = Utils.getProgressbarHeight(); return; }
        v = Math.max(0, Math.min(100, v));
        heightIn.value = String(v);
        Utils.setProgressbarHeight(v);
      };
      heightIn.addEventListener('input', onHeightChange);
      heightIn.addEventListener('change', onHeightChange);
      panel.querySelector('.aivp-reset').addEventListener('click', () => {
        Utils.setProgressbarHeight(6);
        heightIn.value = 6;
      });
      panel.querySelector('h3 button').addEventListener('click', () => this.close());

      this.escHandler = (e) => { if (e.key === 'Escape') this.close(); };
      this.outsideHandler = (e) => {
        if (!this.panel) return;
        const path = typeof e.composedPath === 'function' ? e.composedPath() : [];
        if (path.length) { if (path.includes(this.panel) || path.some(el => el instanceof Node && this.panel.contains(el))) return; }
        if (e.target instanceof Node && this.panel.contains(e.target)) return;
        this.close();
      };
      document.addEventListener('keydown', this.escHandler, true);
      // defer so the opening click does not instantly close the panel
      setTimeout(() => document.addEventListener('mousedown', this.outsideHandler, true), 0);
    },
    close() {
      if (this.panel) {
        this.panel.remove();
        this.panel = null;
      }
      if (this.escHandler) {
        document.removeEventListener('keydown', this.escHandler, true);
        this.escHandler = null;
      }
      if (this.outsideHandler) {
        document.removeEventListener('mousedown', this.outsideHandler, true);
        this.outsideHandler = null;
      }
    }
  };

  const init = () => {
    try {
      try {
        CONFIG.video.disableLoop = !Utils.getLoopEnabled();
        CONFIG.video.unmute = Utils.getSoundEnabled();
        CONFIG.progressbar.height = Utils.getProgressbarHeight();
        CONFIG.ui.showLength = Utils.getShowLength();
      } catch (ex) { /* ignore */ }
      MenuCommands.register();
      VideoDiscovery.startMonitoring();
      VideoInteraction.installSeekDispatcher();
      ProgressbarSetup.installSoundSyncDispatcher();
      console.log('[Instagram Fix] initialized — loop:' + (Utils.getLoopEnabled() ? 'ON' : 'OFF') + ' sound:' + (Utils.getSoundEnabled() ? 'ON' : 'OFF'));
    } catch (ex) {
      console.error('[AIVP] Failed to initialize:', ex);
    }
  };

  init();
  if (typeof module !== 'undefined' && module.exports) {
    module.exports = { Utils, CONFIG, ProgressbarSetup, VideoInteraction, State, ContainerManager, SettingsPanel };
  }
})();