MissAV Precision Clipper (Material Edition)

HLS media clipper specialized for MissAV. Features a responsive Material UI, overlay protection, and scrollable jump intervals for seamless video slicing.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

// ==UserScript==
// @name         MissAV Precision Clipper (Material Edition)
// @namespace    https://github.com/gentlemanan/missav-downloader
// @version      9.6
// @description  HLS media clipper specialized for MissAV. Features a responsive Material UI, overlay protection, and scrollable jump intervals for seamless video slicing.
// @author       gentlemanan
// @match        *://missav.com/*
// @match        *://*.missav.com/*
// @match        *://missav.ws/*
// @match        *://*.missav.ws/*
// @match        *://missav.ai/*
// @match        *://*.missav.ai/*
// @match        *://missav.live/*
// @match        *://*.missav.live/*
// @match        *://missav123.com/*
// @match        *://*.missav123.com/*
// @grant        unsafeWindow
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/mux.min.js
// @run-at       document-start
// @icon         https://www.google.com/s2/favicons?sz=64&domain=missav.com
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // ==========================================
    // UTILITIES
    // ==========================================
    class Utils {
        static formatTime(sec) {
            if (sec === null || isNaN(sec)) return '--:--';
            const m = Math.floor(sec / 60).toString().padStart(2, '0');
            const s = Math.floor(sec % 60).toString().padStart(2, '0');
            return `${m}:${s}`;
        }

        static saveFile(blob, startTime, endTime, ext) {
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            const s = Utils.formatTime(startTime).replace(':', 'm') + 's';
            const e = Utils.formatTime(endTime).replace(':', 'm') + 's';
            a.download = `MissAV_Clip_${s}_to_${e}.${ext}`;
            a.click();
            setTimeout(() => URL.revokeObjectURL(url), 5000);
        }
    }

    // ==========================================
    // NETWORK INTERCEPTOR
    // ==========================================
    class NetworkInterceptor {
        constructor(windowContext) {
            this.context = windowContext || window;
            this.m3u8Url = null;
        }

        init() {
            this._interceptFetch();
            this._interceptXHR();
        }

        getInterceptedUrl() {
            return this.m3u8Url;
        }

        _interceptFetch() {
            const originFetch = this.context.fetch;
            const self = this;
            this.context.fetch = async function (input, init) {
                const url = input instanceof Request ? input.url : input.toString();
                if (url.includes('.m3u8')) self.m3u8Url = url;
                return originFetch.apply(this, arguments);
            };
        }

        _interceptXHR() {
            const originXHR = this.context.XMLHttpRequest;
            const self = this;
            class XHRInterceptor extends originXHR {
                open(method, url) {
                    if (url && url.toString().includes('.m3u8')) {
                        self.m3u8Url = url.toString();
                    }
                    super.open(...arguments);
                }
            }
            this.context.XMLHttpRequest = XHRInterceptor;
        }
    }

    // ==========================================
    // HLS PROCESSOR
    // ==========================================
    class HLSProcessor {
        constructor() {
            if (typeof muxjs === 'undefined') {
                console.error('[Clipper] mux.js library not loaded.');
            }
        }

        async processClip(m3u8Url, startT, endT, uiManager) {
            if (!m3u8Url) throw new Error('No HLS stream (.m3u8) intercepted.');
            if (startT === null || endT === null || startT >= endT) throw new Error('Invalid Start/End times.');

            uiManager.showToast('Fetching & Muxing HLS...');

            let playlistText = await (await fetch(m3u8Url)).text();
            let baseUrl = m3u8Url.substring(0, m3u8Url.lastIndexOf('/') + 1);

            if (playlistText.includes('#EXT-X-STREAM-INF')) {
                const mediaUrl = playlistText.split('\n').find((l, i, arr) => arr[i - 1]?.includes('#EXT-X-STREAM-INF')).trim();
                const finalUrl = mediaUrl.startsWith('http') ? mediaUrl : baseUrl + mediaUrl;
                baseUrl = finalUrl.substring(0, finalUrl.lastIndexOf('/') + 1);
                playlistText = await (await fetch(finalUrl)).text();
            }

            if (playlistText.includes('#EXT-X-KEY:METHOD=AES-128')) {
                throw new Error('Encrypted stream detected. Cannot download raw chunks.');
            }

            let time = 0;
            const segments = playlistText.split('\n').reduce((acc, line) => {
                if (line.startsWith('#EXTINF:')) {
                    acc.duration = parseFloat(line.split(':')[1]);
                } else if (line && !line.startsWith('#')) {
                    time += acc.duration;
                    if (time > startT && (time - acc.duration) < endT) {
                        acc.urls.push(line.startsWith('http') ? line : baseUrl + line);
                    }
                }
                return acc;
            }, { duration: 0, urls: [] }).urls;

            if (!segments.length) throw new Error('No segments found in this timeframe.');

            const buffers = [];
            for (const url of segments) {
                buffers.push(new Uint8Array(await (await fetch(url)).arrayBuffer()));
            }

            const transmuxer = new muxjs.mp4.Transmuxer();
            let initSegment = null;
            const dataSegments = [];

            transmuxer.on('data', (s) => {
                if (!initSegment) initSegment = s.initSegment;
                dataSegments.push(s.data);
            });

            const combined = new Uint8Array(buffers.reduce((a, b) => a + b.length, 0));
            let offset = 0;
            for (const b of buffers) {
                combined.set(b, offset);
                offset += b.length;
            }

            transmuxer.push(combined);
            transmuxer.flush();

            if (!initSegment) throw new Error('Transmuxing failed.');

            Utils.saveFile(new Blob([initSegment, ...dataSegments], { type: 'video/mp4' }), startT, endT, 'mp4');
            uiManager.showToast('✅ Download Complete!');
        }
    }

    // ==========================================
    // UI MANAGER (Material Design)
    // ==========================================
    class UIManager {
        constructor(appInstance) {
            this.app = appInstance;
            this.fab = null;
            this.overlay = null;
            this._onFullscreenChange = this._handleFullscreenChange.bind(this);
            this._injectMaterialCSS();
        }

        _injectMaterialCSS() {
            const css = `
                :root {
                    --google-blue: #1a73e8;
                    --google-surface: #ffffff;
                    --google-text: #202124;
                    --md-shadow-2: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
                    --md-shadow-4: 0 14px 28px rgba(0,0,0,0.25), 0 10px 10px rgba(0,0,0,0.22);
                }

                .mat-fab {
                    position: fixed; right: 24px; bottom: 24px; width: 56px; height: 56px;
                    border-radius: 50%; background: var(--google-surface); color: var(--google-blue);
                    z-index: 2147483640; display: flex; align-items: center; justify-content: center;
                    box-shadow: var(--md-shadow-2); cursor: pointer; user-select: none;
                    transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); border: none;
                }
                .mat-fab:hover { box-shadow: var(--md-shadow-4); transform: translateY(-2px); }
                .mat-fab svg { width: 24px; height: 24px; fill: currentColor; }

                .mat-theater-overlay {
                    position: fixed; inset: 0; width: 100vw; height: 100vh;
                    background: #000; z-index: 2147483645; overflow: hidden;
                    font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
                    display: flex; flex-direction: column;
                }

                .mat-video-wrapper {
                    flex: 1; position: relative;
                    display: flex; align-items: center; justify-content: center;
                    min-height: 0; cursor: pointer; overflow: hidden;
                }
                .mat-video-wrapper video {
                    width: 100% !important; height: 100% !important;
                    object-fit: contain !important; background: #000 !important;
                    pointer-events: none;
                }

                .mat-top-bar {
                    position: absolute; top: 16px; right: 24px; z-index: 10;
                    pointer-events: auto;
                }
                .mat-icon-btn {
                    width: 48px; height: 48px; background: rgba(20,20,20,0.6); color: #fff;
                    border: 1px solid rgba(255,255,255,0.1); border-radius: 50%; cursor: pointer;
                    display: flex; align-items: center; justify-content: center;
                    transition: background 0.2s; backdrop-filter: blur(8px);
                }
                .mat-icon-btn:hover { background: rgba(220, 40, 40, 0.8); }
                .mat-icon-btn svg { width: 28px; height: 28px; fill: currentColor; }

                .mat-controls-bar {
                    flex: 0 0 auto; background: #101010;
                    border-top: 1px solid #222; padding: 20px 24px;
                    display: flex; flex-direction: column; align-items: center;
                    z-index: 2; pointer-events: auto;
                }

                .mat-controls-inner {
                    width: 100%; max-width: 900px;
                    display: flex; flex-direction: column; gap: 16px;
                }

                .mat-progress-container { display: flex; align-items: center; gap: 16px; color: #fff; font-size: 13px; font-weight: 500; font-variant-numeric: tabular-nums; }
                .mat-slider {
                    flex: 1; -webkit-appearance: none; height: 4px; border-radius: 2px;
                    background: rgba(255,255,255,0.3); outline: none; cursor: pointer;
                }
                .mat-slider::-webkit-slider-thumb {
                    -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%;
                    background: var(--google-blue); cursor: pointer; transition: transform 0.1s;
                }
                .mat-slider::-webkit-slider-thumb:hover { transform: scale(1.3); }

                .mat-toolbar { display: flex; align-items: center; gap: 12px; justify-content: center; }

                /* Tooltip & Jump Controls */
                .mat-jump-container { display: flex; align-items: center; gap: 4px; }
                .mat-jump-nav-btn {
                    background: transparent; border: 1px solid rgba(255,255,255,0.2);
                    color: #fff; width: 34px; height: 34px; border-radius: 50%; cursor: pointer;
                    display: flex; align-items: center; justify-content: center;
                    font-size: 14px; font-weight: bold; transition: all 0.2s;
                }
                .mat-jump-nav-btn:hover { background: rgba(255,255,255,0.15); border-color: rgba(255,255,255,0.4); }

                .mat-tooltip-wrap { position: relative; display: flex; align-items: center; }
                .mat-tooltip-wrap::before {
                    content: attr(data-tooltip);
                    position: absolute; bottom: 100%; left: 50%; transform: translate(-50%, -8px);
                    background: rgba(30, 30, 30, 0.95); color: #fff; padding: 6px 12px;
                    border-radius: 6px; font-size: 12px; white-space: nowrap; font-weight: 500;
                    opacity: 0; pointer-events: none; transition: opacity 0.2s ease, transform 0.2s ease;
                    border: 1px solid rgba(255,255,255,0.15); box-shadow: 0 8px 16px rgba(0,0,0,0.4);
                    z-index: 100;
                }
                .mat-tooltip-wrap:hover::before { opacity: 1; transform: translate(-50%, -4px); }

                .mat-jump-input {
                    background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.1);
                    color: #fff; width: 56px; height: 34px; border-radius: 16px;
                    text-align: center; font-weight: 600; font-size: 14px;
                    font-family: inherit; font-variant-numeric: tabular-nums;
                    transition: border-color 0.2s, background 0.2s; outline: none; box-sizing: border-box;
                }
                .mat-jump-input:focus { border-color: var(--google-blue); background: rgba(255,255,255,0.15); }
                .mat-jump-input:hover { background: rgba(255,255,255,0.15); }
                .mat-jump-input::-webkit-inner-spin-button,
                .mat-jump-input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }

                .mat-btn {
                    background: rgba(255,255,255,0.1); color: #fff; border: 1px solid rgba(255,255,255,0.1);
                    border-radius: 16px; padding: 6px 16px; cursor: pointer; height: 34px; box-sizing: border-box;
                    font-size: 14px; font-weight: 500; transition: all 0.2s;
                    display: flex; align-items: center; gap: 8px; font-family: inherit;
                }
                .mat-btn:hover { background: rgba(255,255,255,0.2); }
                .mat-btn.primary { background: var(--google-surface); color: var(--google-blue); border: none; font-weight: 600; }
                .mat-btn.primary:hover { background: #e8f0fe; }
                .mat-btn.primary:disabled { opacity: 0.5; cursor: not-allowed; }

                .mat-toast {
                    position: fixed; bottom: 120px; left: 50%; transform: translateX(-50%);
                    background: #323232; color: #fff; padding: 14px 24px;
                    border-radius: 8px; z-index: 2147483647; font-size: 14px; font-weight: 500;
                    box-shadow: var(--md-shadow-4); font-family: inherit;
                }
            `;
            const style = document.createElement('style');
            style.textContent = css;
            document.documentElement.appendChild(style);
        }

        showToast(message) {
            const el = document.createElement('div');
            el.className = 'mat-toast';
            el.textContent = message;
            document.documentElement.appendChild(el);
            setTimeout(() => el.remove(), 4000);
        }

        renderFAB() {
            if (document.getElementById('mat-clipper-fab')) return;
            this.fab = document.createElement('button');
            this.fab.id = 'mat-clipper-fab';
            this.fab.className = 'mat-fab';
            this.fab.title = 'Open HLS Clipper';
            this.fab.innerHTML = `<svg viewBox="0 0 24 24"><path d="M9.64 7.64c.23-.5.36-1.05.36-1.64 0-2.21-1.79-4-4-4S2 3.79 2 6s1.79 4 4 4c.59 0 1.14-.13 1.64-.36L10 12l-2.36 2.36C7.14 14.13 6.59 14 6 14c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4c0-.59-.13-1.14-.36-1.64L12 14l7 7h3v-1L9.64 7.64zM6 8c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm0 12c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm6-7.5c-.28 0-.5-.22-.5-.5s.22-.5.5-.5.5.22.5.5-.22.5-.5.5zM19 3l-6 6 2 2 7-7V3z"/></svg>`;

            this.fab.addEventListener('click', () => this.app.openTheaterMode());
            document.documentElement.appendChild(this.fab);
        }

        toggleFAB(visible) {
            if (this.fab) this.fab.style.display = visible ? 'flex' : 'none';
        }

        renderTheater(videoElement) {
            this.overlay = document.createElement('div');
            this.overlay.className = 'mat-theater-overlay';

            this.overlay.innerHTML = `
                <div class="mat-top-bar">
                    <button class="mat-icon-btn" id="mat-btn-close" title="Close Theater">
                        <svg viewBox="0 0 24 24"><path d="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"/></svg>
                    </button>
                </div>
                <div class="mat-video-wrapper" id="mat-video-wrap"></div>

                <div class="mat-controls-bar">
                    <div class="mat-controls-inner">
                        <div class="mat-progress-container">
                            <span id="mat-lbl-cur">00:00</span>
                            <input type="range" class="mat-slider" id="mat-seek" min="0" max="1000" value="0">
                            <span id="mat-lbl-dur">00:00</span>
                        </div>
                        <div class="mat-toolbar">
                            <button class="mat-btn" id="mat-btn-play">
                                <svg style="width:18px;height:18px;fill:currentColor;" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg> Play/Pause
                            </button>

                            <div class="mat-jump-container">
                                <button class="mat-jump-nav-btn" id="mat-btn-jump-back" title="Jump Backward">&laquo;</button>
                                <div class="mat-tooltip-wrap" data-tooltip="Scroll to adjust interval (sec)">
                                    <input type="number" class="mat-jump-input" id="mat-input-jump" value="10" min="1" max="120" aria-label="Jump interval">
                                </div>
                                <button class="mat-jump-nav-btn" id="mat-btn-jump-fwd" title="Jump Forward">&raquo;</button>
                            </div>

                            <button class="mat-btn" id="mat-btn-start">Set Start</button>
                            <button class="mat-btn" id="mat-btn-end">Set End</button>
                            <button class="mat-btn" id="mat-btn-fullscreen">
                                <svg id="mat-icon-fullscreen" style="width:18px;height:18px;fill:currentColor;" viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg> Fullscreen
                            </button>
                            <div style="flex:1"></div>
                            <button class="mat-btn primary" id="mat-btn-render">Export MP4 Clip</button>
                        </div>
                    </div>
                </div>
            `;
            document.documentElement.appendChild(this.overlay);
            document.getElementById('mat-video-wrap').appendChild(videoElement);

            this._bindTheaterEvents(videoElement);
            document.addEventListener('fullscreenchange', this._onFullscreenChange);
        }

        destroyTheater() {
            document.removeEventListener('fullscreenchange', this._onFullscreenChange);

            if (document.fullscreenElement) document.exitFullscreen().catch(()=>{});

            if (this.overlay) {
                this.overlay.remove();
                this.overlay = null;
            }
        }

        _handleFullscreenChange() {
            const btnFullscreen = document.getElementById('mat-btn-fullscreen');
            if (!btnFullscreen) return;

            if (document.fullscreenElement) {
                btnFullscreen.innerHTML = '<svg style="width:18px;height:18px;fill:currentColor;" viewBox="0 0 24 24"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg> Exit Fullscreen';
            } else {
                btnFullscreen.innerHTML = '<svg style="width:18px;height:18px;fill:currentColor;" viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg> Fullscreen';
            }
        }

        _bindTheaterEvents(video) {
            const btnPlay = document.getElementById('mat-btn-play');
            const btnStart = document.getElementById('mat-btn-start');
            const btnEnd = document.getElementById('mat-btn-end');
            const btnRender = document.getElementById('mat-btn-render');
            const btnClose = document.getElementById('mat-btn-close');
            const btnFullscreen = document.getElementById('mat-btn-fullscreen');
            const videoWrap = document.getElementById('mat-video-wrap');
            const seekSlider = document.getElementById('mat-seek');
            const lblCur = document.getElementById('mat-lbl-cur');
            const lblDur = document.getElementById('mat-lbl-dur');

            // Jump Elements
            const jumpInput = document.getElementById('mat-input-jump');
            const btnJumpBack = document.getElementById('mat-btn-jump-back');
            const btnJumpFwd = document.getElementById('mat-btn-jump-fwd');

            const togglePlay = () => video.paused ? video.play() : video.pause();
            btnPlay.onclick = togglePlay;
            videoWrap.onclick = togglePlay;
            btnClose.onclick = () => this.app.closeTheaterMode();

            // Handle << and >> Buttons
            const getJumpValue = () => parseFloat(jumpInput.value) || 10;

            btnJumpBack.onclick = () => {
                video.currentTime = Math.max(0, video.currentTime - getJumpValue());
            };

            btnJumpFwd.onclick = () => {
                video.currentTime = Math.min(video.duration, video.currentTime + getJumpValue());
            };

            // Scroll to adjust the number input
            jumpInput.addEventListener('wheel', (e) => {
                e.preventDefault();
                const step = e.deltaY > 0 ? -1 : 1;
                let newVal = (parseInt(jumpInput.value) || 10) + step;
                newVal = Math.max(1, Math.min(120, newVal));
                jumpInput.value = newVal;
            });

            btnFullscreen.onclick = () => {
                if (!document.fullscreenElement) {
                    this.overlay.requestFullscreen().catch(err => {
                        this.showToast(`Error enabling fullscreen: ${err.message}`);
                    });
                } else {
                    document.exitFullscreen();
                }
            };

            btnStart.onclick = () => {
                this.app.setClipStart(video.currentTime);
                btnStart.innerText = `Start: ${Utils.formatTime(video.currentTime)}`;
            };
            btnEnd.onclick = () => {
                this.app.setClipEnd(video.currentTime);
                btnEnd.innerText = `End: ${Utils.formatTime(video.currentTime)}`;
            };

            btnRender.onclick = () => {
                btnRender.disabled = true;
                const origText = btnRender.innerText;
                btnRender.innerText = 'Processing...';

                this.app.executeRender().catch(err => {
                    this.showToast(`Error: ${err.message}`);
                    console.error('[Clipper]', err);
                }).finally(() => {
                    btnRender.disabled = false;
                    btnRender.innerText = origText;
                });
            };

            video.addEventListener('timeupdate', () => {
                if (isNaN(video.duration)) return;
                lblCur.innerText = Utils.formatTime(video.currentTime);
                lblDur.innerText = Utils.formatTime(video.duration);
                if (document.activeElement !== seekSlider) {
                    seekSlider.value = (video.currentTime / video.duration) * 1000;
                }
            });

            seekSlider.addEventListener('input', () => {
                if (isNaN(video.duration)) return;
                video.currentTime = (seekSlider.value / 1000) * video.duration;
            });
        }
    }

    // ==========================================
    // MAIN APP CONTROLLER
    // ==========================================
    class VideoClipperApp {
        constructor() {
            this.interceptor = new NetworkInterceptor(unsafeWindow);
            this.ui = new UIManager(this);
            this.processor = new HLSProcessor();

            this.videoData = {
                element: null,
                originalParent: null,
                originalSibling: null,
                originalStyles: '',
                originalControls: true
            };

            this.clipState = {
                start: null,
                end: null
            };

            this.theaterHiddenElements = [];
        }

        init() {
            this.interceptor.init();

            const boot = () => {
                this.ui.renderFAB();
                setInterval(() => this.ui.renderFAB(), 2000);
            };

            if (document.readyState === 'loading') {
                document.addEventListener('DOMContentLoaded', boot);
            } else {
                boot();
            }
        }

        _findTargetVideo() {
            const missAvPlayer = document.querySelector('video.plyr, video#player, .plyr__video-wrapper video, .video-js video');
            if (missAvPlayer) return missAvPlayer;

            const videos = Array.from(document.querySelectorAll('video'));
            return videos.sort((a, b) => (b.clientWidth * b.clientHeight) - (a.clientWidth * a.clientHeight))[0];
        }

        _hidePageForTheater() {
            this.theaterHiddenElements = [];
            const keepNodes = new Set([this.ui.overlay, this.ui.fab]);

            Array.from(document.body.children).forEach(el => {
                if (keepNodes.has(el) || el.classList.contains('mat-theater-overlay')) return;

                this.theaterHiddenElements.push({
                    el,
                    visibility: el.style.visibility,
                    pointerEvents: el.style.pointerEvents,
                    opacity: el.style.opacity
                });

                el.style.visibility = 'hidden';
                el.style.pointerEvents = 'none';
                el.style.opacity = '0';
            });

            Array.from(document.querySelectorAll('body *')).forEach(el => {
                if (keepNodes.has(el) || el.closest('.mat-theater-overlay')) return;

                const style = window.getComputedStyle(el);
                const zIndex = parseInt(style.zIndex, 10);

                if ((style.position === 'fixed' || style.position === 'sticky') && (!isNaN(zIndex) && zIndex >= 100)) {
                    this.theaterHiddenElements.push({
                        el,
                        visibility: el.style.visibility,
                        pointerEvents: el.style.pointerEvents,
                        opacity: el.style.opacity
                    });

                    el.style.visibility = 'hidden';
                    el.style.pointerEvents = 'none';
                    el.style.opacity = '0';
                }
            });
        }

        _restorePageAfterTheater() {
            this.theaterHiddenElements.forEach(item => {
                item.el.style.visibility = item.visibility;
                item.el.style.pointerEvents = item.pointerEvents;
                item.el.style.opacity = item.opacity;
            });
            this.theaterHiddenElements = [];
        }

        openTheaterMode() {
            const vid = this._findTargetVideo();
            if (!vid) return this.ui.showToast('No video found on page!');

            this.videoData = {
                element: vid,
                originalParent: vid.parentElement,
                originalSibling: vid.nextSibling,
                originalStyles: vid.getAttribute('style') || '',
                originalControls: vid.controls
            };

            this.clipState = { start: null, end: null };

            vid.removeAttribute('style');
            vid.controls = false;

            this.ui.toggleFAB(false);
            document.body.style.overflow = 'hidden';

            this._hidePageForTheater();
            this.ui.renderTheater(vid);
        }

        closeTheaterMode() {
            const { element, originalParent, originalSibling, originalStyles, originalControls } = this.videoData;

            if (element) {
                element.setAttribute('style', originalStyles);
                element.controls = originalControls;
                if (originalParent) {
                    originalParent.insertBefore(element, originalSibling);
                }
            }

            this.ui.destroyTheater();
            this._restorePageAfterTheater();

            this.ui.toggleFAB(true);
            document.body.style.overflow = '';
            this.videoData.element = null;
        }

        setClipStart(time) { this.clipState.start = time; }
        setClipEnd(time) { this.clipState.end = time; }

        async executeRender() {
            const m3u8Url = this.interceptor.getInterceptedUrl();
            await this.processor.processClip(m3u8Url, this.clipState.start, this.clipState.end, this.ui);
        }
    }

    // ==========================================
    // BOOTSTRAP
    // ==========================================
    new VideoClipperApp().init();

})();