Mobile Video Seek Gesture

모바일 브라우저에서 좌우 스와이프 제스처로 동영상 탐색 및 길게 눌러 2배속 재생

// ==UserScript==
// @name         Mobile Video Seek Gesture
// @namespace    http://tampermonkey.net/
// @version      5.1
// @description  모바일 브라우저에서 좌우 스와이프 제스처로 동영상 탐색 및 길게 눌러 2배속 재생
// @author       사용자
// @license      MIT
// @match        *://*/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const userPlaybackRates = new Map(); // 사용자 설정 배속 저장
    let longPressTimeout = null; // 길게 누름 감지

    // 제스처 공통 인터페이스 추상화
    function attachGesture(videoOrPlayer) {
        if (videoOrPlayer._gestureAdded) return;
        videoOrPlayer._gestureAdded = true;

        const isNativeVideo = videoOrPlayer instanceof HTMLVideoElement;
        const isVideoJS = !isNativeVideo
            && typeof videoOrPlayer.currentTime === 'function'
            && typeof videoOrPlayer.playbackRate === 'function';

        function getCurrentTime() {
            return isNativeVideo ? videoOrPlayer.currentTime : videoOrPlayer.currentTime();
        }
        function setCurrentTime(t) {
            if (isNativeVideo) videoOrPlayer.currentTime = t;
            else videoOrPlayer.currentTime(t);
        }
        function getDuration() {
            return isNativeVideo ? videoOrPlayer.duration : videoOrPlayer.duration();
        }
        function getPlaybackRate() {
            return isNativeVideo ? videoOrPlayer.playbackRate : videoOrPlayer.playbackRate();
        }
        function setPlaybackRate(rate) {
            if (isNativeVideo) videoOrPlayer.playbackRate = rate;
            else videoOrPlayer.playbackRate(rate);
        }
        function getContainer() {
            if (isNativeVideo) return videoOrPlayer.parentElement;
            else return videoOrPlayer.el();
        }

        const container = getContainer();

        // 오버레이 생성
        if (container.overlay) container.overlay.remove();
        const overlay = document.createElement('div');
        overlay.style.position = 'absolute';
        overlay.style.top = '50%';
        overlay.style.left = '50%';
        overlay.style.transform = 'translate(-50%, -50%)';
        overlay.style.padding = '10px 20px';
        overlay.style.backgroundColor = 'rgba(0,0,0,0.7)';
        overlay.style.color = '#fff';
        overlay.style.fontSize = '18px';
        overlay.style.textAlign = 'center';
        overlay.style.borderRadius = '8px';
        overlay.style.zIndex = '9999';
        overlay.style.display = 'none';
        overlay.style.lineHeight = '1.5'; // 줄 간격 설정
        container.appendChild(overlay);
        container.overlay = overlay;  // 비디오에 오버레이 속성 추가

        // 제스처 상태
        let startX = 0, initialTime = 0, seeking = false, timeChange = 0;
        let isSpeedingUp = false; // 현재 배속 상태 확인
        let movedEnoughForSeek = false; // 스와이프 감지 여부

        // 시간 포맷
        // 시간을 시:분:초 형식으로 변환
        function formatCurrentTime(seconds) {
            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 formatTimeChange(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 onTouchStart(e) {
            startX = e.touches[0].clientX;
            initialTime = getCurrentTime();
            seeking = true;
            movedEnoughForSeek = false; // 초기화
            overlay.style.display = 'block';

            // 길게 누르면 배속 시작
            // Video.js에서는 길게 누름 기능 적용 안함
            if (!isVideoJS) {
                longPressTimeout = setTimeout(() => {
                    if (!movedEnoughForSeek) { // 탐색 중이 아닐 때만 배속 적용
                        userPlaybackRates.set(videoOrPlayer, getPlaybackRate()); // 기존 배속 저장
                        setPlaybackRate(2.0); // 2배속
                        overlay.innerHTML = `<div>2x Speed</div>`;
                        isSpeedingUp = true;
                    }
                }, 500); // 0.5초 이상 누르면 배속
            }
        }

        // 터치 이동 이벤트
        function onTouchMove(e) {
            if (!seeking || isSpeedingUp) return;
            const deltaX = e.touches[0].clientX - startX;
            if (Math.abs(deltaX) > 10) { // 일정 거리 이상 움직이면 탐색 모드로 간주
                movedEnoughForSeek = true;
                clearTimeout(longPressTimeout); // 길게 누름 취소
            }
            timeChange = deltaX * 0.05; // 민감도 조정
            let newTime = initialTime + timeChange;
            // 비디오 길이를 넘지 않도록 시간 범위 제한
            newTime = Math.max(0, Math.min(newTime, getDuration()));
            overlay.innerHTML = `
                <div>${formatCurrentTime(newTime)}</div>
                <div>(${formatTimeChange(timeChange)})</div>
            `;
        }

        // 터치 종료 이벤트
        function onTouchEnd() {
            seeking = false;
            clearTimeout(longPressTimeout); // 길게 누름 감지 중단
            longPressTimeout = null; // longPressTimeout 초기화

            if (isSpeedingUp) {
                setPlaybackRate(userPlaybackRates.get(videoOrPlayer) || 1.0); // 원래 속도로 복귀
                isSpeedingUp = false;
            } else if (movedEnoughForSeek) {
                let newTime = initialTime + timeChange;
                // 비디오 길이를 넘지 않도록 시간 범위 제한
                newTime = Math.max(0, Math.min(newTime, getDuration()));
                setCurrentTime(newTime);
            }

            // 오버레이 숨기기 - 바로 숨겨짐 
            overlay.style.display = 'none';
            overlay.innerHTML = ''; // 이전에 표시된 내용도 비움
        }

        container.addEventListener('touchstart', onTouchStart);
        container.addEventListener('touchmove', onTouchMove);
        container.addEventListener('touchend', onTouchEnd);

        if (isNativeVideo) {
            videoOrPlayer.addEventListener('ratechange', () => {
                if (!isSpeedingUp) userPlaybackRates.set(videoOrPlayer, videoOrPlayer.playbackRate);
            });
        }

        if (isVideoJS) {
            videoOrPlayer.on('ratechange', () => {
                userPlaybackRates.set(videoOrPlayer, videoOrPlayer.playbackRate());
            });
        }
    }

    // 재귀적 Shadow DOM 포함 video 탐색
    function findAllVideos(root = document) {
        const videos = Array.from(root.querySelectorAll('video'));
        root.querySelectorAll('*').forEach(el => {
            if (el.shadowRoot) videos.push(...findAllVideos(el.shadowRoot));
        });
        return videos;
    }

    // 모든 비디오에 제스처 추가
    function scanVideos() {
        findAllVideos().forEach(v => attachGesture(v));
        if (typeof videojs !== 'undefined') {
            Object.values(videojs.getAllPlayers()).forEach(p => attachGesture(p));
        }
    }

    // DOM 변경 감지 및 비디오 발견 시 제스처 추가
    const observer = new MutationObserver(scanVideos);
    observer.observe(document.body, { childList: true, subtree: true });

    // 페이지 로딩 시 비디오 탐색
    window.addEventListener('load', scanVideos);
})();