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));
})();