Mobile Video Seek Gesture

On mobile, swipe left or right to seek the video, long press to speed up

// ==UserScript==
// @name         Mobile Video Seek Gesture
// @namespace    http://tampermonkey.net/
// @version      8.2
// @description  On mobile, swipe left or right to seek the video, long press to speed up
// @license      MIT
// @match        *://*/*
// @grant        none
// ==/UserScript==

(function() {
  'use strict';

  // ✅ 중복 실행 방지
  if (window.__mobileVideoGesture__) return;
  window.__mobileVideoGesture__ = true;

  const userPlaybackRates = new Map();
  let longPressTimeout = null;

  // ✅ 범용 비디오 제어 래퍼 (다양한 플레이어 호환)
  function createVideoController(video) {
    return {
      el: video,
      get currentTime() {
        try {
          return (
            video.currentTime ?? // HTML5 표준 API
            video?.player?.currentTime?.() ??
            video?.plyr?.currentTime ??
            video?.shakaPlayer?.getMediaElement?.()?.currentTime ??
            video?.hls?.media?.currentTime ??
            0
          );
        } catch {
          return 0;
        }
      },
      set currentTime(t) {
        try {
          video.currentTime = t; // 기본 HTML5 방식
        } catch {}
        // Optional: Video.js 등 wrapper API 처리
        try {
          if (typeof video?.player?.currentTime === 'function') video.player.currentTime(t);
          if (video?.plyr) video.plyr.currentTime = t;
          if (video?.shakaPlayer) video.shakaPlayer.getMediaElement().currentTime = t;
          if (video?.hls) video.hls.media.currentTime = t;
        } catch {}
      },
      get duration() {
        return (
          video.duration ??
          video?.player?.duration?.() ??
          video?.plyr?.duration ??
          video?.shakaPlayer?.getDuration?.() ??
          video?.hls?.media?.duration ??
          0
        );
      },
      get playbackRate() {
        return (
          video.playbackRate ??
          video?.player?.playbackRate?.() ??
          video?.plyr?.speed ??
          video?.shakaPlayer?.getPlaybackRate?.() ??
          1
        );
      },
      set playbackRate(r) {
        try {
          video.playbackRate = r;
        } catch {}
        try {
          if (video?.player?.playbackRate) video.player.playbackRate(r);
          if (video?.plyr) video.plyr.speed = r;
          if (video?.shakaPlayer) video.shakaPlayer.setPlaybackRate(r);
        } catch {}
      },
    };
  }

  // ✅ 오버레이 생성
  const overlay = document.createElement('div');
  Object.assign(overlay.style, {
    position: 'fixed',
    top: '50%',
    left: '50%',
    transform: 'translate(-50%, -50%)',
    background: 'rgba(0,0,0,0.7)',
    color: '#fff',
    fontSize: '18px',
    padding: '10px 20px',
    borderRadius: '10px',
    textAlign: 'center',
    zIndex: 999999,
    display: 'none',
    lineHeight: '1.5',
  });
  document.body.appendChild(overlay);
  function showOverlay(text) { overlay.innerHTML = text; overlay.style.display = 'block'; }
  function hideOverlay() { overlay.style.display = 'none'; overlay.innerHTML = ''; }

  // ✅ 시간 형식 변환
  function formatTime(seconds) {
    if (isNaN(seconds)) return '00:00';
    let absSeconds = Math.floor(seconds); // 소수점 제거
    let hours = Math.floor(absSeconds / 3600);
    let minutes = Math.floor((absSeconds % 3600) / 60);
    let secs = absSeconds % 60;

    if (hours > 0) {
        return `${hours < 10 ? '0' : ''}${hours}:` +
               `${minutes < 10 ? '0' : ''}${minutes}:` +
               `${secs < 10 ? '0' : ''}${secs}`;
    } else {
        return `${minutes < 10 ? '0' : ''}${minutes}:` +
               `${secs < 10 ? '0' : ''}${secs}`;
    }
  }

  // 시간 변화량을 형식화
  function formatDelta(seconds) {
    const sign = seconds < 0 ? '-' : '+';
    let absSeconds = Math.floor(Math.abs(seconds));
    let hours = Math.floor(absSeconds / 3600);
    let minutes = Math.floor((absSeconds % 3600) / 60);
    let secs = absSeconds % 60;

    if (hours > 0) {
        return `${sign}${hours < 10 ? '0' : ''}${hours}:` +
               `${minutes < 10 ? '0' : ''}${minutes}:` +
               `${secs < 10 ? '0' : ''}${secs}`;
    } else {
        return `${sign}${minutes < 10 ? '0' : ''}${minutes}:` +
               `${secs < 10 ? '0' : ''}${secs}`;
    }
  }

  // ✅ 전역 터치 이벤트 적용
  window.addEventListener('touchstart', e => {
    if (e.touches.length !== 1) return;
    const touch = e.touches[0];
    const allVideos = findAllVideos();
    const video = allVideos.find(v => {
      const rect = v.getBoundingClientRect();
      return touch.clientX >= rect.left && touch.clientX <= rect.right &&
             touch.clientY >= rect.top && touch.clientY <= rect.bottom;
    });
    if (!video) return;

    const ctrl = createVideoController(video);
    const startX = touch.clientX;
    const initialTime = ctrl.currentTime;
    let seeking = false;

    let longPressFired = false;
    const LONG_PRESS_DELAY = 500;  // 롱터치 시간(ms)
    const MOVE_THRESHOLD = 10;     // px

    // 롱터치 타이머
    longPressTimeout = setTimeout(() => {
      if (!seeking) {
        longPressFired = true;
        userPlaybackRates.set(video, ctrl.playbackRate); // 현재 배속 저장
        ctrl.playbackRate = 2.0; // 배속 설정
        showOverlay('2x Speed');
      }
    }, LONG_PRESS_DELAY);

    // 터치 이동
    const touchMoveHandler = eMove => {
      const deltaX = eMove.touches[0].clientX - startX;
      if (Math.abs(deltaX) > MOVE_THRESHOLD) {
        seeking = true;
        clearTimeout(longPressTimeout); // 이동하면 롱터치 취소
      }
      if (seeking && !longPressFired) {
        const timeChange = deltaX * 0.05; // 민감도 값 조정
        ctrl.currentTime = Math.max(0, Math.min(initialTime + timeChange, ctrl.duration));
        showOverlay(`${formatTime(ctrl.currentTime)}<br>(${formatDelta(timeChange)})`);
      }
    };

    // 터치 종료
    const touchEndHandler = () => {
      clearTimeout(longPressTimeout);

      if(longPressFired){
        ctrl.playbackRate = userPlaybackRates.get(video) ?? 1;
        userPlaybackRates.delete(video); // 다음 터치에서 새로 기록 가능
        longPressFired=false;
      }
      seeking=false;

      hideOverlay();

      window.removeEventListener('touchmove', touchMoveHandler);
      window.removeEventListener('touchend', touchEndHandler);
      window.removeEventListener('touchcancel', touchEndHandler);
    };

    window.addEventListener('touchmove', touchMoveHandler, { passive: true });
    window.addEventListener('touchend', touchEndHandler);
    window.addEventListener('touchcancel', touchEndHandler);
  }, { passive: true, capture: true }); // capture: true 추가로 커스텀 플레이어 충돌 방지

  // ✅ Shadow DOM 포함 탐색 (iframe도 탐색)
  function findAllVideos(root = document, found = new Set()) {
    const vids = [];
    try {
      root.querySelectorAll('video').forEach(v => {
        if (!found.has(v)) { found.add(v); vids.push(v); }
      });
      root.querySelectorAll('*').forEach(el => {
        if (el.shadowRoot) vids.push(...findAllVideos(el.shadowRoot, found));
        if (el.tagName === 'IFRAME') {
          try { vids.push(...findAllVideos(el.contentDocument, found)); } catch {}
        }
      });
    } catch {}
    return vids;
  }

  // ✅ 반복 감시 및 초기화
  const observer = new MutationObserver(mutations => {
    for (const m of mutations) {
      m.addedNodes.forEach(node => {
        if (node.tagName === 'VIDEO') {
          // 새 video 발견 시 초기화 필요하면 처리
        } else if (node.querySelectorAll) {
          node.querySelectorAll('video').forEach(v => {
            // 새 video 초기화 처리
          });
        }
      });
    }
  });
  observer.observe(document.body, { childList: true, subtree: true });

  // load 이벤트 후 초기 scan
  window.addEventListener('load', () => setTimeout(() => findAllVideos(), 1000));

})();