Mobile Video Seek Gesture

모바일에서 좌우 스와이프 동영상 탐색 + 길게 눌러 2배속

当前为 2025-11-04 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Mobile Video Seek Gesture
// @namespace    http://tampermonkey.net/
// @version      7.1
// @description  모바일에서 좌우 스와이프 동영상 탐색 + 길게 눌러 2배속
// @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 {}
      },
    };
  }

  // ✅ 터치 이벤트 감지 대상 찾기 (Shadow DOM 포함)
  function findGestureTarget(video) {
    const candidates = [video, video.parentElement];

    // 부모 요소와 Shadow DOM 포함
    let root = video.parentElement;
    while (root) {
      candidates.push(root);
      if (root.shadowRoot) candidates.push(root.shadowRoot);
      root = root.parentElement;
    }

    // candidates 중 pointer-events가 유효한 첫 요소 반환
    for (const el of candidates) {
      try {
        const style = el instanceof ShadowRoot ? { pointerEvents: 'auto' } : getComputedStyle(el);
        if (style.pointerEvents !== 'none') return el;
      } catch {}
    }

    // 없으면 body fallback
    return document.body;
  }

  // ✅ 오버레이 생성
  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}`;
    }
  }

  // ✅ 제스처 부착
  function attachGesture(video) {
    if (video._gestureBound) return;
    video._gestureBound = true;

    const ctrl = createVideoController(video);
    const target = findGestureTarget(video);

    let startX=0, initialTime=0, timeChange=0, seeking=false, isSpeedingUp=false, movedEnough=false;

    // 터치 시작
    target.addEventListener('touchstart', e => {
      if (e.touches.length!==1) return;
      startX = e.touches[0].clientX;
      initialTime = ctrl.currentTime;
      seeking = false;
      movedEnough = false;

      longPressTimeout = setTimeout(()=>{
        if(!movedEnough && !isSpeedingUp){
          userPlaybackRates.set(video, ctrl.playbackRate);
          ctrl.playbackRate = 2.0; // 배속 설정
          showOverlay('2x Speed');
          isSpeedingUp = true;
        }
      }, 500);
    }, {passive:true});

    // 터치 이동
    target.addEventListener('touchmove', e=>{
      const deltaX = e.touches[0].clientX - startX;
      if(Math.abs(deltaX)>10){
        seeking=true;
        movedEnough=true;
        clearTimeout(longPressTimeout);
      }
      if(seeking && !isSpeedingUp){
        timeChange = deltaX*0.05; // 민감도 값 조정
        const newTime = Math.max(0, Math.min(initialTime+timeChange, ctrl.duration));
        ctrl.currentTime = newTime;
        showOverlay(`${formatTime(newTime)}<br>(${formatDelta(timeChange)})`);
      }
    }, {passive:true});

    // 터치 종료
    function endGesture(){
      clearTimeout(longPressTimeout);
      if(isSpeedingUp){
        ctrl.playbackRate = userPlaybackRates.get(video)||1;
        isSpeedingUp=false;
      }
      seeking=false;
      hideOverlay();
    }

    target.addEventListener('touchend', endGesture);
    target.addEventListener('touchcancel', endGesture);

    // 일반 HTML5 비디오 배속 변경 감지
    video.addEventListener('ratechange', () => {
      if (!isSpeedingUp) userPlaybackRates.set(video, ctrl.playbackRate);
    });

    // Video.js 등 배속 변경 감지 (안전 단축 버전)
    if (typeof videojs !== 'undefined' && video.classList.contains('video-js')) {
      try {
        const players = (typeof videojs.getPlayers === 'function') ? videojs.getPlayers() : {};
        const player = players && video.id ? players[video.id] : null;
        if (player && typeof player.on === 'function') {
          player.on('ratechange', () => {
            if (!isSpeedingUp) {
              try {
                const rate = (typeof player.playbackRate === 'function') ? player.playbackRate() : player.playbackRate;
                userPlaybackRates.set(video, rate ?? ctrl.playbackRate);
              } catch {}
            }
          });
        }
      } catch {
        // videojs 구조나 player 접근 중 오류 나면 그냥 무시
      }
    }
  }

  // ✅ Shadow DOM 포함 탐색 (iframe도 탐색)
  function findAllVideos(root = document, found = new Set()) {
    const vids = [];
    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 {}
      }
    });
    return vids;
  }

  // ✅ 반복 감시 및 초기화
  function scanVideos(){
    findAllVideos().forEach(v=>attachGesture(v));
    if (typeof videojs !== 'undefined') {
      try {
        const players = (typeof videojs.getAllPlayers === 'function')
          ? videojs.getAllPlayers()
          : (typeof videojs.getPlayers === 'function')
            ? videojs.getPlayers()
            : {};
        Object.values(players).forEach(p=>{
          const el = p?.el?.();
          const v = el?.querySelector?.('video');
          if (v) attachGesture(v);
        });
      } catch {}
    }
  }

  const observer = new MutationObserver(scanVideos);
  observer.observe(document.body, {childList:true, subtree:true});
  window.addEventListener('load', ()=>setTimeout(scanVideos,1000));

})();