GlideVideo: Pro Mobile Touch Controller

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.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey .

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

// ==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();

})();