Mobile Video Controller (Movable + Speed Menu + Skip Durations + Speed Slider + Fullscreen)

Overlay with movable skip/speed controls. Custom skip, smooth speed slider, fullscreen support. Play/Pause label replaces 0x and toggles playback (restores last rate).

// ==UserScript==
// @name         Mobile Video Controller (Movable + Speed Menu  + Skip Durations + Speed Slider + Fullscreen)
// @namespace    https://your.namespace
// @version      5.1.0
// @description  Overlay with movable skip/speed controls. Custom skip, smooth speed slider, fullscreen support. Play/Pause label replaces 0x and toggles playback (restores last rate).
// @match        *://*/*
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  const MIN_FRAC_PAUSED  = 0.20;
  const MIN_FRAC_PLAYING = 0.08;
  const EDGE = 6;
  const DRAG_OFFSET_Y = -20;

  // Fixed menu speeds (0 replaced by Play/Pause menu item)
  const SPEEDS = [0, 2, 1.75, 1.5, 1.25, 1];
  // Definable skip duration options for the long-press menu
  const SKIP_DURATIONS = [5, 10, 15, 30, 60];

  let activeVideo = null;
  let uiWrap = null;
  let manualDrag = false;
  let menu, speedBtn, hideSpeedMenu, backdrop, skipMenu;
  let longPressDirection = 0; // To store skip direction (-1 or 1)

  // --- Persistent settings ---
  const skipKey = "mvc_skip_seconds";
  let skipSeconds = Number(localStorage.getItem(skipKey)) || 10;

  // store last non-zero rate so Play/Pause can restore it
  const LAST_RATE_KEY = "mvc_last_rate";
  if (!localStorage.getItem(LAST_RATE_KEY)) localStorage.setItem(LAST_RATE_KEY, "1");

  function saveSkip(v) {
    skipSeconds = v;
    localStorage.setItem(skipKey, String(v));
  }

  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
  const clampTime = (v, t) => {
    const d = Number.isFinite(v.duration) ? v.duration : Infinity;
    return clamp(t, 0, d);
  };

  // ---------- UI ----------
  function createUI() {
    uiWrap = document.createElement('div');
    uiWrap.style.cssText = `
      position: fixed;
      left: 12px; top: 12px;
      z-index: 2147483647;
      font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
      display: none;
      pointer-events: auto;
    `;

    const panel = document.createElement('div');
    panel.style.cssText = `
      display: flex; flex-direction: column; align-items: center;
      gap: 2px; background: transparent; color: #fff;
      border-radius: 2px; touch-action: none; user-select: none;
      pointer-events: auto;
    `;

    const dragHandle = document.createElement('div');
    dragHandle.style.cssText = `
      width: 60px; height: 10px; border-radius: 3px;
      background: rgba(200, 200, 200, 0.2);

      margin-bottom: 4px; cursor: grab;
    `;

    const row = document.createElement('div');
    row.style.cssText = `
      display: flex; align-items: center; gap: 8px;
      padding: 1px 4px; background: transparent;
      pointer-events: auto;
    `;

    const btnStyle = `
      appearance: none;
      border: 0; border-radius: 10px;
      padding: 6px 10px; font-size: 20px; font-weight: 600;
      color: #fff; background: rgba(43,43,43,0.25); min-width: 56px;
      text-align: center;
      line-height: 1; pointer-events: auto;
    `;
    const mkBtn = (txt, title, minW) => {
      const b = document.createElement('button');
      b.textContent = txt;
      b.title = title || '';
      b.style.cssText = btnStyle;
      if (minW) b.style.minWidth = minW;
      ['pointerdown','pointerup','click','touchstart','touchend'].forEach(type => {
        b.addEventListener(type, e => { e.stopPropagation(); });
      });
      return b;
    };

    const rewind = mkBtn(`⟲ ${skipSeconds}`, 'Rewind');
    speedBtn = mkBtn('1x', 'Playback speed', '72px');
    const forward = mkBtn(`${skipSeconds} ⟳`, 'Forward');

    // Backdrop
    backdrop = document.createElement('div');
    backdrop.style.cssText = `
      display: none;
      position: fixed;
      left: 0; top: 0;
      width: 100%; height: 100%;
      z-index: 2147483646;
      background: transparent;
      pointer-events: auto;
      touch-action: none;
    `;
    document.body.appendChild(backdrop);

    function hideSkipMenu() {
        if (skipMenu) skipMenu.style.display = 'none';
        if (menu.style.display === 'none') {
            backdrop.style.display = 'none';
        }
    }

    ['pointerdown','click','touchstart','touchend'].forEach(ev => {
      backdrop.addEventListener(ev, e => {
        e.stopPropagation();
        e.preventDefault();
        hideSpeedMenu();
        hideSkipMenu();
      }, true);
    });

    // Speed menu
    menu = document.createElement('div');
    menu.className = 'mvc-speed-menu';
    menu.style.cssText = `
      display: none; position: fixed;
      background: rgba(0,0,0,0.50); border-radius: 5px;
      z-index: 2147483647; min-width: 50px;
      max-height: 64vh; overflow-y: auto;
      pointer-events: auto; touch-action: manipulation;
      -webkit-tap-highlight-color: transparent;
    `;
    document.body.append(menu);
    // Speed options
    function makeOpt(sp, isFirst) {
      const opt = document.createElement('div');
      opt.textContent = (sp === 0) ? `Play/Pause` : `${sp}x`;
      opt.dataset.sp = String(sp);
      opt.style.cssText = `
        color: white;
        padding: 0.45em 0.9em; font-size: 15px;
        text-align: center; border-top: ${isFirst ? 'none' : '1px solid rgba(255,255,255,0.12)'};
        user-select: none;
      `;
      opt.addEventListener('click', e => {
        e.stopPropagation();
        e.preventDefault();
        const spv = Number(opt.dataset.sp);
        if (!activeVideo) {
          hideSpeedMenu();
          return;
        }

        if (spv === 0) {
          if (activeVideo.paused) {
            const last = Number(localStorage.getItem(LAST_RATE_KEY)) || 1;
            try { activeVideo.playbackRate = last; } catch (err) {}
            try { activeVideo.play(); } catch (err) {}
            speedBtn.firstChild.nodeValue = `${(last).toFixed(2)}x`.replace(/\.00$/,'');
            speedBtn.removeAttribute('data-zero');
            localStorage.setItem(LAST_RATE_KEY, String(last));
          } else {
            try { localStorage.setItem(LAST_RATE_KEY, String(activeVideo.playbackRate)); } catch (err) {}
            try { activeVideo.pause(); } catch (err) {}
            speedBtn.setAttribute('data-zero', '1');
            speedBtn.firstChild.nodeValue = 'Play';
          }
        } else {
          try { activeVideo.playbackRate = spv; } catch (err) {}
          try { localStorage.setItem(LAST_RATE_KEY, String(spv)); } catch (err) {}
          speedBtn.firstChild.nodeValue = `${spv}x`;
          speedBtn.removeAttribute('data-zero');
        }

        highlightSelected(spv);
        hideSpeedMenu();
      });

      return opt;
    }

    let firstOption = true;
    for (const sp of SPEEDS) {
      menu.appendChild(makeOpt(sp, firstOption));
      firstOption = false;
    }

    function highlightSelected(sp) {
      Array.from(menu.children).forEach(el => {
        el.style.background = 'none';
        el.style.fontWeight = 'normal';
      });
      const sel = Array.from(menu.children).find(el => Number(el.dataset.sp) === Number(sp));
      if (sel) {
        sel.style.background = 'rgba(255,255,255,0.14)';
        sel.style.fontWeight = '700';
      }
    }

    function showAndMeasure(el) {
      const originalDisplay = el.style.display;
      el.style.display = originalDisplay === 'none' ? 'block' : originalDisplay;
      el.style.visibility = 'hidden';
      el.style.left = '-9999px';
      el.style.top = '-9999px';
      const rect = el.getBoundingClientRect();
      el.style.display = 'none'; // hide it back
      return { w: rect.width, h: rect.height };
    }

    function placeMenu() {
      const { w: menuW, h: menuH } = showAndMeasure(menu);
      const rect = speedBtn.getBoundingClientRect();
      let left = Math.round(rect.left + rect.width / 2 - menuW / 2);
      if (left < EDGE) left = EDGE;
      if (left + menuW > window.innerWidth - EDGE) left = window.innerWidth - menuW - EDGE;
      let openAbove = rect.top - menuH - 6 >= EDGE;
      let top = openAbove ? Math.max(EDGE, rect.top - menuH - 6) : rect.bottom + 6;
      let openBelow = !openAbove;
      const items = Array.from(menu.children);
      const minSpeed = Math.min(...SPEEDS);
      const firstVal = items.length ? Number(items[0].dataset.sp) : NaN;
      if (openBelow) {
        if (firstVal === minSpeed) items.reverse().forEach(el => menu.appendChild(el));
      } else {
        if (firstVal !== minSpeed) items.reverse().forEach(el => menu.appendChild(el));
      }
      Object.assign(menu.style, { left: left + 'px', top: top + 'px', visibility: 'visible' });
    }

    hideSpeedMenu = function() {
      menu.style.display = 'none';
      if (skipMenu && skipMenu.style.display === 'none') {
          backdrop.style.display = 'none';
      }
    };

    function toggleSpeedMenu() {
      const open = menu.style.display === 'block';
      hideSpeedMenu();
      if (!open) {
        placeMenu();
        menu.style.display = 'block';
        backdrop.style.display = 'block';
        if (activeVideo) highlightSelected(Number(activeVideo.playbackRate) || 1);
      }
    }

    // Create Skip Duration Menu
    skipMenu = document.createElement('div');
    skipMenu.style.cssText = `
        display: none; position: fixed; flex-direction: row; align-items: center;
        gap: 5px; padding: 5px;
        background: rgba(0,0,0,0.65); border-radius: 10px;
        z-index: 2147483647; pointer-events: auto; touch-action: manipulation;
        -webkit-tap-highlight-color: transparent;
    `;
    // --- Modified: Made buttons a little bigger ---
    const skipBtnStyle = `
        appearance: none; border: 0; border-radius: 8px;
        padding: 6px 14px; font-size: 16px; font-weight: 600;
        color: #fff; background: rgba(70,70,70,0.5);
        line-height: 1.2; pointer-events: auto; user-select: none;
    `;
    SKIP_DURATIONS.forEach(duration => {
        const opt = document.createElement('button');
        opt.textContent = `${duration}s`;
        opt.style.cssText = skipBtnStyle;
        opt.addEventListener('click', e => {
            e.stopPropagation();
            if (activeVideo && longPressDirection !== 0) {
                activeVideo.currentTime = clampTime(
                    activeVideo,
                    activeVideo.currentTime + longPressDirection * duration
                );
            }
        });
        skipMenu.appendChild(opt);
    });

    // --- New: Custom button to set main skip value ---
    const customSkipBtn = document.createElement('button');
    customSkipBtn.textContent = '✎ Set';
    customSkipBtn.title = 'Set default skip time';
    customSkipBtn.style.cssText = skipBtnStyle;
    customSkipBtn.style.background = 'rgba(50, 80, 130, 0.6)'; // Different color to stand out
    customSkipBtn.addEventListener('click', e => {
        e.stopPropagation();
        const choice = prompt("Set new default skip seconds:", skipSeconds);
        if (choice != null && choice !== "" && !isNaN(choice)) {
            saveSkip(Number(choice));
            rewind.textContent = `⟲ ${skipSeconds}`;
            forward.textContent = `${skipSeconds} ⟳`;
        }
        hideSkipMenu(); // Close menu after setting
    });
    skipMenu.appendChild(customSkipBtn);

    document.body.appendChild(skipMenu);

    function showSkipMenu() {
        const { w: menuW, h: menuH } = showAndMeasure(skipMenu);
        const rect = uiWrap.getBoundingClientRect();

        // --- Modified: Default to above but closer, fallback to below ---
        let top = rect.top - menuH - 4; // 4px spacing above

        // Fallback to below if no space above
        if (top < EDGE) {
            top = rect.bottom + 8;
        }

        let left = Math.round(rect.left + rect.width / 2 - menuW / 2);
        left = clamp(left, EDGE, window.innerWidth - menuW - EDGE);
        top = clamp(top, EDGE, window.innerHeight - menuH - EDGE);

        Object.assign(skipMenu.style, {
            left: `${left}px`,
            top: `${top}px`,
            visibility: 'visible',
            display: 'flex'
        });
        backdrop.style.display = 'block';
    }

    // ---------- Skip handling ----------
    function doSkip(dir) {
      if (activeVideo) {
        activeVideo.currentTime = clampTime(activeVideo, activeVideo.currentTime + dir * skipSeconds);
      }
    }

    rewind.onclick = e => { e.preventDefault(); doSkip(-1); };
    forward.onclick = e => { e.preventDefault(); doSkip(1); };

    const setupLongPress = (btn, dir) => {
        let pressTimer;
        btn.addEventListener("pointerdown", () => {
            pressTimer = setTimeout(() => {
                longPressDirection = dir;
                showSkipMenu();
            }, 600);
        });
        btn.addEventListener("pointerup", () => clearTimeout(pressTimer));
        btn.addEventListener("pointerleave", () => clearTimeout(pressTimer));
        btn.addEventListener("pointercancel", () => clearTimeout(pressTimer));
    };
    setupLongPress(rewind, -1);
    setupLongPress(forward, 1);

    // ---------- Speed handling (click menu OR vertical drag) ----------
    let isSliding = false;
    let dragStartY = null, dragStartRate = null, moved = 0;
    speedBtn.addEventListener("pointerdown", e => {
      e.preventDefault();
      dragStartY = e.clientY;
      dragStartRate = activeVideo ? activeVideo.playbackRate : 1;
      moved = 0;
      isSliding = false;
      try { speedBtn.setPointerCapture(e.pointerId); } catch (err) {}
    });
    speedBtn.addEventListener("pointermove", e => {
  if (dragging || dragStartY == null || !activeVideo) return; // এই লাইনটি পরিবর্তন করা হয়েছে
  const dy = dragStartY - e.clientY;

      moved += Math.abs(e.movementY || (dragStartY - e.clientY));
      if (moved > 6) isSliding = true;
      if (!isSliding) return;
      let newRate = dragStartRate + dy * 0.005;
      newRate = clamp(newRate, 0.1, 6);
      activeVideo.playbackRate = newRate;
      speedBtn.firstChild.nodeValue = newRate.toFixed(2) + "x";
      try { localStorage.setItem(LAST_RATE_KEY, String(newRate)); } catch (err) {}
      speedBtn.removeAttribute('data-zero');
    });
    speedBtn.addEventListener("pointerup", e => {
      try { speedBtn.releasePointerCapture(e.pointerId); } catch (err) {}
      dragStartY = null;
      dragStartRate = null;
      if (isSliding) {
        isSliding = false;
        return;
      }
    });
    speedBtn.addEventListener("click", e => {
      e.preventDefault();
      if (speedBtn.getAttribute('data-zero') === '1') {
        if (!activeVideo) return;
        if (activeVideo.paused) {
          const last = Number(localStorage.getItem(LAST_RATE_KEY)) || 1;
          try { activeVideo.playbackRate = last; } catch (err) {}
          try { activeVideo.play(); } catch (err) {}
          speedBtn.firstChild.nodeValue = `${(last).toFixed(2)}x`.replace(/\.00$/,'');
          speedBtn.removeAttribute('data-zero');
        } else {
          try { localStorage.setItem(LAST_RATE_KEY, String(activeVideo.playbackRate)); } catch (err) {}
          try { activeVideo.pause(); } catch (err) {}
          speedBtn.firstChild.nodeValue = 'Play';
        }
        return;
      }
      toggleSpeedMenu();
    });

    row.append(rewind, speedBtn, forward);
    panel.append(dragHandle, row);
    uiWrap.append(panel);
    document.body.appendChild(uiWrap);

    // Dragging for the whole widget
    let dragging = false;
    dragHandle.onpointerdown = e => {
      dragging = true;
      try { dragHandle.setPointerCapture(e.pointerId); } catch (err) {}
      moveUnderFinger(e);
      manualDrag = true;
      e.preventDefault();
      e.stopPropagation();
    };
    dragHandle.onpointermove = e => {
      if (!dragging) return;
      moveUnderFinger(e);
      e.preventDefault();
      e.stopPropagation();
    };
    dragHandle.onpointerup = e => { dragging = false; };
    dragHandle.onpointercancel = () => { dragging = false; };
    function moveUnderFinger(e) {
      const w = uiWrap.offsetWidth, h = uiWrap.offsetHeight;
      const x = clamp(e.clientX - w / 2, EDGE, window.innerWidth - w - EDGE);
      const y = clamp(e.clientY - h / 2 - DRAG_OFFSET_Y, EDGE, window.innerHeight - h - EDGE);
      uiWrap.style.left = x + 'px';
      uiWrap.style.top  = y + 'px';
      uiWrap.style.right = 'auto';
      uiWrap.style.bottom = 'auto';
    }

    // --- Fullscreen support: move UI/menu/backdrop into fullscreen container ---
    function getFullscreenElement() {
      return document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement || null;
    }

    function getFullscreenContainer(fsEl) {
      if (!fsEl) return null;
      try {
        if (fsEl.tagName && fsEl.tagName.toLowerCase() === 'video' && fsEl.parentElement) {
          return fsEl.parentElement;
        }
        if (fsEl.shadowRoot) return fsEl.shadowRoot;
        return fsEl;
      } catch (e) { return fsEl; }
    }

    function moveToContainer(container) {
      if (!container) return;
      try {
        const append = node => {
          if (!node) return;
          if (container instanceof ShadowRoot) container.appendChild(node);
          else if (container.appendChild) container.appendChild(node);
        };
        append(uiWrap);
        append(menu);
        append(backdrop);
        append(skipMenu);
      } catch (e) {}
    }

    function enterFullscreenMode(fsEl) {
      if (!fsEl) return;
      const container = getFullscreenContainer(fsEl);
      if (!container) return;
      moveToContainer(container);
      uiWrap.style.position = 'absolute';
      menu.style.position = 'absolute';
      backdrop.style.position = 'absolute';
      skipMenu.style.position = 'absolute';
      uiWrap.style.zIndex = 2147483647;
      menu.style.zIndex = 2147483647;
      skipMenu.style.zIndex = 2147483647;
      backdrop.style.zIndex = 2147483646;
      positionOnVideo();
    }

    function exitFullscreenMode() {
      try {
        document.body.appendChild(uiWrap);
        document.body.appendChild(menu);
        document.body.appendChild(backdrop);
        document.body.appendChild(skipMenu);
      } catch (e) {}
      uiWrap.style.position = 'fixed';
      menu.style.position = 'fixed';
      backdrop.style.position = 'fixed';
      skipMenu.style.position = 'fixed';
      uiWrap.style.zIndex = 2147483647;
      menu.style.zIndex = 2147483647;
      skipMenu.style.zIndex = 2147483647;
      backdrop.style.zIndex = 2147483646;
      positionOnVideo();
    }

    function onFullScreenChange() {
      const fsEl = getFullscreenElement();
      if (fsEl) enterFullscreenMode(fsEl);
      else exitFullscreenMode();
    }

    document.addEventListener('fullscreenchange', onFullScreenChange);
    document.addEventListener('webkitfullscreenchange', onFullScreenChange);
    document.addEventListener('mozfullscreenchange', onFullScreenChange);
    document.addEventListener('MSFullscreenChange', onFullScreenChange);

    onFullScreenChange();
  }

  // ---------- Video handling ----------
  function visibleArea(v) {
    const r = v.getBoundingClientRect();
    const iw = window.innerWidth, ih = window.innerHeight;
    const w = Math.max(0, Math.min(r.right, iw) - Math.max(r.left, 0));
    const h = Math.max(0, Math.min(r.bottom, ih) - Math.max(r.top, 0));
    return w * h;
  }
  const isPlaying = v => !v.paused && !v.ended && v.readyState > 2;
  function pickCandidate() {
    const vids = Array.from(document.querySelectorAll('video'));
    if (!vids.length) return null;
    let best = null, bestScore = -1, bestFrac = 0;
    const viewArea = window.innerWidth * window.innerHeight;
    for (const v of vids) {
      const area = visibleArea(v);
      if (area <= 0) continue;
      const frac = area / viewArea;
      const playing = isPlaying(v);
      const score = area + (playing ? viewArea : 0);
      if (score > bestScore) { best = v; bestScore = score; bestFrac = frac; }
    }
    if (!best) return null;
    const minFrac = isPlaying(best) ? MIN_FRAC_PLAYING : MIN_FRAC_PAUSED;
    if (bestFrac >= minFrac) return best;
    return null;
  }

  function setActiveVideo(v) {
    if (activeVideo === v) return;
    activeVideo = v;
    if (!activeVideo) {
      uiWrap.style.display = 'none';
      return;
    }
    if (!document.body.contains(uiWrap)) document.body.appendChild(uiWrap);
    uiWrap.style.display = 'block';
    manualDrag = false;
    hideSpeedMenu();
    positionOnVideo();
    try {
      if (speedBtn && speedBtn.getAttribute('data-zero') === '1') {
        speedBtn.textContent = activeVideo.paused ? 'Play' : 'Pause';
      } else {
        speedBtn.textContent = (activeVideo && activeVideo.playbackRate) ?
        activeVideo.playbackRate.toFixed(2) + 'x' : '1x';
      }
    } catch (e) {}
  }

  function positionOnVideo() {
    if (!activeVideo || manualDrag) return;
    const r = activeVideo.getBoundingClientRect();
    const w = uiWrap.offsetWidth, h = uiWrap.offsetHeight;
    if (!(r && r.width > 0 && r.height > 0)) return;
    let x = r.right - w - 40;
    let y = r.bottom - h - 10;
    x = clamp(x, EDGE, window.innerWidth - w - EDGE);
    y = clamp(y, EDGE, window.innerHeight - h - EDGE);
    uiWrap.style.left = x + 'px';
    uiWrap.style.top  = y + 'px';
  }

  function evaluateActive() {
    const cand = pickCandidate();
    setActiveVideo(cand);
  }

  function tick() {
    try {
      positionOnVideo();
      evaluateActive();
    } catch (e) {}
    requestAnimationFrame(tick);
  }

  function observeVideos() {
    const io = new IntersectionObserver(() => evaluateActive(), { threshold: [0,0.25,0.5,0.75,1] });
    const observed = new WeakSet();
    const attachIO = () => {
      document.querySelectorAll('video').forEach(v => {
        if (!observed.has(v)) { io.observe(v); observed.add(v); }
      });
    };
    attachIO();
    new MutationObserver(attachIO).observe(document.body, { childList: true, subtree: true });
    setInterval(evaluateActive, 1500);
    addEventListener('resize', positionOnVideo);
    addEventListener('scroll', () => { evaluateActive(); positionOnVideo(); }, { passive: true });
  }

  function init() {
    createUI();
    observeVideos();
    evaluateActive();
    positionOnVideo();
    requestAnimationFrame(tick);
  }
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, { once: true });
  else init();

})();