// ==UserScript==
// @name Mobile Video Controller (Movable + Speed Menu + Skip Durations + Speed Slider + Fullscreen)
// @namespace https://your.namespace
// @version 5.1.0
// @description Overlay with movable skip/speed controls. Custom skip, smooth speed slider, fullscreen support. Play/Pause label replaces 0x and toggles playback (restores last rate).
// @match *://*/*
// @grant none
// ==/UserScript==
(function () {
'use strict';
const MIN_FRAC_PAUSED = 0.20;
const MIN_FRAC_PLAYING = 0.08;
const EDGE = 6;
const DRAG_OFFSET_Y = -20;
// Fixed menu speeds (0 replaced by Play/Pause menu item)
const SPEEDS = [0, 2, 1.75, 1.5, 1.25, 1];
// Definable skip duration options for the long-press menu
const SKIP_DURATIONS = [5, 10, 15, 30, 60];
let activeVideo = null;
let uiWrap = null;
let manualDrag = false;
let menu, speedBtn, hideSpeedMenu, backdrop, skipMenu;
let longPressDirection = 0; // To store skip direction (-1 or 1)
// --- Persistent settings ---
const skipKey = "mvc_skip_seconds";
let skipSeconds = Number(localStorage.getItem(skipKey)) || 10;
// store last non-zero rate so Play/Pause can restore it
const LAST_RATE_KEY = "mvc_last_rate";
if (!localStorage.getItem(LAST_RATE_KEY)) localStorage.setItem(LAST_RATE_KEY, "1");
function saveSkip(v) {
skipSeconds = v;
localStorage.setItem(skipKey, String(v));
}
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const clampTime = (v, t) => {
const d = Number.isFinite(v.duration) ? v.duration : Infinity;
return clamp(t, 0, d);
};
// ---------- UI ----------
function createUI() {
uiWrap = document.createElement('div');
uiWrap.style.cssText = `
position: fixed;
left: 12px; top: 12px;
z-index: 2147483647;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
display: none;
pointer-events: auto;
`;
const panel = document.createElement('div');
panel.style.cssText = `
display: flex; flex-direction: column; align-items: center;
gap: 2px; background: transparent; color: #fff;
border-radius: 2px; touch-action: none; user-select: none;
pointer-events: auto;
`;
const dragHandle = document.createElement('div');
dragHandle.style.cssText = `
width: 60px; height: 10px; border-radius: 3px;
background: rgba(200, 200, 200, 0.2);
margin-bottom: 4px; cursor: grab;
`;
const row = document.createElement('div');
row.style.cssText = `
display: flex; align-items: center; gap: 8px;
padding: 1px 4px; background: transparent;
pointer-events: auto;
`;
const btnStyle = `
appearance: none;
border: 0; border-radius: 10px;
padding: 6px 10px; font-size: 20px; font-weight: 600;
color: #fff; background: rgba(43,43,43,0.25); min-width: 56px;
text-align: center;
line-height: 1; pointer-events: auto;
`;
const mkBtn = (txt, title, minW) => {
const b = document.createElement('button');
b.textContent = txt;
b.title = title || '';
b.style.cssText = btnStyle;
if (minW) b.style.minWidth = minW;
['pointerdown','pointerup','click','touchstart','touchend'].forEach(type => {
b.addEventListener(type, e => { e.stopPropagation(); });
});
return b;
};
const rewind = mkBtn(`⟲ ${skipSeconds}`, 'Rewind');
speedBtn = mkBtn('1x', 'Playback speed', '72px');
const forward = mkBtn(`${skipSeconds} ⟳`, 'Forward');
// Backdrop
backdrop = document.createElement('div');
backdrop.style.cssText = `
display: none;
position: fixed;
left: 0; top: 0;
width: 100%; height: 100%;
z-index: 2147483646;
background: transparent;
pointer-events: auto;
touch-action: none;
`;
document.body.appendChild(backdrop);
function hideSkipMenu() {
if (skipMenu) skipMenu.style.display = 'none';
if (menu.style.display === 'none') {
backdrop.style.display = 'none';
}
}
['pointerdown','click','touchstart','touchend'].forEach(ev => {
backdrop.addEventListener(ev, e => {
e.stopPropagation();
e.preventDefault();
hideSpeedMenu();
hideSkipMenu();
}, true);
});
// Speed menu
menu = document.createElement('div');
menu.className = 'mvc-speed-menu';
menu.style.cssText = `
display: none; position: fixed;
background: rgba(0,0,0,0.50); border-radius: 5px;
z-index: 2147483647; min-width: 50px;
max-height: 64vh; overflow-y: auto;
pointer-events: auto; touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
`;
document.body.append(menu);
// Speed options
function makeOpt(sp, isFirst) {
const opt = document.createElement('div');
opt.textContent = (sp === 0) ? `Play/Pause` : `${sp}x`;
opt.dataset.sp = String(sp);
opt.style.cssText = `
color: white;
padding: 0.45em 0.9em; font-size: 15px;
text-align: center; border-top: ${isFirst ? 'none' : '1px solid rgba(255,255,255,0.12)'};
user-select: none;
`;
opt.addEventListener('click', e => {
e.stopPropagation();
e.preventDefault();
const spv = Number(opt.dataset.sp);
if (!activeVideo) {
hideSpeedMenu();
return;
}
if (spv === 0) {
if (activeVideo.paused) {
const last = Number(localStorage.getItem(LAST_RATE_KEY)) || 1;
try { activeVideo.playbackRate = last; } catch (err) {}
try { activeVideo.play(); } catch (err) {}
speedBtn.firstChild.nodeValue = `${(last).toFixed(2)}x`.replace(/\.00$/,'');
speedBtn.removeAttribute('data-zero');
localStorage.setItem(LAST_RATE_KEY, String(last));
} else {
try { localStorage.setItem(LAST_RATE_KEY, String(activeVideo.playbackRate)); } catch (err) {}
try { activeVideo.pause(); } catch (err) {}
speedBtn.setAttribute('data-zero', '1');
speedBtn.firstChild.nodeValue = 'Play';
}
} else {
try { activeVideo.playbackRate = spv; } catch (err) {}
try { localStorage.setItem(LAST_RATE_KEY, String(spv)); } catch (err) {}
speedBtn.firstChild.nodeValue = `${spv}x`;
speedBtn.removeAttribute('data-zero');
}
highlightSelected(spv);
hideSpeedMenu();
});
return opt;
}
let firstOption = true;
for (const sp of SPEEDS) {
menu.appendChild(makeOpt(sp, firstOption));
firstOption = false;
}
function highlightSelected(sp) {
Array.from(menu.children).forEach(el => {
el.style.background = 'none';
el.style.fontWeight = 'normal';
});
const sel = Array.from(menu.children).find(el => Number(el.dataset.sp) === Number(sp));
if (sel) {
sel.style.background = 'rgba(255,255,255,0.14)';
sel.style.fontWeight = '700';
}
}
function showAndMeasure(el) {
const originalDisplay = el.style.display;
el.style.display = originalDisplay === 'none' ? 'block' : originalDisplay;
el.style.visibility = 'hidden';
el.style.left = '-9999px';
el.style.top = '-9999px';
const rect = el.getBoundingClientRect();
el.style.display = 'none'; // hide it back
return { w: rect.width, h: rect.height };
}
function placeMenu() {
const { w: menuW, h: menuH } = showAndMeasure(menu);
const rect = speedBtn.getBoundingClientRect();
let left = Math.round(rect.left + rect.width / 2 - menuW / 2);
if (left < EDGE) left = EDGE;
if (left + menuW > window.innerWidth - EDGE) left = window.innerWidth - menuW - EDGE;
let openAbove = rect.top - menuH - 6 >= EDGE;
let top = openAbove ? Math.max(EDGE, rect.top - menuH - 6) : rect.bottom + 6;
let openBelow = !openAbove;
const items = Array.from(menu.children);
const minSpeed = Math.min(...SPEEDS);
const firstVal = items.length ? Number(items[0].dataset.sp) : NaN;
if (openBelow) {
if (firstVal === minSpeed) items.reverse().forEach(el => menu.appendChild(el));
} else {
if (firstVal !== minSpeed) items.reverse().forEach(el => menu.appendChild(el));
}
Object.assign(menu.style, { left: left + 'px', top: top + 'px', visibility: 'visible' });
}
hideSpeedMenu = function() {
menu.style.display = 'none';
if (skipMenu && skipMenu.style.display === 'none') {
backdrop.style.display = 'none';
}
};
function toggleSpeedMenu() {
const open = menu.style.display === 'block';
hideSpeedMenu();
if (!open) {
placeMenu();
menu.style.display = 'block';
backdrop.style.display = 'block';
if (activeVideo) highlightSelected(Number(activeVideo.playbackRate) || 1);
}
}
// Create Skip Duration Menu
skipMenu = document.createElement('div');
skipMenu.style.cssText = `
display: none; position: fixed; flex-direction: row; align-items: center;
gap: 5px; padding: 5px;
background: rgba(0,0,0,0.65); border-radius: 10px;
z-index: 2147483647; pointer-events: auto; touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
`;
// --- Modified: Made buttons a little bigger ---
const skipBtnStyle = `
appearance: none; border: 0; border-radius: 8px;
padding: 6px 14px; font-size: 16px; font-weight: 600;
color: #fff; background: rgba(70,70,70,0.5);
line-height: 1.2; pointer-events: auto; user-select: none;
`;
SKIP_DURATIONS.forEach(duration => {
const opt = document.createElement('button');
opt.textContent = `${duration}s`;
opt.style.cssText = skipBtnStyle;
opt.addEventListener('click', e => {
e.stopPropagation();
if (activeVideo && longPressDirection !== 0) {
activeVideo.currentTime = clampTime(
activeVideo,
activeVideo.currentTime + longPressDirection * duration
);
}
});
skipMenu.appendChild(opt);
});
// --- New: Custom button to set main skip value ---
const customSkipBtn = document.createElement('button');
customSkipBtn.textContent = '✎ Set';
customSkipBtn.title = 'Set default skip time';
customSkipBtn.style.cssText = skipBtnStyle;
customSkipBtn.style.background = 'rgba(50, 80, 130, 0.6)'; // Different color to stand out
customSkipBtn.addEventListener('click', e => {
e.stopPropagation();
const choice = prompt("Set new default skip seconds:", skipSeconds);
if (choice != null && choice !== "" && !isNaN(choice)) {
saveSkip(Number(choice));
rewind.textContent = `⟲ ${skipSeconds}`;
forward.textContent = `${skipSeconds} ⟳`;
}
hideSkipMenu(); // Close menu after setting
});
skipMenu.appendChild(customSkipBtn);
document.body.appendChild(skipMenu);
function showSkipMenu() {
const { w: menuW, h: menuH } = showAndMeasure(skipMenu);
const rect = uiWrap.getBoundingClientRect();
// --- Modified: Default to above but closer, fallback to below ---
let top = rect.top - menuH - 4; // 4px spacing above
// Fallback to below if no space above
if (top < EDGE) {
top = rect.bottom + 8;
}
let left = Math.round(rect.left + rect.width / 2 - menuW / 2);
left = clamp(left, EDGE, window.innerWidth - menuW - EDGE);
top = clamp(top, EDGE, window.innerHeight - menuH - EDGE);
Object.assign(skipMenu.style, {
left: `${left}px`,
top: `${top}px`,
visibility: 'visible',
display: 'flex'
});
backdrop.style.display = 'block';
}
// ---------- Skip handling ----------
function doSkip(dir) {
if (activeVideo) {
activeVideo.currentTime = clampTime(activeVideo, activeVideo.currentTime + dir * skipSeconds);
}
}
rewind.onclick = e => { e.preventDefault(); doSkip(-1); };
forward.onclick = e => { e.preventDefault(); doSkip(1); };
const setupLongPress = (btn, dir) => {
let pressTimer;
btn.addEventListener("pointerdown", () => {
pressTimer = setTimeout(() => {
longPressDirection = dir;
showSkipMenu();
}, 600);
});
btn.addEventListener("pointerup", () => clearTimeout(pressTimer));
btn.addEventListener("pointerleave", () => clearTimeout(pressTimer));
btn.addEventListener("pointercancel", () => clearTimeout(pressTimer));
};
setupLongPress(rewind, -1);
setupLongPress(forward, 1);
// ---------- Speed handling (click menu OR vertical drag) ----------
let isSliding = false;
let dragStartY = null, dragStartRate = null, moved = 0;
speedBtn.addEventListener("pointerdown", e => {
e.preventDefault();
dragStartY = e.clientY;
dragStartRate = activeVideo ? activeVideo.playbackRate : 1;
moved = 0;
isSliding = false;
try { speedBtn.setPointerCapture(e.pointerId); } catch (err) {}
});
speedBtn.addEventListener("pointermove", e => {
if (dragging || dragStartY == null || !activeVideo) return; // এই লাইনটি পরিবর্তন করা হয়েছে
const dy = dragStartY - e.clientY;
moved += Math.abs(e.movementY || (dragStartY - e.clientY));
if (moved > 6) isSliding = true;
if (!isSliding) return;
let newRate = dragStartRate + dy * 0.005;
newRate = clamp(newRate, 0.1, 6);
activeVideo.playbackRate = newRate;
speedBtn.firstChild.nodeValue = newRate.toFixed(2) + "x";
try { localStorage.setItem(LAST_RATE_KEY, String(newRate)); } catch (err) {}
speedBtn.removeAttribute('data-zero');
});
speedBtn.addEventListener("pointerup", e => {
try { speedBtn.releasePointerCapture(e.pointerId); } catch (err) {}
dragStartY = null;
dragStartRate = null;
if (isSliding) {
isSliding = false;
return;
}
});
speedBtn.addEventListener("click", e => {
e.preventDefault();
if (speedBtn.getAttribute('data-zero') === '1') {
if (!activeVideo) return;
if (activeVideo.paused) {
const last = Number(localStorage.getItem(LAST_RATE_KEY)) || 1;
try { activeVideo.playbackRate = last; } catch (err) {}
try { activeVideo.play(); } catch (err) {}
speedBtn.firstChild.nodeValue = `${(last).toFixed(2)}x`.replace(/\.00$/,'');
speedBtn.removeAttribute('data-zero');
} else {
try { localStorage.setItem(LAST_RATE_KEY, String(activeVideo.playbackRate)); } catch (err) {}
try { activeVideo.pause(); } catch (err) {}
speedBtn.firstChild.nodeValue = 'Play';
}
return;
}
toggleSpeedMenu();
});
row.append(rewind, speedBtn, forward);
panel.append(dragHandle, row);
uiWrap.append(panel);
document.body.appendChild(uiWrap);
// Dragging for the whole widget
let dragging = false;
dragHandle.onpointerdown = e => {
dragging = true;
try { dragHandle.setPointerCapture(e.pointerId); } catch (err) {}
moveUnderFinger(e);
manualDrag = true;
e.preventDefault();
e.stopPropagation();
};
dragHandle.onpointermove = e => {
if (!dragging) return;
moveUnderFinger(e);
e.preventDefault();
e.stopPropagation();
};
dragHandle.onpointerup = e => { dragging = false; };
dragHandle.onpointercancel = () => { dragging = false; };
function moveUnderFinger(e) {
const w = uiWrap.offsetWidth, h = uiWrap.offsetHeight;
const x = clamp(e.clientX - w / 2, EDGE, window.innerWidth - w - EDGE);
const y = clamp(e.clientY - h / 2 - DRAG_OFFSET_Y, EDGE, window.innerHeight - h - EDGE);
uiWrap.style.left = x + 'px';
uiWrap.style.top = y + 'px';
uiWrap.style.right = 'auto';
uiWrap.style.bottom = 'auto';
}
// --- Fullscreen support: move UI/menu/backdrop into fullscreen container ---
function getFullscreenElement() {
return document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement || null;
}
function getFullscreenContainer(fsEl) {
if (!fsEl) return null;
try {
if (fsEl.tagName && fsEl.tagName.toLowerCase() === 'video' && fsEl.parentElement) {
return fsEl.parentElement;
}
if (fsEl.shadowRoot) return fsEl.shadowRoot;
return fsEl;
} catch (e) { return fsEl; }
}
function moveToContainer(container) {
if (!container) return;
try {
const append = node => {
if (!node) return;
if (container instanceof ShadowRoot) container.appendChild(node);
else if (container.appendChild) container.appendChild(node);
};
append(uiWrap);
append(menu);
append(backdrop);
append(skipMenu);
} catch (e) {}
}
function enterFullscreenMode(fsEl) {
if (!fsEl) return;
const container = getFullscreenContainer(fsEl);
if (!container) return;
moveToContainer(container);
uiWrap.style.position = 'absolute';
menu.style.position = 'absolute';
backdrop.style.position = 'absolute';
skipMenu.style.position = 'absolute';
uiWrap.style.zIndex = 2147483647;
menu.style.zIndex = 2147483647;
skipMenu.style.zIndex = 2147483647;
backdrop.style.zIndex = 2147483646;
positionOnVideo();
}
function exitFullscreenMode() {
try {
document.body.appendChild(uiWrap);
document.body.appendChild(menu);
document.body.appendChild(backdrop);
document.body.appendChild(skipMenu);
} catch (e) {}
uiWrap.style.position = 'fixed';
menu.style.position = 'fixed';
backdrop.style.position = 'fixed';
skipMenu.style.position = 'fixed';
uiWrap.style.zIndex = 2147483647;
menu.style.zIndex = 2147483647;
skipMenu.style.zIndex = 2147483647;
backdrop.style.zIndex = 2147483646;
positionOnVideo();
}
function onFullScreenChange() {
const fsEl = getFullscreenElement();
if (fsEl) enterFullscreenMode(fsEl);
else exitFullscreenMode();
}
document.addEventListener('fullscreenchange', onFullScreenChange);
document.addEventListener('webkitfullscreenchange', onFullScreenChange);
document.addEventListener('mozfullscreenchange', onFullScreenChange);
document.addEventListener('MSFullscreenChange', onFullScreenChange);
onFullScreenChange();
}
// ---------- Video handling ----------
function visibleArea(v) {
const r = v.getBoundingClientRect();
const iw = window.innerWidth, ih = window.innerHeight;
const w = Math.max(0, Math.min(r.right, iw) - Math.max(r.left, 0));
const h = Math.max(0, Math.min(r.bottom, ih) - Math.max(r.top, 0));
return w * h;
}
const isPlaying = v => !v.paused && !v.ended && v.readyState > 2;
function pickCandidate() {
const vids = Array.from(document.querySelectorAll('video'));
if (!vids.length) return null;
let best = null, bestScore = -1, bestFrac = 0;
const viewArea = window.innerWidth * window.innerHeight;
for (const v of vids) {
const area = visibleArea(v);
if (area <= 0) continue;
const frac = area / viewArea;
const playing = isPlaying(v);
const score = area + (playing ? viewArea : 0);
if (score > bestScore) { best = v; bestScore = score; bestFrac = frac; }
}
if (!best) return null;
const minFrac = isPlaying(best) ? MIN_FRAC_PLAYING : MIN_FRAC_PAUSED;
if (bestFrac >= minFrac) return best;
return null;
}
function setActiveVideo(v) {
if (activeVideo === v) return;
activeVideo = v;
if (!activeVideo) {
uiWrap.style.display = 'none';
return;
}
if (!document.body.contains(uiWrap)) document.body.appendChild(uiWrap);
uiWrap.style.display = 'block';
manualDrag = false;
hideSpeedMenu();
positionOnVideo();
try {
if (speedBtn && speedBtn.getAttribute('data-zero') === '1') {
speedBtn.textContent = activeVideo.paused ? 'Play' : 'Pause';
} else {
speedBtn.textContent = (activeVideo && activeVideo.playbackRate) ?
activeVideo.playbackRate.toFixed(2) + 'x' : '1x';
}
} catch (e) {}
}
function positionOnVideo() {
if (!activeVideo || manualDrag) return;
const r = activeVideo.getBoundingClientRect();
const w = uiWrap.offsetWidth, h = uiWrap.offsetHeight;
if (!(r && r.width > 0 && r.height > 0)) return;
let x = r.right - w - 40;
let y = r.bottom - h - 10;
x = clamp(x, EDGE, window.innerWidth - w - EDGE);
y = clamp(y, EDGE, window.innerHeight - h - EDGE);
uiWrap.style.left = x + 'px';
uiWrap.style.top = y + 'px';
}
function evaluateActive() {
const cand = pickCandidate();
setActiveVideo(cand);
}
function tick() {
try {
positionOnVideo();
evaluateActive();
} catch (e) {}
requestAnimationFrame(tick);
}
function observeVideos() {
const io = new IntersectionObserver(() => evaluateActive(), { threshold: [0,0.25,0.5,0.75,1] });
const observed = new WeakSet();
const attachIO = () => {
document.querySelectorAll('video').forEach(v => {
if (!observed.has(v)) { io.observe(v); observed.add(v); }
});
};
attachIO();
new MutationObserver(attachIO).observe(document.body, { childList: true, subtree: true });
setInterval(evaluateActive, 1500);
addEventListener('resize', positionOnVideo);
addEventListener('scroll', () => { evaluateActive(); positionOnVideo(); }, { passive: true });
}
function init() {
createUI();
observeVideos();
evaluateActive();
positionOnVideo();
requestAnimationFrame(tick);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, { once: true });
else init();
})();