Greasy Fork is available in English.
A premium, gesture-driven video controller for mobile. Swipe to seek, long-press for 2x speed, and precision zoom—all in a sleek, "Media Card" UI.
// ==UserScript==
// @name GlideVideo: Pro Mobile Touch Controller
// @namespace https://github.com/quantavil/userscript/mobile-video-controller
// @version 1.5.4
// @description A premium, gesture-driven video controller for mobile. Swipe to seek, long-press for 2x speed, and precision zoom—all in a sleek, "Media Card" UI.
// @match *://*/*
// @grant none
// @license MIT
// @run-at document-start
// ==/UserScript==
// src/config.js – Static configuration for MobileVideoController
'use strict';
const MVC_CONFIG = {
MIN_VIDEO_AREA: 150 * 150,
MIN_VIDEO_HEIGHT: 50,
EDGE: 10,
DEFAULT_RIGHT_OFFSET: 50,
UI_TALL_VIDEO_OFFSET: 82,
INTERACTION_TIMEOUT: 4500,
VISIBILITY_GUARDIAN_DELAY: 500,
UI_FADE_TIMEOUT: 3500,
UI_FADE_OPACITY: 0,
LONG_PRESS_DURATION_MS: 300,
DRAG_THRESHOLD: 15,
SLIDER_SENSITIVITY: 0.003,
SLIDER_POWER: 1.2,
DEFAULT_SPEEDS: [0, 1, 1.25, 1.5, 1.75, 2],
DEFAULT_SKIP_DURATIONS: [5, 10, 15, 30, 60],
DEFAULT_SNAP_POINTS: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.25, 2.5, 2.75, 3, 3.25],
SNAP_THRESHOLD: 0.05,
SNAP_STRENGTH: 1,
LONG_PRESS_VIBRATE_MS: 15,
INITIAL_EVAL_DELAY: 500,
BACKDROP_POINTER_EVENTS_DELAY: 150,
SPEED_TOAST_FADE_DELAY: 750,
// ECO SETTINGS
MUTATION_DEBOUNCE_MS: 800,
INTERSECTION_THROTTLE_MS: 300,
TIMEUPDATE_THROTTLE_MS: 2000,
SCROLL_END_TIMEOUT: 150,
STORAGE_DEBOUNCE_MS: 2000,
// GESTURE SETTINGS
GESTURE_MOVE_THRESHOLD: 10,
GESTURE_SEEK_SENSITIVITY: 0.1,
GESTURE_LONG_PRESS_DELAY: 500,
GESTURE_SPEED_BOOST: 2.0
};
// src/styles.js – CSS injection for MobileVideoController
'use strict';
const MVC_Styles = {
injectStyles() {
if (document.getElementById('mvc-styles')) return;
if (!document.head) return;
const style = document.createElement('style');
style.id = 'mvc-styles';
style.textContent = `
.mvc-ui-wrap { position:absolute; left:0; top:0; z-index:2147483647; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; display:none; opacity:0; pointer-events:none; transition:opacity .5s ease; will-change:opacity, transform; transform:translate3d(0,0,0); }
/* Card panel */
.mvc-panel { position:relative; display:flex; align-items:center; gap:4px; background:rgba(20, 20, 22, 0.75); color:#fff; padding:4px; backdrop-filter:blur(24px); -webkit-backdrop-filter:blur(24px); border:1px solid rgba(255,255,255,0.08); border-radius:14px; touch-action:none!important; user-select:none; -webkit-user-select:none; pointer-events:auto; cursor:grab; width:fit-content; transform:translate3d(0,0,0); will-change:transform; box-shadow: 0 20px 40px -10px rgba(0,0,0,0.3), inset 0 1px 1px rgba(255,255,255,0.12); }
.mvc-panel::before { content:""; position:absolute; inset:-2.5rem; z-index:-1; background:rgba(0, 0, 0, 0); }
/* Buttons */
.mvc-btn { appearance:none; border:0; border-radius:10px; width:38px; height:32px; padding:0; font-size:13px; font-weight:600; text-align:center; line-height:1; pointer-events:auto; transition:transform .2s cubic-bezier(0.32, 0.72, 0, 1), background-color .15s ease, box-shadow .15s ease; user-select:none; display:flex; align-items:center; justify-content:center; touch-action:none!important; background:rgba(255,255,255,0.04)!important; color:rgba(255,255,255,0.85)!important; box-shadow:inset 0 1px 1px rgba(255,255,255,0.05); }
.mvc-btn svg { fill:currentColor!important; }
.mvc-btn:active { transform:scale(0.96); background:rgba(255,255,255,0.12); box-shadow:inset 0 2px 6px rgba(0,0,0,0.2); }
/* Speed pill */
.mvc-btn-speed { width:auto; padding:0 8px; border-radius:10px; min-width:40px; color:#40c4ff!important; font-size:12px; font-weight:700; border:1px solid rgba(64,196,255,0.25)!important; background:rgba(64,196,255,0.1)!important; box-shadow:inset 0 1px 1px rgba(255,255,255,0.1); }
/* Speed menu list */
.mvc-speed-list { padding:0 !important; overflow:hidden; }
.mvc-speed-list .mvc-menu-opt { margin:0 !important; border-radius:0 !important; border-bottom:1px solid rgba(255,255,255,0.15); padding:8px 12px; }
.mvc-speed-list .mvc-menu-opt:last-child { border-bottom:none; }
/* Colour accents */
.mvc-btn-rewind { color:rgba(255,100,100,0.9)!important; }
.mvc-btn-forward { color:rgba(105,240,174,0.9)!important; }
.mvc-btn.snapped { color:#fff!important; border-color:rgba(255,255,255,0.4); background:rgba(255,255,255,0.2); text-shadow:0 0 8px rgba(255,255,255,0.6); box-shadow:inset 0 1px 3px rgba(255,255,255,0.3); }
.mvc-skip-btn { appearance:none; border:0; border-radius:12px; padding:10px 18px; font-size:15px; font-weight:600; color:#fff; background:rgba(255,255,255,0.1); line-height:1.2; user-select:none; transition:background 0.2s; }
.mvc-skip-btn:active { background:rgba(255,255,255,0.2); }
.mvc-backdrop { display:none; position:fixed; inset:0; z-index:2147483646; background:rgba(0,0,0,.01); touch-action:none; }
.mvc-toast { position:fixed; left:50%; bottom:60px; transform:translateX(-50%) translate3d(0,0,0); background:rgba(20,20,22,0.75); backdrop-filter:blur(24px); border:1px solid rgba(255,255,255,0.08); color:#fff; padding:10px 20px; border-radius:20px; z-index:2147483647; opacity:0; transition:opacity .3s cubic-bezier(0.32, 0.72, 0, 1); pointer-events:none; font-size:14px; font-weight:500; box-shadow: 0 10px 30px -5px rgba(0,0,0,0.3), inset 0 1px 1px rgba(255,255,255,0.12); }
.mvc-speed-toast { position:fixed; transform:translate(-50%,-50%) translate3d(0,0,0); background:rgba(20,20,22,0.75); backdrop-filter:blur(24px); border:1px solid rgba(255,255,255,0.08); color:#fff; padding:12px 24px; border-radius:16px; z-index:2147483647; font-size:24px; font-weight:600; opacity:0; transition:opacity .3s cubic-bezier(0.32, 0.72, 0, 1), color .2s linear; pointer-events:none; will-change:opacity,color; box-shadow: 0 10px 30px -5px rgba(0,0,0,0.3), inset 0 1px 1px rgba(255,255,255,0.12); }
.mvc-speed-toast.snapped { color:#fff!important; text-shadow:0 0 12px rgba(255,255,255,0.5); }
.mvc-menu { display:none; flex-direction:column; position:fixed; background:rgba(24,24,28,0.85); border-radius:16px; backdrop-filter:blur(32px); -webkit-backdrop-filter:blur(32px); border:1px solid rgba(255,255,255,0.08); box-shadow: 0 24px 48px -12px rgba(0,0,0,0.5), inset 0 1px 1px rgba(255,255,255,0.1); z-index:2147483647; min-width:60px; max-height:80vh; overflow-y:auto; pointer-events:auto; touch-action:manipulation; -webkit-tap-highlight-color:transparent; transform:translate3d(0,0,0); padding:4px; }
.mvc-menu-opt { padding:6px 6px; font-size:15px; text-align:center; border-radius:8px; margin:2px 4px; user-select:none; cursor:pointer; transition:background .2s; }
.mvc-menu-opt:active { background:rgba(255,255,255,0.15); }
.mvc-settings-container { min-width: 260px; max-width: 90vw; padding: 12px; display: flex; flex-direction: column; box-sizing: border-box; }
.mvc-settings-section { display: flex; flex-direction: column; width: 100%; }
.mvc-settings-card { background: rgba(255,255,255,0.05); border-radius: 12px; padding: 12px; margin-bottom: 8px; border: 1px solid rgba(255,255,255,0.05); display: flex; flex-direction: column; gap: 12px; }
.mvc-settings-row { display:flex; justify-content:space-between; align-items:center; gap:12px; cursor:default; background:transparent; margin:0; width: 100%; }
.mvc-settings-label { color:rgba(255,255,255,0.9); white-space:nowrap; font-size:14px; font-weight: 500; }
.mvc-settings-slider-wrap { display: flex; align-items: center; gap: 8px; flex-grow: 1; }
.mvc-settings-value { color:rgba(255,255,255,0.7); font-variant-numeric:tabular-nums; min-width:48px; text-align:right; font-size:13px; font-weight: 500; background: rgba(255,255,255,0.1); padding: 4px 8px; border-radius: 6px; }
.mvc-settings-input { width:60px; background:rgba(255,255,255,.12); border:1px solid rgba(255,255,255,0.1); color:white; border-radius:8px; text-align:center; font-size:14px; padding:6px; outline: none; transition: border-color 0.2s; }
.mvc-settings-input:focus { border-color: #34c759; }
.mvc-settings-select { background:rgba(255,255,255,.12); border:1px solid rgba(255,255,255,0.1); color:white; border-radius:8px; font-size:14px; padding:6px 10px; flex-grow:1; outline:none; transition: border-color 0.2s; cursor: pointer; }
.mvc-settings-select:focus { border-color: #34c759; }
.mvc-settings-slider { width:100%; flex-grow:1; accent-color:#34c759; height:4px; border-radius:2px; cursor: pointer; }
.mvc-settings-btn { font-size:13px; font-weight: 500; padding:8px 14px; background:rgba(255,255,255,0.12); color:white; border:none; border-radius:8px; cursor:pointer; white-space:nowrap; transition:background .2s; outline: none; display: inline-flex; justify-content: center; align-items: center; flex: 1; }
.mvc-settings-btn:active { background:rgba(255,255,255,0.25); }
.mvc-btn-icon { background: rgba(64,196,255,0.15); color: #40c4ff; }
.mvc-btn-icon:hover { background: rgba(64,196,255,0.25); }
.mvc-btn-icon:active { background: rgba(64,196,255,0.35); }
.mvc-btn-danger { background: rgba(255,82,82,0.15); color: #ff5252; }
.mvc-btn-danger:hover { background: rgba(255,82,82,0.25); }
.mvc-btn-danger:active { background: rgba(255,82,82,0.35); }
.mvc-settings-section-title { font-size:12px; font-weight:700; color:rgba(235,235,245,0.6); text-transform:uppercase; letter-spacing:0.5px; margin-top:8px; margin-bottom:8px; padding:0 4px; text-align:left; border-top:none; cursor:default; }
/* Gesture overlay */
.mvc-gesture-overlay { position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); background:rgba(20,20,22,0.65); backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px); color:#fff; font-size:18px; font-weight:600; padding:10px 22px; border-radius:14px; text-align:center; z-index:2147483647; display:none; line-height:1.5; pointer-events:none; border:1px solid rgba(255,255,255,0.06); box-shadow: 0 10px 20px -5px rgba(0,0,0,0.2), inset 0 1px 1px rgba(255,255,255,0.1); }
`;
document.head.appendChild(style);
}
};
// src/utils.js – Pure helper methods for MobileVideoController
'use strict';
const MVC_Utils = {
debounce(func, wait) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
},
clamp(v, a, b) { return Math.max(a, Math.min(b, v)); },
clampTime(t) { return this.clamp(t, 0, this.activeVideo?.duration ?? Infinity); },
isPlaying(v) { return v && !v.paused && !v.ended && v.readyState > 2; },
vibrate(ms = 10) {
if (navigator.vibrate) try { navigator.vibrate(ms); } catch (e) {}
},
showToast(message) {
if (!this.ui.toast) return;
this.ui.toast.textContent = message;
this.ui.toast.style.opacity = '1';
clearTimeout(this.timers.toast);
this.timers.toast = setTimeout(() => { this.ui.toast.style.opacity = '0'; }, 1500);
},
showSpeedToast(message, updatePosition = true) {
if (!this.ui.speedToast) return;
if (updatePosition) {
if (this.activeVideo?.isConnected) {
const rr = this.activeVideo.getBoundingClientRect();
this.ui.speedToast.style.top = `${rr.top + rr.height / 2}px`;
this.ui.speedToast.style.left = `${rr.left + rr.width / 2}px`;
} else {
this.ui.speedToast.style.top = '50%';
this.ui.speedToast.style.left = '50%';
}
}
this.ui.speedToast.textContent = message;
this.ui.speedToast.style.opacity = '1';
},
hideSpeedToast() {
clearTimeout(this.timers.speedToast);
this.timers.speedToast = setTimeout(() => {
if (this.ui.speedToast) this.ui.speedToast.style.opacity = '0';
}, MVC_CONFIG.SPEED_TOAST_FADE_DELAY);
},
showAndMeasure(el) {
const prev = { display: el.style.display, visibility: el.style.visibility };
Object.assign(el.style, { display: 'flex', visibility: 'hidden' });
const r = el.getBoundingClientRect();
Object.assign(el.style, prev);
return { w: r.width, h: r.height };
}
};
// src/ui.js – DOM construction & menu logic for MobileVideoController
'use strict';
const MVC_UI = {
// ── Primitive builders ──────────────────────────────────────────────────
createEl(tag, className, props = {}) {
const el = document.createElement(tag);
if (className) el.className = className;
for (const [k, v] of Object.entries(props)) {
if (k === 'style') Object.assign(el.style, v);
else el[k] = v;
}
return el;
},
createSvgIcon(pathData) {
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('width', '16');
svg.setAttribute('height', '16');
svg.setAttribute('fill', 'currentColor');
const path = document.createElementNS(svgNS, 'path');
path.setAttribute('d', pathData);
svg.appendChild(path);
return svg;
},
getIcon(name) {
const paths = {
rewind: 'M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z',
forward: 'M4 18l8.5-6L4 6v12zm9-12v12l8.5-6-8.5-6z',
settings: 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.56-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22l-1.92 3.32c-.12.22-.07.49.12.61l2.03 1.58c-.04.3-.06.61-.06.94 0 .32.02.64.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .43-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.49-.12-.61l-2.03-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z',
close: 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'
};
return this.createSvgIcon(paths[name] || '');
},
// ── Main panel ──────────────────────────────────────────────────────────
createMainUI() {
this.ui.wrap = this.createEl('div', 'mvc-ui-wrap');
this.ui.panel = this.createEl('div', 'mvc-panel');
this.ui.backdrop = this.createEl('div', 'mvc-backdrop');
this.ui.toast = this.createEl('div', 'mvc-toast');
this.ui.speedToast = this.createEl('div', 'mvc-speed-toast');
this.ui.gestureOverlay = this.createEl('div', 'mvc-gesture-overlay');
this.ui.wrap.style.zIndex = '2147483647';
document.body.append(this.ui.backdrop, this.ui.toast, this.ui.speedToast, this.ui.gestureOverlay);
const makeBtn = (content, title, extraClass) => {
const btn = this.createEl('button', `mvc-btn ${extraClass}`);
if (content instanceof Element) btn.appendChild(content);
else btn.textContent = content;
btn.title = title;
btn.addEventListener('click', e => e.stopPropagation());
btn.addEventListener('pointerdown', () => this.showUI(true));
return btn;
};
this.ui.rewindBtn = makeBtn(this.getIcon('rewind'), 'Rewind', 'mvc-btn-rewind');
this.ui.speedBtn = makeBtn('1.0', 'Playback speed', 'mvc-btn-speed');
this.ui.forwardBtn = makeBtn(this.getIcon('forward'), 'Forward', 'mvc-btn-forward');
this.ui.settingsBtn = makeBtn(this.getIcon('settings'), 'Settings', '');
this.ui.panel.append(this.ui.rewindBtn, this.ui.speedBtn, this.ui.forwardBtn, this.ui.settingsBtn);
this.ui.wrap.append(this.ui.panel);
},
// ── Lazy menu builders ──────────────────────────────────────────────────
ensureSkipMenu() {
if (this.ui.skipMenu) return;
this.ui.skipMenu = this.createEl('div', 'mvc-menu');
Object.assign(this.ui.skipMenu.style, {
flexDirection: 'row', gap: '1px', padding: '1px',
flexWrap: 'nowrap', maxWidth: 'none', justifyContent: 'center'
});
MVC_CONFIG.DEFAULT_SKIP_DURATIONS.forEach(duration => {
const opt = this.createEl('button', 'mvc-skip-btn', { textContent: `${duration}s` });
opt.onclick = (e) => {
e.stopPropagation();
if (this.activeVideo && this.longPressDirection) {
this.activeVideo.currentTime = this.clampTime(
this.activeVideo.currentTime + this.longPressDirection * duration
);
this.showUI(true);
opt.style.transform = 'scale(0.9)';
setTimeout(() => opt.style.transform = 'scale(1)', 100);
this.showToast(`Skipped ${duration}s`);
}
};
this.ui.skipMenu.appendChild(opt);
});
const container = document.fullscreenElement || document.webkitFullscreenElement || document.body;
container.appendChild(this.ui.skipMenu);
},
ensureSpeedMenu() {
if (this.ui.speedMenu) return;
this.ui.speedMenu = this.createEl('div', 'mvc-menu mvc-speed-list');
const makeOpt = (sp) => {
const opt = this.createEl('div', 'mvc-menu-opt');
opt.textContent = sp === 0 ? 'Pause' : `${sp.toFixed(2)}x`;
opt.dataset.sp = String(sp);
opt.style.color = sp === 0 ? '#89cff0' : 'white';
opt.style.fontWeight = sp === 0 ? '600' : 'normal';
opt.onclick = () => {
if (!this.activeVideo) return this.hideAllMenus();
const spv = Number(opt.dataset.sp);
if (spv === 0) this.handlePlayPauseClick();
else {
this.setPlaybackRate(spv);
if (this.activeVideo.paused) this.activeVideo.play();
}
this.hideAllMenus();
};
return opt;
};
MVC_CONFIG.DEFAULT_SPEEDS.forEach(sp => this.ui.speedMenu.appendChild(makeOpt(sp)));
const customOpt = this.createEl('div', 'mvc-menu-opt', {
style: { color: '#c5a5ff', fontWeight: '600' }
});
const input = this.createEl('input', '', {
type: 'number', step: 0.05, placeholder: 'Custom',
style: { width: '80%', background: 'transparent', border: 'none', color: 'inherit', textAlign: 'center', outline: 'none', fontSize: '15px' }
});
input.onclick = e => e.stopPropagation();
input.onkeydown = e => e.stopPropagation();
input.onchange = () => {
if (!this.activeVideo) return this.hideAllMenus();
const newRate = parseFloat(input.value);
if (!isNaN(newRate) && newRate > 0 && newRate <= 16) {
this.setPlaybackRate(newRate);
if (this.activeVideo.paused) this.activeVideo.play();
} else this.showToast('Invalid speed entered.');
this.hideAllMenus();
};
customOpt.appendChild(input);
this.ui.speedMenu.appendChild(customOpt);
const container = document.fullscreenElement || document.webkitFullscreenElement || document.body;
container.appendChild(this.ui.speedMenu);
},
ensureSettingsMenu() {
if (this.ui.settingsMenu) return;
this.ui.settingsMenu = this.createEl('div', 'mvc-menu mvc-settings-container');
const createSection = (title) => {
const section = this.createEl('div', 'mvc-settings-section');
const h = this.createEl('div', 'mvc-settings-section-title', { textContent: title });
const card = this.createEl('div', 'mvc-settings-card');
section.append(h, card);
this.ui.settingsMenu.appendChild(section);
return card;
};
const createSliderRow = (labelStr, props, fmt) => {
const row = this.createEl('div', 'mvc-settings-row');
const labelEl = this.createEl('label', 'mvc-settings-label', { textContent: labelStr });
const sliderWrap = this.createEl('div', 'mvc-settings-slider-wrap');
const slider = this.createEl('input', 'mvc-settings-slider', Object.assign({ type: 'range' }, props));
const valueEl = this.createEl('span', 'mvc-settings-value', { textContent: fmt(props.value) });
slider.oninput = (e) => { valueEl.textContent = fmt(e.target.value); if (props.oninput) props.oninput(e.target.value); };
slider.onchange = (e) => { if (props.onchange) props.onchange(e.target.value); };
sliderWrap.append(slider, valueEl);
row.append(labelEl, sliderWrap);
return { row, slider, valueEl };
};
// --- Video Transform Section ---
const transformCard = createSection('Video Transform');
const transformRow1 = this.createEl('div', 'mvc-settings-row');
const ratioLabel = this.createEl('label', 'mvc-settings-label', { textContent: 'Aspect Ratio:' });
const ratioSelect = this.createEl('select', 'mvc-settings-select');
['Fit', 'Fill', 'Stretch'].forEach(r => ratioSelect.add(new Option(r, r.toLowerCase())));
ratioSelect.value = this.settings.transform.ratio;
ratioSelect.onchange = () => {
this.settings.transform.ratio = ratioSelect.value;
this.saveSetting('transform', this.settings.transform);
this.applyVideoTransform();
};
transformRow1.append(ratioLabel, ratioSelect);
const zoomControl = createSliderRow('Zoom Level:', {
min: 0.5, max: 3, step: 0.05, value: this.settings.transform.zoom,
oninput: (v) => { this.settings.transform.zoom = parseFloat(v); this.applyVideoTransform(); },
onchange: () => this.saveSetting('transform', this.settings.transform)
}, v => `${Math.round(v * 100)}%`);
const transformRow2 = this.createEl('div', 'mvc-settings-row');
const rotateBtn = this.createEl('button', 'mvc-settings-btn mvc-btn-icon', { textContent: 'Rotate 90° ↻' });
rotateBtn.onclick = () => {
this.settings.transform.rotation = (this.settings.transform.rotation + 90) % 360;
this.saveSetting('transform', this.settings.transform);
this.applyVideoTransform();
};
const transformResetBtn = this.createEl('button', 'mvc-settings-btn mvc-btn-danger', { textContent: 'Reset Transform' });
transformResetBtn.onclick = () => {
this.saveSetting('transform', { ratio: 'fit', zoom: 1, rotation: 0 });
ratioSelect.value = 'fit';
zoomControl.slider.value = 1;
zoomControl.valueEl.textContent = '100%';
this.applyVideoTransform();
};
transformRow2.append(rotateBtn, transformResetBtn);
transformCard.append(transformRow1, zoomControl.row, transformRow2);
// --- Playback Preferences Section ---
const playbackCard = createSection('Playback Preferences');
const speedRow = this.createEl('div', 'mvc-settings-row');
const speedLabel = this.createEl('label', 'mvc-settings-label', { textContent: 'Default Speed:' });
const speedInput = this.createEl('input', 'mvc-settings-input', { type: 'number', step: 0.05, min: 0.1, value: this.settings.defaultSpeed });
speedInput.onchange = () => {
const val = parseFloat(speedInput.value);
if (!isNaN(val) && val > 0 && val <= 16) this.saveSetting('defaultSpeed', val);
else speedInput.value = this.settings.defaultSpeed;
};
speedRow.append(speedLabel, speedInput);
const skipRow = this.createEl('div', 'mvc-settings-row');
const skipLabel = this.createEl('label', 'mvc-settings-label', { textContent: 'Skip Duration (s):' });
const skipInput = this.createEl('input', 'mvc-settings-input', { type: 'number', min: 1, value: this.settings.skipSeconds });
skipInput.onchange = () => {
const val = parseInt(skipInput.value, 10);
if (!isNaN(val) && val > 0) { this.saveSetting('skipSeconds', val); this.updateSkipButtonText(); }
else skipInput.value = this.settings.skipSeconds;
};
skipRow.append(skipLabel, skipInput);
const gestureRow = this.createEl('div', 'mvc-settings-row');
const gestureLabel = this.createEl('label', 'mvc-settings-label', { textContent: 'Swipe & Hold Gestures:' });
const gestureToggle = this.createEl('input', '', { type: 'checkbox', checked: this.settings.gesturesEnabled });
Object.assign(gestureToggle.style, { width: '18px', height: '18px', accentColor: '#34c759', cursor: 'pointer' });
gestureToggle.onchange = () => this.saveSetting('gesturesEnabled', gestureToggle.checked);
gestureRow.append(gestureLabel, gestureToggle);
playbackCard.append(speedRow, skipRow, gestureRow);
const container = document.fullscreenElement || document.webkitFullscreenElement || document.body;
container.appendChild(this.ui.settingsMenu);
},
updateSkipButtonText() {
this.ui.rewindBtn.title = `Rewind ${this.settings.skipSeconds}s`;
this.ui.forwardBtn.title = `Forward ${this.settings.skipSeconds}s`;
},
updateSpeedDisplay() {
if (!this.activeVideo || !this.ui.speedBtn) return;
if (this.activeVideo.ended) this.ui.speedBtn.textContent = 'Replay';
else if (this.activeVideo.paused) this.ui.speedBtn.textContent = '▶︎';
else this.ui.speedBtn.textContent = `${this.activeVideo.playbackRate.toFixed(1)}x`;
this.saveSetting('last_rate', String(this.activeVideo.playbackRate));
},
// ── Menu placement ──────────────────────────────────────────────────────
placeMenu(menuEl, anchorEl) {
const { w, h } = this.showAndMeasure(menuEl);
const rect = anchorEl.getBoundingClientRect();
let left = rect.left + rect.width / 2 - w / 2;
const openAbove = rect.top - h - 8 >= MVC_CONFIG.EDGE;
let top = openAbove ? rect.top - h - 8 : rect.bottom + 8;
if (menuEl === this.ui.speedMenu || menuEl === this.ui.settingsMenu) {
menuEl.style.flexDirection = openAbove ? 'column-reverse' : 'column';
}
const v = window.visualViewport;
const viewportWidth = v ? v.width : window.innerWidth;
const viewportHeight = v ? v.height : window.innerHeight;
left = this.clamp(left, MVC_CONFIG.EDGE, viewportWidth - w - MVC_CONFIG.EDGE);
top = this.clamp(top, MVC_CONFIG.EDGE, viewportHeight - h - MVC_CONFIG.EDGE);
menuEl.style.left = `${Math.round(left)}px`;
menuEl.style.top = `${Math.round(top)}px`;
},
toggleMenu(menuEl, anchorEl) {
const isOpen = getComputedStyle(menuEl).display !== 'none';
this.hideAllMenus();
if (isOpen) return;
this.placeMenu(menuEl, anchorEl);
menuEl.style.display = 'flex';
this.showBackdrop();
clearTimeout(this.timers.hide);
},
showBackdrop() {
this.ui.backdrop.style.display = 'block';
this.ui.backdrop.style.pointerEvents = 'none';
setTimeout(() => {
if (this.ui.backdrop) this.ui.backdrop.style.pointerEvents = 'auto';
}, MVC_CONFIG.BACKDROP_POINTER_EVENTS_DELAY);
},
hideAllMenus() {
Object.values(this.ui).forEach(el => {
if (el?.classList?.contains && el.classList.contains('mvc-menu')) el.style.display = 'none';
});
this.ui.backdrop.style.display = 'none';
this.showUI(true);
}
};
// src/video.js – Video lifecycle, observers, position, transform/filter & playback
'use strict';
const MVC_Video = {
// ── Settings persistence ────────────────────────────────────────────────
loadSettings() {
const getStored = (k, d) => {
try { const v = localStorage.getItem(k); return v === null ? d : JSON.parse(v); }
catch (e) { return d; }
};
this.settings = {
skipSeconds: getStored('mvc_skipSeconds', 10),
defaultSpeed: getStored('mvc_defaultSpeed', 1.0),
lastRate: parseFloat(getStored('mvc_lastRate', '"1.0"')) || 1.0,
transform: getStored('mvc_transform', { ratio: 'fit', zoom: 1, rotation: 0 }),
gesturesEnabled: getStored('mvc_gesturesEnabled', true)
};
},
saveSetting(key, val) {
this.settings[key] = val;
clearTimeout(this.timers[`save_${key}`]);
this.timers[`save_${key}`] = setTimeout(() => {
try { localStorage.setItem(`mvc_${key}`, JSON.stringify(val)); } catch (e) {}
}, MVC_CONFIG.STORAGE_DEBOUNCE_MS);
},
// ── Active-video selection ──────────────────────────────────────────────
evaluateActive() {
if (this.activeVideo &&
this.isPlaying(this.activeVideo) &&
this.activeVideo.isConnected &&
this.visibleVideos.has(this.activeVideo)) {
const r = this.activeVideo.getBoundingClientRect();
if (r.height > 50 && r.bottom > 0 && r.top < window.innerHeight) return;
}
let best = null, bestScore = -1;
const viewArea = window.innerWidth * window.innerHeight;
for (const v of this.visibleVideos.keys()) {
if (!v.isConnected) { this.visibleVideos.delete(v); continue; }
if (getComputedStyle(v).visibility === 'hidden') continue;
const r = v.getBoundingClientRect();
const area = r.width * r.height;
if (area < MVC_CONFIG.MIN_VIDEO_AREA || r.height < MVC_CONFIG.MIN_VIDEO_HEIGHT) continue;
// Skip likely preview/thumbnail videos
if (v.closest('a')) continue;
// If a video is small and muted, it's almost certainly a hover/scroll preview
// A typical UI is > 50px tall; attaching to a <130px video "slices" it in half.
if (r.height < 130 && v.muted) continue;
const score = area + (this.isPlaying(v) ? viewArea * 2 : 0);
if (score > bestScore) { best = v; bestScore = score; }
}
this.setActiveVideo(best);
},
setActiveVideo(v, options = {}) {
if (this.activeVideo === v) return;
clearTimeout(this.timers.hideGrace);
if (this.activeVideo) {
['ended', 'play', 'pause', 'ratechange'].forEach(ev =>
this.activeVideo.removeEventListener(ev, this)
);
this.videoResizeObserver.unobserve(this.activeVideo);
this.videoMutationObserver.disconnect();
if (this.currentScrollParent) {
this.currentScrollParent.removeEventListener('scroll', this.boundScrollHandler);
this.currentScrollParent = null;
}
}
this.activeVideo = v;
this.dragData = { isDragging: false };
if (v) {
this.attachUIToVideo(v);
const scrollParent = this.findScrollableParent(v);
if (scrollParent) {
this.currentScrollParent = scrollParent;
this.currentScrollParent.addEventListener('scroll', this.boundScrollHandler, { passive: true });
}
this.videoResizeObserver.observe(v);
this.videoMutationObserver.observe(v.parentElement || v, { attributes: true, subtree: true });
this.applyDefaultSpeed(v);
this.applyVideoTransform();
} else {
const gracePeriod = options.immediateHide ? 0 : 250;
this.timers.hideGrace = setTimeout(() => {
if (!this.activeVideo && this.ui.wrap) this.ui.wrap.style.display = 'none';
}, gracePeriod);
}
},
// ── UI ↔ video attachment ───────────────────────────────────────────────
attachUIToVideo(video) {
this.ui.wrap.style.visibility = 'hidden';
this.ui.wrap.style.position = 'absolute';
const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
let parent = fsEl;
if (parent && parent.isConnected) {
if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative';
parent.appendChild(this.ui.wrap);
} else {
document.body.appendChild(this.ui.wrap);
}
this.ui.wrap.style.display = 'block';
this.isManuallyPositioned = false;
this.throttledPositionOnVideo();
setTimeout(() => {
this.ui.wrap.style.visibility = 'visible';
this.showUI(true);
this.updateSpeedDisplay();
}, 50);
['ended', 'play', 'pause', 'ratechange'].forEach(ev => video.addEventListener(ev, this));
},
// ── Positioning ─────────────────────────────────────────────────────────
_applyPagePosition(pageX, pageY, ignoreYClamp = false) {
const v = this.getViewportPageBounds();
const uiWidth = this.ui.wrap.offsetWidth;
const uiHeight = this.ui.wrap.offsetHeight;
const minPageX = v.leftPage + MVC_CONFIG.EDGE;
const maxPageX = v.leftPage + v.width - uiWidth - MVC_CONFIG.EDGE;
const minPageY = v.topPage + MVC_CONFIG.EDGE;
const maxPageY = v.topPage + v.height - uiHeight - MVC_CONFIG.EDGE;
const clampedLeft = this.clamp(pageX, minPageX, maxPageX);
const clampedTop = ignoreYClamp ? pageY : this.clamp(pageY, minPageY, maxPageY);
const parent = this.ui.wrap.parentElement || document.body;
const parentRect = parent.getBoundingClientRect();
const parentLeftPage = parentRect.left + window.scrollX;
const parentTopPage = parentRect.top + window.scrollY;
this.ui.wrap.style.left = `${Math.round(clampedLeft - parentLeftPage)}px`;
this.ui.wrap.style.top = `${Math.round(clampedTop - parentTopPage)}px`;
this.ui.wrap.style.right = 'auto';
this.ui.wrap.style.bottom = 'auto';
},
positionOnVideo() {
if (!this.activeVideo || !this.ui.wrap || this.isManuallyPositioned || this.dragData?.isDragging) return;
this.ui.wrap.style.transform = '';
const vr = this.activeVideo.getBoundingClientRect();
const layoutWidth = this.activeVideo.clientWidth;
const layoutHeight = this.activeVideo.clientHeight;
const zoom = this.settings.transform.zoom;
const offsetX = (layoutWidth * (zoom - 1)) / 2;
const offsetY = (layoutHeight * (zoom - 1)) / 2;
const uiWidth = this.ui.wrap.offsetWidth;
const uiHeight = this.ui.wrap.offsetHeight;
const desiredLeftPage = vr.left + offsetX + window.scrollX + layoutWidth - uiWidth - MVC_CONFIG.DEFAULT_RIGHT_OFFSET;
let desiredTopPage = vr.top + offsetY + window.scrollY + layoutHeight - uiHeight - 10;
if (layoutHeight > window.innerHeight * 0.7 && vr.bottom > window.innerHeight - 150) desiredTopPage -= MVC_CONFIG.UI_TALL_VIDEO_OFFSET;
this._applyPagePosition(desiredLeftPage, desiredTopPage, this.isScrolling);
},
ensureUIInViewport() {
if (!this.ui.wrap || !this.ui.wrap.offsetWidth || !this.ui.wrap.offsetHeight) return;
const uiRect = this.ui.wrap.getBoundingClientRect();
this._applyPagePosition(uiRect.left + window.scrollX, uiRect.top + window.scrollY);
},
throttledPositionOnVideo() {
if (this.isTicking) return;
this.isTicking = true;
requestAnimationFrame(() => {
if (!this.dragData?.isDragging && !this.isManuallyPositioned) this.positionOnVideo();
this.isTicking = false;
});
},
onViewportChange() {
if (this.isManuallyPositioned) return;
this.ensureUIInViewport();
if (this.activeVideo) this.throttledPositionOnVideo();
},
getViewportPageBounds() {
const v = window.visualViewport;
const leftPage = window.scrollX + (v ? v.offsetLeft : 0);
const topPage = window.scrollY + (v ? v.offsetTop : 0);
const width = v ? v.width : window.innerWidth;
const height = v ? v.height : window.innerHeight;
return { leftPage, topPage, width, height };
},
findScrollableParent(element) {
let parent = element.parentElement;
while (parent) {
const { overflowY } = window.getComputedStyle(parent);
if ((overflowY === 'scroll' || overflowY === 'auto') && parent.scrollHeight > parent.clientHeight)
return parent;
parent = parent.parentElement;
}
return window;
},
// ── Observers ───────────────────────────────────────────────────────────
setupObservers() {
this.intersectionObserver = new IntersectionObserver(
e => this.handleIntersection(e), { threshold: 0.05 }
);
document.querySelectorAll('video').forEach(v => this.intersectionObserver.observe(v));
this.mutationObserver = new MutationObserver(m => this.handleMutation(m));
const root = document.body || document.documentElement;
this.mutationObserver.observe(root, { childList: true, subtree: true });
},
handleIntersection(entries) {
let needsReevaluation = false;
entries.forEach(entry => {
const target = entry.target;
if (entry.isIntersecting) {
if (!this.visibleVideos.has(target)) { this.visibleVideos.set(target, true); needsReevaluation = true; }
} else {
if (this.visibleVideos.has(target)) {
this.visibleVideos.delete(target);
if (target === this.activeVideo) {
const scrolledOffTop = entry.boundingClientRect.bottom < 10;
this.setActiveVideo(null, { immediateHide: scrolledOffTop });
}
needsReevaluation = true;
}
}
});
if (needsReevaluation) this.debouncedEvaluate();
},
handleMutation(mutations) {
let videoAdded = false, activeVideoRemoved = false, relevantMutation = false;
mutations.forEach(mutation => {
if (mutation.addedNodes.length) {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1 && (node.tagName === 'VIDEO' || (node.querySelector && node.querySelector('video')))) {
relevantMutation = true;
const videos = node.tagName === 'VIDEO' ? [node] : node.querySelectorAll('video');
videos.forEach(v => { this.intersectionObserver.observe(v); videoAdded = true; });
}
});
}
if (mutation.removedNodes.length) {
mutation.removedNodes.forEach(node => {
if (node.nodeType === 1 && (node.tagName === 'VIDEO' || (node.querySelector && node.querySelector('video')))) {
relevantMutation = true;
const videos = node.tagName === 'VIDEO' ? [node] : node.querySelectorAll('video');
videos.forEach(v => {
this.intersectionObserver.unobserve(v);
this.visibleVideos.delete(v);
if (v === this.activeVideo) activeVideoRemoved = true;
});
}
});
}
});
if (!relevantMutation) return;
if (activeVideoRemoved) this.setActiveVideo(null);
if (videoAdded || activeVideoRemoved || (this.activeVideo && !this.activeVideo.isConnected)) {
this.debouncedEvaluate();
}
},
setupVideoPositionObserver() {
this.videoResizeObserver = new ResizeObserver(() => this.throttledPositionOnVideo());
this.videoMutationObserver = new MutationObserver(() => this.throttledPositionOnVideo());
},
// ── Media-event handler (addEventListener(ev, this) interface) ──────────
handleEvent(event) {
switch (event.type) {
case 'ended':
this.onVideoEnded();
break;
case 'play':
case 'pause':
this.updateSpeedDisplay();
this.showUI();
break;
case 'ratechange':
this.updateSpeedDisplay();
if (!this.isSpeedSliding && !this.inLongPressGesture) this.showUI();
break;
}
},
// ── Playback actions ────────────────────────────────────────────────────
setPlaybackRate(rate) {
if (!this.activeVideo) return;
this.activeVideo.playbackRate = rate;
this.saveSetting('lastRate', String(rate));
this.updateSpeedDisplay();
},
onVideoEnded() {
if (this.activeVideo) {
this.setPlaybackRate(this.settings.defaultSpeed);
}
},
handlePlayPauseClick() {
if (!this.activeVideo) return;
if (this.activeVideo.paused || this.activeVideo.ended) {
this.activeVideo.playbackRate = this.settings.lastRate || this.settings.defaultSpeed;
this.activeVideo.play().catch(() => {});
} else {
this.saveSetting('lastRate', String(this.activeVideo.playbackRate));
this.activeVideo.pause();
}
},
doSkip(dir) {
if (this.activeVideo)
this.activeVideo.currentTime = this.clampTime(this.activeVideo.currentTime + dir * this.settings.skipSeconds);
},
applyDefaultSpeed(v) {
if (v && this.settings.defaultSpeed !== 1.0 && Math.abs(v.playbackRate - 1.0) < 0.1)
v.playbackRate = this.settings.defaultSpeed;
},
applyVideoTransform() {
if (!this.activeVideo) return;
const { ratio, zoom, rotation } = this.settings.transform;
this.activeVideo.style.objectFit = ratio === 'fit' ? 'contain' : ratio === 'fill' ? 'cover' : 'fill';
this.activeVideo.style.transform = `scale(${zoom}) rotate(${rotation}deg)`;
},
// ── Fullscreen / guardian ───────────────────────────────────────────────
onFullScreenChange() {
const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
const container = fsEl || document.body;
[this.ui.backdrop, this.ui.toast, this.ui.speedToast, this.ui.gestureOverlay, this.ui.speedMenu, this.ui.skipMenu, this.ui.settingsMenu]
.forEach(el => { if (el) container.appendChild(el); });
if (this.activeVideo) this.attachUIToVideo(this.activeVideo);
this.guardianCheck();
},
guardianCheck() {
if (!this.activeVideo || !this.ui.wrap) return;
const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
const expectedParent = fsEl ? fsEl : document.body;
if (expectedParent && (!this.ui.wrap.isConnected || this.ui.wrap.parentElement !== expectedParent))
this.attachUIToVideo(this.activeVideo);
}
};
// src/controls.js – Gesture/event management for MobileVideoController
'use strict';
const MVC_Controls = {
// ── Viewport + global event listeners ──────────────────────────────────
attachEventListeners() {
window.addEventListener('resize', () => this.onViewportChange(), { passive: true });
window.addEventListener('scroll', () => {
this.isScrolling = true;
clearTimeout(this.timers.scrollEnd);
this.timers.scrollEnd = setTimeout(() => { this.isScrolling = false; }, MVC_CONFIG.SCROLL_END_TIMEOUT);
this.onViewportChange();
}, { passive: true });
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => this.onViewportChange(), { passive: true });
window.visualViewport.addEventListener('scroll', () => this.onViewportChange(), { passive: true });
}
['fullscreenchange', 'webkitfullscreenchange'].forEach(ev =>
document.addEventListener(ev, () => {
this.onFullScreenChange();
setTimeout(() => this.guardianCheck(), 500);
}, { passive: true })
);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible')
setTimeout(() => this.guardianCheck(), MVC_CONFIG.VISIBILITY_GUARDIAN_DELAY);
}, { passive: true });
['pointerdown', 'keydown', 'touchstart'].forEach(ev =>
window.addEventListener(ev, e => {
if (!e.isTrusted) return;
this.lastRealUserEvent = Date.now();
// Only show UI for keyboard events or touches on the MVC panel itself
if (e.type === 'keydown' || this.ui.wrap?.contains(e.target)) this.showUI(true);
}, { passive: true })
);
this.ui.backdrop.addEventListener('click', e => {
e.preventDefault(); e.stopPropagation(); this.hideAllMenus();
});
document.body.addEventListener('play', e => {
if (e.target.tagName === 'VIDEO' && e.target !== this.activeVideo)
setTimeout(() => this.debouncedEvaluate(), 50);
}, true);
this.attachSpeedButtonListeners();
this.attachPanelDragListeners();
this.ui.rewindBtn.onclick = () => { this.showUI(true); if (!this.wasDragging) this.doSkip(-1); this.wasDragging = false; };
this.ui.forwardBtn.onclick = () => { this.showUI(true); if (!this.wasDragging) this.doSkip(1); this.wasDragging = false; };
this._attachSettingsButtonListeners();
this.setupLongPress(this.ui.rewindBtn, -1);
this.setupLongPress(this.ui.forwardBtn, 1);
},
// ── Settings button: long-press → close-mode ────────────────────────────
_attachSettingsButtonListeners() {
let isSettingsCloseMode = false;
let settingsJustTurnedToCross = false;
let settingsLongPressTimer = null;
const resetSettingsButton = () => {
if (!isSettingsCloseMode) return;
isSettingsCloseMode = false;
settingsJustTurnedToCross = false;
this.ui.settingsBtn.innerHTML = '';
this.ui.settingsBtn.appendChild(this.getIcon('settings'));
this.ui.settingsBtn.style.color = '';
this.ui.settingsBtn.style.background = '';
};
document.addEventListener('pointerdown', e => {
if (this.ui.settingsBtn && !this.ui.settingsBtn.contains(e.target) && isSettingsCloseMode)
resetSettingsButton();
}, { passive: true });
this.ui.settingsBtn.onpointerdown = e => {
clearTimeout(settingsLongPressTimer);
if (isSettingsCloseMode) return;
settingsLongPressTimer = setTimeout(() => {
if (this.dragData.isDragging || !this.ui.settingsBtn) return;
isSettingsCloseMode = true;
settingsJustTurnedToCross = true;
this.vibrate(15);
this.ui.settingsBtn.innerHTML = '';
this.ui.settingsBtn.appendChild(this.getIcon('close'));
this.ui.settingsBtn.style.color = '#ff3b30';
this.ui.settingsBtn.style.background = 'rgba(255, 59, 48, 0.2)';
}, MVC_CONFIG.LONG_PRESS_DURATION_MS);
};
const clearSettingsTimer = () => clearTimeout(settingsLongPressTimer);
this.ui.settingsBtn.onpointerup = clearSettingsTimer;
this.ui.settingsBtn.onpointerleave = clearSettingsTimer;
this.ui.settingsBtn.onpointercancel = clearSettingsTimer;
this.ui.settingsBtn.onclick = e => {
if (this.wasDragging) { e.stopPropagation(); this.wasDragging = false; return; }
if (settingsJustTurnedToCross) { settingsJustTurnedToCross = false; e.stopPropagation(); return; }
if (isSettingsCloseMode) {
e.stopPropagation();
this.ui.wrap.style.display = 'none';
resetSettingsButton();
return;
}
this.ensureSettingsMenu();
this.toggleMenu(this.ui.settingsMenu, this.ui.settingsBtn);
};
},
// ── Speed button: drag-to-slide + tap-for-menu ──────────────────────────
attachSpeedButtonListeners() {
let longPressActioned = false;
this.ui.speedBtn.addEventListener('touchstart', e => e.stopPropagation(), { passive: true });
this.ui.speedBtn.addEventListener('pointerdown', e => {
e.stopPropagation(); e.preventDefault();
let toastPos = { top: '50%', left: '50%' };
if (this.activeVideo?.isConnected) {
const vr = this.activeVideo.getBoundingClientRect();
toastPos = { top: `${vr.top + vr.height / 2}px`, left: `${vr.left + vr.width / 2}px` };
}
this.sliderData = { startY: e.clientY, currentY: e.clientY, startRate: this.activeVideo ? this.activeVideo.playbackRate : 1.0, isSliding: false, toastPosition: toastPos };
longPressActioned = false;
try { this.ui.speedBtn.setPointerCapture(e.pointerId); } catch (err) {}
this.timers.longPress = setTimeout(() => {
if (this.activeVideo) {
this.activeVideo.playbackRate = 1.0;
this.showToast('Speed reset to 1.00x');
this.vibrate(MVC_CONFIG.LONG_PRESS_VIBRATE_MS);
}
longPressActioned = true;
this.sliderData.isSliding = false;
}, MVC_CONFIG.LONG_PRESS_DURATION_MS);
});
this.ui.speedBtn.addEventListener('pointermove', e => {
if (this.sliderData.startY == null || !this.activeVideo || longPressActioned) return;
e.stopPropagation(); e.preventDefault();
if (!this.sliderData.isSliding && Math.abs(e.clientY - this.sliderData.startY) > MVC_CONFIG.DRAG_THRESHOLD) {
clearTimeout(this.timers.longPress);
this.sliderData.isSliding = true;
this.isSpeedSliding = true;
this.showUI(true);
this.ui.speedBtn.style.transform = 'scale(1.1)';
Object.assign(this.ui.speedToast.style, this.sliderData.toastPosition);
this.vibrate();
}
if (this.sliderData.isSliding) {
this.sliderData.currentY = e.clientY;
if (!this.isTickingSlider) {
requestAnimationFrame(() => this.updateSpeedSlider());
this.isTickingSlider = true;
}
}
});
this.ui.speedBtn.addEventListener('pointerup', e => {
e.stopPropagation();
clearTimeout(this.timers.longPress);
if (this.sliderData.isSliding && this.activeVideo) {
this.saveSetting('lastRate', this.activeVideo.playbackRate.toString());
this.updateSpeedDisplay();
} else if (!longPressActioned) {
if (this.activeVideo && (this.activeVideo.paused || this.activeVideo.ended)) {
this.handlePlayPauseClick();
} else {
this.ensureSpeedMenu();
this.toggleMenu(this.ui.speedMenu, this.ui.speedBtn);
}
}
this.isSpeedSliding = false;
this.isTickingSlider = false;
this.sliderData = { isSliding: false };
this.ui.speedBtn.style.transform = 'scale(1)';
this.ui.speedBtn.classList.remove('snapped');
if (this.ui.speedToast) this.ui.speedToast.classList.remove('snapped');
this.hideSpeedToast();
clearTimeout(this.timers.hide);
this.timers.hide = setTimeout(() => this.hideUI(), MVC_CONFIG.UI_FADE_TIMEOUT);
});
},
updateSpeedSlider() {
if (!this.sliderData.isSliding || !this.activeVideo) { this.isTickingSlider = false; return; }
this.showUI(true);
const dy = this.sliderData.startY - this.sliderData.currentY;
const delta = Math.sign(dy) * Math.pow(Math.abs(dy), MVC_CONFIG.SLIDER_POWER) * MVC_CONFIG.SLIDER_SENSITIVITY;
let newRate = this.clamp(this.sliderData.startRate + delta, 0.1, 16);
let isSnapped = false;
for (const point of MVC_CONFIG.DEFAULT_SNAP_POINTS) {
if (Math.abs(newRate - point) < MVC_CONFIG.SNAP_THRESHOLD) { newRate = point; isSnapped = true; break; }
}
this.activeVideo.playbackRate = newRate;
this.showSpeedToast(`${newRate.toFixed(2)}x`, false);
this.ui.speedBtn.classList.toggle('snapped', isSnapped);
if (this.ui.speedToast) this.ui.speedToast.classList.toggle('snapped', isSnapped);
this.isTickingSlider = false;
},
// ── Panel drag ──────────────────────────────────────────────────────────
attachPanelDragListeners() {
this.ui.panel.addEventListener('touchstart', e => e.stopPropagation(), { passive: true });
this.ui.panel.addEventListener('touchmove', e => { e.preventDefault(); e.stopImmediatePropagation(); }, { passive: false });
this.ui.panel.onpointerdown = e => {
e.stopPropagation();
this.wasDragging = false;
this.showUI(true);
this.ui.wrap.style.transform = '';
const rect = this.ui.wrap.getBoundingClientRect();
this.dragData = {
startPageX: rect.left + window.scrollX,
startPageY: rect.top + window.scrollY,
startX: e.clientX, startY: e.clientY,
isDragging: false
};
const onDragMove = moveEvent => {
moveEvent.stopPropagation();
moveEvent.stopImmediatePropagation();
if (moveEvent.cancelable) moveEvent.preventDefault();
this.dragData.dx = moveEvent.clientX - this.dragData.startX;
this.dragData.dy = moveEvent.clientY - this.dragData.startY;
if (!this.dragData.isDragging && Math.sqrt(this.dragData.dx ** 2 + this.dragData.dy ** 2) > MVC_CONFIG.DRAG_THRESHOLD) {
this.dragData.isDragging = true;
this.ui.panel.style.cursor = 'grabbing';
this.showUI(true);
clearTimeout(this.timers.longPressSkip);
}
if (this.dragData.isDragging && !this.isTickingDrag) {
requestAnimationFrame(() => this.updateDragPosition());
this.isTickingDrag = true;
}
};
const onDragEnd = () => {
window.removeEventListener('pointermove', onDragMove);
window.removeEventListener('pointerup', onDragEnd);
window.removeEventListener('pointercancel', onDragEnd);
this.ui.panel.style.cursor = 'grab';
if (this.dragData.isDragging) { this.isManuallyPositioned = true; this.wasDragging = true; }
this.dragData.isDragging = false;
clearTimeout(this.timers.hide);
this.timers.hide = setTimeout(() => this.hideUI(), MVC_CONFIG.UI_FADE_TIMEOUT);
};
window.addEventListener('pointermove', onDragMove);
window.addEventListener('pointerup', onDragEnd, { once: true });
window.addEventListener('pointercancel', onDragEnd, { once: true });
};
},
updateDragPosition() {
if (!this.dragData.isDragging) { this.isTickingDrag = false; return; }
const parent = this.ui.wrap.parentElement || document.body;
const parentRect = parent.getBoundingClientRect();
const parentLeftPage = parentRect.left + window.scrollX;
const parentTopPage = parentRect.top + window.scrollY;
let newPageX = this.dragData.startPageX + this.dragData.dx;
let newPageY = this.dragData.startPageY + this.dragData.dy;
const v = this.getViewportPageBounds();
newPageX = this.clamp(newPageX, v.leftPage + MVC_CONFIG.EDGE, v.leftPage + v.width - this.ui.wrap.offsetWidth - MVC_CONFIG.EDGE);
newPageY = this.clamp(newPageY, v.topPage + MVC_CONFIG.EDGE, v.topPage + v.height - this.ui.wrap.offsetHeight - MVC_CONFIG.EDGE);
this.ui.wrap.style.left = `${Math.round(newPageX - parentLeftPage)}px`;
this.ui.wrap.style.top = `${Math.round(newPageY - parentTopPage)}px`;
this.isTickingDrag = false;
},
// ── Long-press skip (rewind / forward buttons) ──────────────────────────
setupLongPress(btn, dir) {
const clear = () => clearTimeout(this.timers.longPressSkip);
btn.addEventListener('pointerdown', () => {
clear();
this.timers.longPressSkip = setTimeout(() => {
this.longPressDirection = dir;
this.ensureSkipMenu();
this.placeMenu(this.ui.skipMenu, this.ui.wrap);
this.ui.skipMenu.style.display = 'flex';
this.showBackdrop();
}, MVC_CONFIG.LONG_PRESS_DURATION_MS);
});
['pointerup', 'pointerleave', 'pointercancel'].forEach(ev => btn.addEventListener(ev, clear));
},
// ── UI visibility ────────────────────────────────────────────────────────
showUI(force = false) {
if (!this.ui.wrap || !this.activeVideo || this.inLongPressGesture) return;
if (!this.ui.wrap.isConnected) this.attachUIToVideo(this.activeVideo);
if (!force && (Date.now() - this.lastRealUserEvent >= MVC_CONFIG.INTERACTION_TIMEOUT)) return;
this.ui.wrap.style.opacity = '1';
this.ui.wrap.style.pointerEvents = 'auto';
clearTimeout(this.timers.hide);
const isInteracting = this.dragData.isDragging || this.sliderData.isSliding || this.isSpeedSliding;
if (!isInteracting && !this.activeVideo.paused) {
this.timers.hide = setTimeout(() => this.hideUI(), MVC_CONFIG.UI_FADE_TIMEOUT);
}
},
hideUI() {
const isInteracting = this.dragData.isDragging || this.sliderData.isSliding || this.isSpeedSliding;
if (this.activeVideo?.paused || isInteracting) return;
const anyMenuOpen = Object.values(this.ui).some(
el => el?.classList?.contains && el.classList.contains('mvc-menu') && getComputedStyle(el).display !== 'none'
);
if (this.ui.wrap && !anyMenuOpen) {
this.ui.wrap.style.opacity = String(MVC_CONFIG.UI_FADE_OPACITY);
this.ui.wrap.style.pointerEvents = 'none';
}
}
};
// src/gestures.js – Touch gesture support for MobileVideoController
'use strict';
const MVC_Gestures = {
// ── Time formatting helpers ────────────────────────────────────────────
_formatGestureTime(seconds) {
if (isNaN(seconds)) return '00:00';
const abs = Math.floor(seconds);
const h = Math.floor(abs / 3600);
const m = Math.floor((abs % 3600) / 60);
const s = abs % 60;
const pad = v => (v < 10 ? '0' : '') + v;
return h > 0 ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
},
_formatGestureDelta(seconds) {
const sign = seconds < 0 ? '-' : '+';
const abs = Math.floor(Math.abs(seconds));
const h = Math.floor(abs / 3600);
const m = Math.floor((abs % 3600) / 60);
const s = abs % 60;
const pad = v => (v < 10 ? '0' : '') + v;
return h > 0 ? `${sign}${pad(h)}:${pad(m)}:${pad(s)}` : `${sign}${pad(m)}:${pad(s)}`;
},
// ── Gesture overlay helpers ────────────────────────────────────────────
_showGestureOverlay(html) {
if (!this.ui.gestureOverlay) return;
this.ui.gestureOverlay.innerHTML = html;
this.ui.gestureOverlay.style.display = 'block';
},
_hideGestureOverlay() {
if (!this.ui.gestureOverlay) return;
this.ui.gestureOverlay.style.display = 'none';
this.ui.gestureOverlay.innerHTML = '';
},
_isTouchOnVideo(touch) {
if (!this.activeVideo?.isConnected) return false;
const r = this.activeVideo.getBoundingClientRect();
return (
touch.clientX >= r.left && touch.clientX <= r.right &&
touch.clientY >= r.top && touch.clientY <= r.bottom
);
},
_isTouchOnUI(touch) {
if (!this.ui.wrap) return false;
const r = this.ui.wrap.getBoundingClientRect();
return (
touch.clientX >= r.left && touch.clientX <= r.right &&
touch.clientY >= r.top && touch.clientY <= r.bottom
);
},
// ── Swipe-to-seek (touchstart on video → touchmove → touchend) ────────
attachGestureListeners() {
window.addEventListener('touchstart', e => {
if (!this.settings.gesturesEnabled) return;
if (e.touches.length !== 1) return;
const touch = e.touches[0];
// Ignore touches on the MVC UI itself
if (this._isTouchOnUI(touch)) return;
// Only act on touches landing on the active video
if (!this._isTouchOnVideo(touch)) return;
const video = this.activeVideo;
const startX = touch.clientX;
const initialTime = video.currentTime;
let seeking = false;
const onTouchMove = ev => {
const dx = ev.touches[0].clientX - startX;
if (!seeking) {
if (Math.abs(dx) > MVC_CONFIG.GESTURE_MOVE_THRESHOLD) seeking = true;
else return;
}
const timeChange = dx * MVC_CONFIG.GESTURE_SEEK_SENSITIVITY;
const newTime = this.clamp(initialTime + timeChange, 0, video.duration || 0);
video.currentTime = newTime;
this._showGestureOverlay(
`${this._formatGestureTime(newTime)}<br><span style="font-size:14px;opacity:0.8">${this._formatGestureDelta(timeChange)}</span>`
);
};
const onTouchEnd = () => {
seeking = false;
this._hideGestureOverlay();
e.target.removeEventListener('touchmove', onTouchMove);
e.target.removeEventListener('touchend', onTouchEnd);
e.target.removeEventListener('touchcancel', onTouchEnd);
};
e.target.addEventListener('touchmove', onTouchMove, { passive: true });
e.target.addEventListener('touchend', onTouchEnd);
e.target.addEventListener('touchcancel', onTouchEnd);
}, { passive: true, capture: true });
},
// ── Long-press-to-speed (pointerdown on video → timer → pointerup) ────
attachLongPressGestureListeners() {
let longPressTimer = null;
let longPressFired = false;
let savedRate = 1;
let startX = 0;
let startY = 0;
window.addEventListener('pointerdown', e => {
if (!this.settings.gesturesEnabled) return;
if (e.pointerType !== 'touch') return;
if (!this.activeVideo?.isConnected) return;
// Ignore presses on the MVC UI
if (this.ui.wrap?.contains(e.target)) return;
const r = this.activeVideo.getBoundingClientRect();
if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) return;
longPressFired = false;
startX = e.clientX;
startY = e.clientY;
longPressTimer = setTimeout(() => {
longPressFired = true;
this.inLongPressGesture = true;
savedRate = this.activeVideo.playbackRate;
this.activeVideo.playbackRate = MVC_CONFIG.GESTURE_SPEED_BOOST;
this._showGestureOverlay(`${MVC_CONFIG.GESTURE_SPEED_BOOST}× Speed`);
this.vibrate(MVC_CONFIG.LONG_PRESS_VIBRATE_MS);
}, MVC_CONFIG.GESTURE_LONG_PRESS_DELAY);
}, { capture: true });
// Cancel long-press if user starts moving (swipe)
window.addEventListener('pointermove', e => {
if (e.pointerType !== 'touch' || !longPressTimer || longPressFired) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (Math.abs(dx) > MVC_CONFIG.GESTURE_MOVE_THRESHOLD || Math.abs(dy) > MVC_CONFIG.GESTURE_MOVE_THRESHOLD) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}, { capture: true, passive: true });
window.addEventListener('pointerup', e => {
if (e.pointerType !== 'touch') return;
clearTimeout(longPressTimer);
longPressTimer = null;
if (longPressFired && this.activeVideo) {
this.activeVideo.playbackRate = savedRate;
this.inLongPressGesture = false;
this._hideGestureOverlay();
}
longPressFired = false;
}, { capture: true });
window.addEventListener('pointercancel', e => {
if (e.pointerType !== 'touch') return;
clearTimeout(longPressTimer);
longPressTimer = null;
if (longPressFired && this.activeVideo) {
this.activeVideo.playbackRate = savedRate;
this.inLongPressGesture = false;
}
this._hideGestureOverlay();
longPressFired = false;
}, { capture: true });
}
};
// ==UserScript==
// @name GlideVideo: Pro Mobile Touch Controller
// @namespace https://github.com/quantavil/userscript/mobile-video-controller
// @version 1.5.4
// @description A premium, gesture-driven video controller for mobile. Swipe to seek, long-press for 2x speed, and precision zoom—all in a sleek, "Media Card" UI.
// @match *://*/*
// @grant none
// @license MIT
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
// ── Module sources are concatenated here by the build step. ─────────────
// In source form each src/*.js file defines its exports on globals:
// MVC_CONFIG (src/config.js)
// MVC_Styles (src/styles.js)
// MVC_Utils (src/utils.js)
// MVC_UI (src/ui.js)
// MVC_Video (src/video.js)
// MVC_Controls (src/controls.js)
// MVC_Gestures (src/gestures.js)
// ────────────────────────────────────────────────────────────────────────
class MobileVideoController {
// Static config comes from src/config.js
static CONFIG = MVC_CONFIG;
constructor() {
this.activeVideo = null;
this.visibleVideos = new Map();
// State flags
this.isManuallyPositioned = false;
this.wasDragging = false;
this.isTicking = false;
this.isTickingDrag = false;
this.isTickingSlider = false;
this.isSpeedSliding = false;
this.isScrolling = false;
this.inLongPressGesture = false;
this.lastRealUserEvent = 0;
this.ui = {
wrap: null, panel: null, backdrop: null, toast: null, speedToast: null,
gestureOverlay: null,
rewindBtn: null, speedBtn: null, forwardBtn: null, settingsBtn: null,
speedMenu: null, skipMenu: null, settingsMenu: null
};
this.timers = {};
this.dragData = { isDragging: false };
this.sliderData = { isSliding: false };
this.boundScrollHandler = this.onViewportChange.bind(this);
this.debouncedEvaluate = this.debounce(this.evaluateActive.bind(this), MVC_CONFIG.MUTATION_DEBOUNCE_MS);
this.loadSettings();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.safeInit(), { once: true });
} else {
this.safeInit();
}
}
safeInit() {
if (!document.body) { setTimeout(() => this.safeInit(), 50); return; }
this.init();
}
init() {
this.injectStyles();
this.createMainUI();
this.attachEventListeners();
this.attachGestureListeners();
this.attachLongPressGestureListeners();
this.setupObservers();
this.setupVideoPositionObserver();
setTimeout(() => this.evaluateActive(), MVC_CONFIG.INITIAL_EVAL_DELAY);
}
}
// Mix all module methods into the prototype
const allKeys = new Set();
[MVC_Styles, MVC_Utils, MVC_UI, MVC_Video, MVC_Controls, MVC_Gestures].forEach(mod => {
Object.keys(mod).forEach(k => {
if (allKeys.has(k)) console.warn(`[MVC] Method collision detected: ${k}`);
allKeys.add(k);
});
Object.assign(MobileVideoController.prototype, mod);
});
new MobileVideoController();
})();