모바일에서 좌우 스와이프 동영상 탐색 + 길게 눌러 2배속
当前为
// ==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));
})();