Greasy Fork

Greasy Fork is available in English.

yt-dlp BAT-Downloader

Unified yt-dlp downloader - configure video, audio, subtitles with flexible output modes (None/Merge/Separate/Both)

当前为 2026-01-11 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         yt-dlp BAT-Downloader
// @namespace    http://greasyfork.icu/en/users/1462137-piknockyou
// @version      8.4
// @author       Piknockyou (vibe-coded)
// @license      AGPL-3.0
// @description  Unified yt-dlp downloader - configure video, audio, subtitles with flexible output modes (None/Merge/Separate/Both)
//
// ═══════════════════════════════════════════════════════════════
// CHANGELOG
// ═══════════════════════════════════════════════════════════════
// v8.4
// - Refactored: Sites now default to full mkvmerge flow (supports Separate)
// - Added: COMBINED_STREAM_SITES blacklist for sites without separate streams
// - Fixed: Reddit, Twitter, etc. now properly support Merge+Separate
// - Note: Add sites to COMBINED_STREAM_SITES if they fail with "format not available"
//
// v8.3
// - Fixed: Non-YouTube sites now use format with /best fallback
// - Fixed: Livestorm and other HLS sites work again (combined streams)
// - Fixed: Non-YouTube sites skip complex merge flow, use yt-dlp native merge
// - Note: Merge/Separate options still work for YouTube; non-YouTube gets best available
//
// v8.2
// - Changed: Error prompts now require Enter key (not any key) to close
// - Uses "set /p" instead of "pause" for consistent behavior
//
// v8.1
// - Fixed: Removed invalid %(lang)s placeholder from subtitle template
// - Fixed: Subtitle files now correctly named as filename.en.srt (not filename.NA.en.srt)
// - Note: yt-dlp automatically inserts language code before extension
//
// v8.0
// - Complete rewrite of batch generation for all merge/separate combinations
// - Fixed: Subtitle glob patterns now match actual downloaded files
// - Fixed: Separate file naming (no temp prefix in output names)
// - Fixed: Video/audio detection using ffprobe for ambiguous formats
// - Fixed: Selective cleanup - only deletes temp files, preserves separate copies
// - Fixed: All 64 combinations of V/A/S × None/Merge/Sep/Both now work correctly
// - Added: Proper language preservation in subtitle filenames
// - Added: Robust file identification with subroutine-based processing
//
// v7.10
// - Fixed: Merge ON now sets ALL components to Merge (user deselects unwanted)
// - Fixed: Merge OFF only removes from clicked component
// - Fixed: Auto-cleanup when only 1 merge remains
//
// v7.9
// - Fixed: Merge OFF now only removes merge from clicked component
// - Fixed: Other components keep their merge when one is deselected
// - Fixed: Auto-cleanup only when going from 2 merges to 1 (can't merge alone)
// - Fixed: Merge ON adds to clicked + other enabled components if first merge
//
// v7.8
// - Fixed: Merge is now a global toggle - on for all or off for all
// - Fixed: Clicking Merge OFF on any component removes merge from ALL
// - Fixed: Auto-removes merge when only 1 component remains with merge
// - Fixed: Audio was incorrectly included in merge when set to Separate only
//
// v7.7
// - Fixed: Merge button always clickable - clicking sets ALL to merge
// - Fixed: Merge persists on remaining components when one is set to None/Sep
// - Fixed: Merge auto-removes only when single component left with merge
//
// v7.6
// - Fixed: Subtitles no longer downloaded twice in separate mode
// - Fixed: Deactivating Merge on one deactivates Merge on ALL
// - Fixed: Merge button disabled when only one component is enabled
// - Fixed: Submenus now available even when None is selected
// - Fixed: Panel width fits content
//
// v7.5
// - Redesigned output modes: Merge and Separate are now independent toggles
// - Removed "Both" button - now click Merge AND Sep to get both behaviors
// - Clicking Merge auto-activates Merge for all other enabled components
// - Fixed: No longer crashes when only subs want merge but no media to merge into
// - Simplified case logic in batch generation
//
// v7.4
// - Fixed: Crash when subs=Both but no media being merged
// - Fixed: Single yt-dlp call for media+subs (no duplicate metadata fetch)
// - Improved needsMerge logic to require actual media track to merge
//
// v7.3
// - Fixed: Batch window now waits for user confirmation after mkvmerge
// - Fixed: Merge with subtitles no longer crashes
// - Fixed: "Both" mode now correctly creates merged + separate files
// - Restructured batch flow to always reach pause at end
//
// v7.2
// - Fixed: Subtitle-only mode now uses --skip-download correctly
// - Fixed: Merge/Both buttons disabled when only one component selected
// - Reordered output buttons: Merge/Separate/Both/None
// - Improved stability and error handling
//
// v7.1
// - Restructured menu: Video/Audio/Subtitle rows with inline output modes
// - Detail submenus open on hover (disabled if output=None)
// - Menu stays open until clicking outside (even after download)
// - Removed error message - just grays out download button
// - Original subtitle format now default and first in list
//
// v7.0
// - Complete redesign: unified Media Download menu
// - New output modes: None/Merge/Separate/Both for each component
// - Removed interactive console prompts (D/E/ALL/ALLS quick-picks)
// - Removed subtitle source selection (always fetches all available)
// - Subtitle language selection remains in .bat file
// - DRY: shared subtitle format options across all modes
//
// ═══════════════════════════════════════════════════════════════
// SIMPLE SITES - Just add @match lines here, nothing else needed!
// ═══════════════════════════════════════════════════════════════
// @match        *://www.arte.tv/*/videos/*
// @match        *://www.dailymotion.com/*
// @match        *://www.facebook.com/*
// @match        *://www.instagram.com/*
// @match        *://www.reddit.com/*
// @match        *://soundcloud.com/*
// @match        *://www.tagesschau.de/*
// @match        *://www.tiktok.com/*
// @match        *://www.twitch.tv/*
// @match        *://twitter.com/*
// @match        *://vimeo.com/*
// @match        *://www.youtube.com/*
// @match        *://x.com/*
//
// ═══════════════════════════════════════════════════════════════
// COMPLEX SITES - These need special extractors (defined below)
// ═══════════════════════════════════════════════════════════════
// @match        *://app.livestorm.co/*
// @icon         https://raw.githubusercontent.com/yt-dlp/yt-dlp/refs/heads/master/devscripts/logo.ico
// @grant        GM_setClipboard
// @grant        GM_setValue
// @grant        GM_getValue
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    // ═══════════════════════════════════════════════════════════════
    // SITE-SPECIFIC URL CONDITIONS (SPA Support)
    // ═══════════════════════════════════════════════════════════════
    const SITE_CONDITIONS = {
        'www.reddit.com': (url) => /\/r\/[^/]+\/comments\/[^/]+/.test(url),
    };

    // ═══════════════════════════════════════════════════════════════
    // COMPLEX SITES - Only define sites that need special URL extraction
    // ═══════════════════════════════════════════════════════════════
    const COMPLEX_SITES = {
        'app.livestorm.co': {
            cookieFile: 'app.livestorm.co_cookies.txt',
            extractUrl: () => {
                const match = document.documentElement.innerHTML.match(/https:\\?\/\\?\/cdn\.livestorm\.co\\?\/[^"'\s]+\.m3u8[^"'\s]*/);
                return match ? { url: match[0].replace(/\\/g, '') } : { error: 'Video URL not found. Make sure the video is loaded.' };
            }
        }
    };

    // ═══════════════════════════════════════════════════════════════
    // COMBINED STREAM SITES - Sites that only have muxed video+audio
    // These sites don't support Separate mode (no individual streams)
    // Add hostname here if download fails with "format not available"
    // ═══════════════════════════════════════════════════════════════
    const COMBINED_STREAM_SITES = [
        'app.livestorm.co',
        'cdn.livestorm.co',
        // Add more sites here as needed, e.g.:
        // 'some-hls-only-site.com',
    ];

    // Helper to check if current site has combined streams only
    const isCombinedStreamSite = () => COMBINED_STREAM_SITES.some(site =>
        window.location.hostname === site ||
        window.location.hostname.endsWith('.' + site)
    );

    //================================================================================
    // CONFIGURATION
    //================================================================================

    // ═══════════════════════════════════════════════════════════════
    // OUTPUT MODES - Merge and Separate are independent toggles
    // Internal values: 'none', 'merge', 'separate', 'merge-separate'
    // ═══════════════════════════════════════════════════════════════
    const OUTPUT_MODES = [
        { id: 'none', label: 'None', desc: 'Do not download this component' },
        { id: 'merge', label: 'Merge', desc: 'Include in merged file' },
        { id: 'separate', label: 'Separate', desc: 'Keep as standalone file' },
        { id: 'merge-separate', label: 'Merge+Sep', desc: 'Merged AND separate file' },
    ];

    // Helper to check if output includes merge
    const hasMergeFlag = (output) => output === 'merge' || output === 'merge-separate';
    const hasSeparateFlag = (output) => output === 'separate' || output === 'merge-separate';
    const isEnabled = (output) => output !== 'none';

    // ═══════════════════════════════════════════════════════════════
    // VIDEO OPTIONS
    // ═══════════════════════════════════════════════════════════════
    const VIDEO_QUALITIES = [
        { id: 'best', label: 'Best', format: 'bestvideo' },
        { id: '1080p', label: '1080p', format: 'bestvideo[height<=1080]' },
        { id: '720p', label: '720p', format: 'bestvideo[height<=720]' },
        { id: '480p', label: '480p', format: 'bestvideo[height<=480]' },
        { id: '360p', label: '360p', format: 'bestvideo[height<=360]' },
    ];

    const VIDEO_CODECS = [
        { id: 'default', label: 'Auto', sortArg: '', desc: 'Let yt-dlp choose best available' },
        { id: 'av1', label: 'AV1', sortArg: '-S "vcodec:av01"', desc: 'Most efficient, needs modern hardware' },
        { id: 'vp9', label: 'VP9', sortArg: '-S "vcodec:vp9"', desc: 'Good efficiency, wide support' },
        { id: 'h264', label: 'H.264', sortArg: '-S "+vcodec:avc"', desc: 'Maximum compatibility' },
    ];

    // ═══════════════════════════════════════════════════════════════
    // AUDIO OPTIONS
    // ═══════════════════════════════════════════════════════════════
    const AUDIO_QUALITIES = [
        { id: 'best', label: 'Best', format: 'bestaudio' },
        { id: 'worst', label: 'Smallest', format: 'worstaudio' },
    ];

    // ═══════════════════════════════════════════════════════════════
    // SUBTITLE OPTIONS (Original first)
    // ═══════════════════════════════════════════════════════════════
    const SUBTITLE_FORMATS = [
        { id: 'original', label: 'Original', convertArg: '', desc: 'Keep original format' },
        { id: 'srt', label: 'SRT', convertArg: '--convert-subs srt', desc: 'Universal, widely supported' },
        { id: 'vtt', label: 'VTT', convertArg: '--convert-subs vtt', desc: 'Web standard format' },
        { id: 'ass', label: 'ASS', convertArg: '--convert-subs ass', desc: 'Advanced styling support' },
    ];

    // ═══════════════════════════════════════════════════════════════
    // STORAGE KEYS
    // ═══════════════════════════════════════════════════════════════
    const STORAGE_KEYS = {
        VIDEO_QUALITY: 'ytdlp_video_quality',
        VIDEO_CODEC: 'ytdlp_video_codec',
        VIDEO_OUTPUT: 'ytdlp_video_output',
        AUDIO_QUALITY: 'ytdlp_audio_quality',
        AUDIO_OUTPUT: 'ytdlp_audio_output',
        SUBS_FORMAT: 'ytdlp_subs_format',
        SUBS_OUTPUT: 'ytdlp_subs_output',
    };

    // ═══════════════════════════════════════════════════════════════
    // DEFAULTS
    // ═══════════════════════════════════════════════════════════════
    const DEFAULTS = {
        VIDEO_QUALITY: 'best',
        VIDEO_CODEC: 'default',
        VIDEO_OUTPUT: 'merge',
        AUDIO_QUALITY: 'best',
        AUDIO_OUTPUT: 'merge',
        SUBS_FORMAT: 'original',
        SUBS_OUTPUT: 'none',
    };

    // ═══════════════════════════════════════════════════════════════
    // OVERWRITE EXISTING FILES
    // ═══════════════════════════════════════════════════════════════
    const FORCE_OVERWRITE = true;

    //================================================================================
    // STORAGE HELPERS
    //================================================================================

    function getStoredValue(key, defaultValue, validOptions = null) {
        try {
            const stored = GM_getValue(key, defaultValue);
            if (validOptions && !validOptions.some(v => v.id === stored)) {
                return defaultValue;
            }
            return stored;
        } catch (e) {
            return defaultValue;
        }
    }

    function setStoredValue(key, value) {
        try {
            GM_setValue(key, value);
        } catch (e) {
            console.warn('[yt-dlp] Failed to save setting:', e);
        }
    }

    // Getters
    const getVideoQuality = () => getStoredValue(STORAGE_KEYS.VIDEO_QUALITY, DEFAULTS.VIDEO_QUALITY, VIDEO_QUALITIES);
    const getVideoCodec = () => getStoredValue(STORAGE_KEYS.VIDEO_CODEC, DEFAULTS.VIDEO_CODEC, VIDEO_CODECS);
    const getVideoOutput = () => getStoredValue(STORAGE_KEYS.VIDEO_OUTPUT, DEFAULTS.VIDEO_OUTPUT, OUTPUT_MODES);
    const getAudioQuality = () => getStoredValue(STORAGE_KEYS.AUDIO_QUALITY, DEFAULTS.AUDIO_QUALITY, AUDIO_QUALITIES);
    const getAudioOutput = () => getStoredValue(STORAGE_KEYS.AUDIO_OUTPUT, DEFAULTS.AUDIO_OUTPUT, OUTPUT_MODES);
    const getSubsFormat = () => getStoredValue(STORAGE_KEYS.SUBS_FORMAT, DEFAULTS.SUBS_FORMAT, SUBTITLE_FORMATS);
    const getSubsOutput = () => getStoredValue(STORAGE_KEYS.SUBS_OUTPUT, DEFAULTS.SUBS_OUTPUT, OUTPUT_MODES);

    // Setters
    const setVideoQuality = (v) => setStoredValue(STORAGE_KEYS.VIDEO_QUALITY, v);
    const setVideoCodec = (v) => setStoredValue(STORAGE_KEYS.VIDEO_CODEC, v);
    const setVideoOutput = (v) => setStoredValue(STORAGE_KEYS.VIDEO_OUTPUT, v);
    const setAudioQuality = (v) => setStoredValue(STORAGE_KEYS.AUDIO_QUALITY, v);
    const setAudioOutput = (v) => setStoredValue(STORAGE_KEYS.AUDIO_OUTPUT, v);
    const setSubsFormat = (v) => setStoredValue(STORAGE_KEYS.SUBS_FORMAT, v);
    const setSubsOutput = (v) => setStoredValue(STORAGE_KEYS.SUBS_OUTPUT, v);

    // Helpers
    const findOption = (options, id) => options.find(o => o.id === id);
    const getLabel = (options, id) => findOption(options, id)?.label || options[0].label;
    const isYouTubeDomain = () => ['www.youtube.com', 'youtube.com', 'm.youtube.com'].includes(window.location.hostname);

    //================================================================================
    // VALIDATION & STATE HELPERS
    //================================================================================

    function getComponentStates() {
        const videoOut = getVideoOutput();
        const audioOut = getAudioOutput();
        const subsOut = getSubsOutput();

        const hasVideo = isEnabled(videoOut);
        const hasAudio = isEnabled(audioOut);
        const hasSubs = isEnabled(subsOut);

        const videoMerge = hasMergeFlag(videoOut);
        const audioMerge = hasMergeFlag(audioOut);
        const subsMerge = hasMergeFlag(subsOut);

        const videoSeparate = hasSeparateFlag(videoOut);
        const audioSeparate = hasSeparateFlag(audioOut);
        const subsSeparate = hasSeparateFlag(subsOut);

        const activeCount = [hasVideo, hasAudio, hasSubs].filter(Boolean).length;

        // Merge requires at least one MEDIA track (video or audio) to be merged
        // Subs alone cannot be merged - they need something to embed into
        const hasMediaMerge = (hasVideo && videoMerge) || (hasAudio && audioMerge);
        const needsMerge = hasMediaMerge;

        return {
            videoOut, audioOut, subsOut,
            hasVideo, hasAudio, hasSubs,
            videoMerge, audioMerge, subsMerge,
            videoSeparate, audioSeparate, subsSeparate,
            activeCount, needsMerge
        };
    }

    // Count how many components have merge flag
    function getMergeCount() {
        const videoOut = getVideoOutput();
        const audioOut = getAudioOutput();
        const subsOut = getSubsOutput();
        return [hasMergeFlag(videoOut), hasMergeFlag(audioOut), hasMergeFlag(subsOut)].filter(Boolean).length;
    }

    // Set ALL components to merge (preserving separate flags)
    function setAllToMerge() {
        const videoOut = getVideoOutput();
        const audioOut = getAudioOutput();
        const subsOut = getSubsOutput();

        setVideoOutput(hasSeparateFlag(videoOut) ? 'merge-separate' : 'merge');
        setAudioOutput(hasSeparateFlag(audioOut) ? 'merge-separate' : 'merge');
        setSubsOutput(hasSeparateFlag(subsOut) ? 'merge-separate' : 'merge');
    }

    // If only one component has merge, remove it (can't merge alone)
    function cleanupLoneMerge() {
        if (getMergeCount() === 1) {
            const videoOut = getVideoOutput();
            const audioOut = getAudioOutput();
            const subsOut = getSubsOutput();

            if (hasMergeFlag(videoOut)) {
                setVideoOutput(hasSeparateFlag(videoOut) ? 'separate' : 'none');
            }
            if (hasMergeFlag(audioOut)) {
                setAudioOutput(hasSeparateFlag(audioOut) ? 'separate' : 'none');
            }
            if (hasMergeFlag(subsOut)) {
                setSubsOutput(hasSeparateFlag(subsOut) ? 'separate' : 'none');
            }
        }
    }

    // Toggle merge for a component
    function toggleMerge(componentType, currentOutput, setter) {
        const hadMerge = hasMergeFlag(currentOutput);

        if (hadMerge) {
            // Clicking merge OFF: remove merge from THIS component only
            setter(hasSeparateFlag(currentOutput) ? 'separate' : 'none');
            // If only 1 merge remains after this, remove it too (can't merge alone)
            cleanupLoneMerge();
        } else {
            // Clicking merge ON: set ALL components to merge
            // User can deselect the ones they don't want
            setAllToMerge();
        }
    }

    // Toggle separate for a component
    function toggleSeparate(componentType, currentOutput, setter) {
        const hadMerge = hasMergeFlag(currentOutput);
        const hadSeparate = hasSeparateFlag(currentOutput);

        if (hadSeparate) {
            // Remove separate
            setter(hadMerge ? 'merge' : 'none');
        } else {
            // Add separate
            if (currentOutput === 'none') {
                setter('separate');
            } else {
                setter('merge-separate');
            }
        }
        // No need to cleanup here - separate doesn't affect merge count
    }

    // Set component to None
    function setToNone(setter) {
        setter('none');
        // After setting to none, check if only 1 merge left
        cleanupLoneMerge();
    }

    function validateSettings() {
        const state = getComponentStates();

        // Rule 1: At least one component must be non-None
        if (state.activeCount === 0) {
            return { valid: false };
        }

        return { valid: true };
    }

    //================================================================================
    // CONTEXT MENU
    //================================================================================

    const ContextMenu = {
        shadowHost: null,
        shadowRoot: null,
        element: null,
        isOpen: false,
        closeHandler: null,
        updateFunctions: [], // Store update functions for dynamic state

        getStyles() {
            return `
                :host { all: initial; }
                * { box-sizing: border-box; }

                .ytdlp-context-menu {
                    position: fixed;
                    background: #2d2d2d;
                    border: 1px solid #444;
                    border-radius: 8px;
                    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
                    padding: 8px;
                    z-index: 2147483647;
                    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
                    font-size: 13px;
                    color: #e0e0e0;
                    user-select: none;
                    pointer-events: auto;
                    display: none;
                    width: fit-content;
                }

                .ytdlp-context-menu.visible { display: block; }

                /* Component rows (Video, Audio, Subtitle) */
                .ytdlp-component-row {
                    padding: 8px 12px;
                    border-radius: 4px;
                    position: relative;
                    transition: background 0.15s;
                }

                .ytdlp-component-row:not(:last-child) {
                    border-bottom: 1px solid #3a3a3a;
                }

                .ytdlp-component-row:hover {
                    background: #3a3a3a;
                }



                .ytdlp-component-header {
                    display: flex;
                    align-items: center;
                    justify-content: space-between;
                    margin-bottom: 6px;
                }

                .ytdlp-component-title {
                    font-size: 11px;
                    font-weight: 600;
                    text-transform: uppercase;
                    letter-spacing: 0.5px;
                    color: #aaa;
                }

                .ytdlp-component-arrow {
                    font-size: 9px;
                    opacity: 0.5;
                    transition: opacity 0.15s;
                }

                .ytdlp-component-row:hover .ytdlp-component-arrow {
                    opacity: 1;
                }

                /* Output mode buttons */
                .ytdlp-output-modes {
                    display: flex;
                    gap: 4px;
                }

                .ytdlp-output-btn {
                    padding: 4px 8px;
                    font-size: 11px;
                    border-radius: 4px;
                    cursor: pointer;
                    background: #3a3a3a;
                    color: #999;
                    transition: all 0.15s;
                    border: 1px solid transparent;
                }

                .ytdlp-output-btn:hover:not(.disabled) {
                    background: #444;
                    color: #ccc;
                }

                .ytdlp-output-btn.selected {
                    background: #4a6da7;
                    color: #fff;
                    border-color: #5a7db7;
                }

                .ytdlp-output-btn.disabled {
                    opacity: 0.35;
                    cursor: not-allowed;
                    pointer-events: none;
                }

                .ytdlp-output-btn[data-tooltip] {
                    position: relative;
                }

                .ytdlp-output-btn[data-tooltip]:hover::before {
                    content: attr(data-tooltip);
                    position: absolute;
                    bottom: calc(100% + 6px);
                    left: 50%;
                    transform: translateX(-50%);
                    background: rgba(0, 0, 0, 0.9);
                    color: #fff;
                    padding: 4px 8px;
                    border-radius: 4px;
                    font-size: 10px;
                    white-space: nowrap;
                    z-index: 100;
                    pointer-events: none;
                }

                /* Detail submenu */
                .ytdlp-detail-submenu {
                    display: none;
                    position: absolute;
                    right: calc(100% + 4px);
                    top: 0;
                    background: #2d2d2d;
                    border: 1px solid #444;
                    border-radius: 8px;
                    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
                    padding: 8px;
                    min-width: 140px;
                }

                .ytdlp-component-row.submenu-open .ytdlp-detail-submenu {
                    display: block;
                }

                .ytdlp-detail-section {
                    padding: 4px 0;
                }

                .ytdlp-detail-section:not(:last-child) {
                    border-bottom: 1px solid #3a3a3a;
                    margin-bottom: 4px;
                    padding-bottom: 8px;
                }

                .ytdlp-detail-header {
                    padding: 4px 10px;
                    font-size: 10px;
                    text-transform: uppercase;
                    color: #888;
                    letter-spacing: 0.5px;
                }

                .ytdlp-detail-item {
                    padding: 6px 10px;
                    font-size: 12px;
                    cursor: pointer;
                    border-radius: 4px;
                    transition: background 0.15s;
                    display: flex;
                    align-items: center;
                    gap: 8px;
                }

                .ytdlp-detail-item:hover {
                    background: #3a3a3a;
                }

                .ytdlp-detail-item.selected::before {
                    content: '✓';
                    font-size: 10px;
                    color: #4CAF50;
                    width: 12px;
                }

                .ytdlp-detail-item:not(.selected)::before {
                    content: '';
                    width: 12px;
                    display: inline-block;
                }

                /* Simple menu row (Comments) */
                .ytdlp-menu-row {
                    display: flex;
                    align-items: center;
                    padding: 10px 12px;
                    border-radius: 4px;
                    cursor: pointer;
                    transition: background 0.15s;
                }

                .ytdlp-menu-row:hover {
                    background: #3a3a3a;
                }

                .ytdlp-menu-row:not(:last-child) {
                    border-bottom: 1px solid #3a3a3a;
                }

                /* Download button */
                .ytdlp-download-btn {
                    display: flex;
                    align-items: center;
                    justify-content: center;
                    padding: 10px 12px;
                    margin-top: 4px;
                    background: #4CAF50;
                    color: #fff;
                    font-weight: 500;
                    font-size: 13px;
                    border-radius: 4px;
                    cursor: pointer;
                    transition: background 0.15s;
                }

                .ytdlp-download-btn:hover:not(.disabled) {
                    background: #43a047;
                }

                .ytdlp-download-btn.disabled {
                    background: #555;
                    cursor: not-allowed;
                    opacity: 0.6;
                }
            `;
        },

        init() {
            if (this.shadowHost) return;

            try {
                this.shadowHost = document.createElement('div');
                this.shadowHost.id = 'ytdlp-context-menu-host';
                Object.assign(this.shadowHost.style, {
                    position: 'fixed',
                    top: '0',
                    left: '0',
                    width: '0',
                    height: '0',
                    overflow: 'visible',
                    zIndex: '2147483647',
                    pointerEvents: 'none'
                });

                this.shadowRoot = this.shadowHost.attachShadow({ mode: 'open' });

                const style = document.createElement('style');
                style.textContent = this.getStyles();
                this.shadowRoot.appendChild(style);

                this.element = document.createElement('div');
                this.element.className = 'ytdlp-context-menu';
                this.shadowRoot.appendChild(this.element);

                document.body.appendChild(this.shadowHost);

                document.addEventListener('keydown', (e) => {
                    if (e.key === 'Escape' && this.isOpen) this.hide();
                }, true);
            } catch (e) {
                console.error('[yt-dlp] Failed to init context menu:', e);
            }
        },

        show(x, y, onDownload) {
            if (!this.shadowHost) this.init();
            if (!this.element) return;

            this.isOpen = true;
            this.element.textContent = '';
            this.updateFunctions = [];

            const showCodecOption = isYouTubeDomain();
            let downloadBtn = null;

            // Master update function - updates all dynamic states
            const updateAllStates = () => {
                this.updateFunctions.forEach(fn => {
                    try { fn(); } catch (e) { console.warn('[yt-dlp] Update error:', e); }
                });
                updateDownloadState();
            };

            // Helper to update download button state
            const updateDownloadState = () => {
                if (downloadBtn) {
                    const valid = validateSettings().valid;
                    downloadBtn.classList.toggle('disabled', !valid);
                }
            };

            // Helper to build a component row (Video, Audio, Subtitle)
            const buildComponentRow = (title, componentType, outputGetter, outputSetter, detailBuilder) => {
                const row = document.createElement('div');
                row.className = 'ytdlp-component-row';

                // Header with title and arrow
                const header = document.createElement('div');
                header.className = 'ytdlp-component-header';

                const titleEl = document.createElement('span');
                titleEl.className = 'ytdlp-component-title';
                titleEl.textContent = title;
                header.appendChild(titleEl);

                const arrow = document.createElement('span');
                arrow.className = 'ytdlp-component-arrow';
                arrow.textContent = '◀';
                header.appendChild(arrow);

                row.appendChild(header);

                // Output mode buttons: [Merge] [Sep] [None]
                const modesContainer = document.createElement('div');
                modesContainer.className = 'ytdlp-output-modes';

                const mergeBtn = document.createElement('div');
                mergeBtn.className = 'ytdlp-output-btn';
                mergeBtn.textContent = 'Merge';
                mergeBtn.setAttribute('data-tooltip', 'Include in merged file (syncs with other components)');

                const sepBtn = document.createElement('div');
                sepBtn.className = 'ytdlp-output-btn';
                sepBtn.textContent = 'Sep';
                sepBtn.setAttribute('data-tooltip', 'Keep as standalone file');

                const noneBtn = document.createElement('div');
                noneBtn.className = 'ytdlp-output-btn';
                noneBtn.textContent = 'None';
                noneBtn.setAttribute('data-tooltip', 'Do not download this component');

                mergeBtn.onclick = (e) => {
                    e.preventDefault();
                    e.stopPropagation();
                    toggleMerge(componentType, outputGetter(), outputSetter);
                    updateAllStates();
                };

                sepBtn.onclick = (e) => {
                    e.preventDefault();
                    e.stopPropagation();
                    toggleSeparate(componentType, outputGetter(), outputSetter);
                    updateAllStates();
                };

                noneBtn.onclick = (e) => {
                    e.preventDefault();
                    e.stopPropagation();
                    setToNone(outputSetter);
                    updateAllStates();
                };

                modesContainer.appendChild(mergeBtn);
                modesContainer.appendChild(sepBtn);
                modesContainer.appendChild(noneBtn);
                row.appendChild(modesContainer);

                // Detail submenu
                const submenu = document.createElement('div');
                submenu.className = 'ytdlp-detail-submenu';
                detailBuilder(submenu);
                row.appendChild(submenu);

                // Update function for this row
                const updateRowState = () => {
                    const currentOutput = outputGetter();
                    const isMerge = hasMergeFlag(currentOutput);
                    const isSeparate = hasSeparateFlag(currentOutput);
                    const isNone = currentOutput === 'none';

                    // Update button states - Merge and Sep can both be selected
                    // Merge is ALWAYS clickable (clicking sets all to merge)
                    mergeBtn.classList.toggle('selected', isMerge);
                    sepBtn.classList.toggle('selected', isSeparate);
                    noneBtn.classList.toggle('selected', isNone);
                };

                // Register update function
                this.updateFunctions.push(updateRowState);
                updateRowState();

                // Hover to open submenu (always available)
                row.addEventListener('mouseenter', () => {
                    row.classList.add('submenu-open');
                    requestAnimationFrame(() => {
                        try {
                            const rowRect = row.getBoundingClientRect();
                            const submenuRect = submenu.getBoundingClientRect();
                            submenu.style.top = '0';
                            submenu.style.bottom = '';
                            if (rowRect.top + submenuRect.height > window.innerHeight - 10) {
                                submenu.style.top = 'auto';
                                submenu.style.bottom = '0';
                            }
                        } catch (e) { /* ignore */ }
                    });
                });

                row.addEventListener('mouseleave', () => {
                    row.classList.remove('submenu-open');
                });

                return row;
            };

            // Helper to build detail items
            const buildDetailSection = (container, headerText, options, currentValueGetter, onSelect) => {
                const section = document.createElement('div');
                section.className = 'ytdlp-detail-section';

                const header = document.createElement('div');
                header.className = 'ytdlp-detail-header';
                header.textContent = headerText;
                section.appendChild(header);

                const items = [];

                options.forEach(opt => {
                    const item = document.createElement('div');
                    item.className = 'ytdlp-detail-item';
                    item.textContent = opt.label;

                    item.onclick = (e) => {
                        e.preventDefault();
                        e.stopPropagation();
                        items.forEach(el => el.classList.remove('selected'));
                        item.classList.add('selected');
                        onSelect(opt.id);
                    };

                    items.push(item);
                    section.appendChild(item);
                });

                // Update selection state
                const updateSelection = () => {
                    const currentValue = currentValueGetter();
                    items.forEach((item, index) => {
                        item.classList.toggle('selected', options[index].id === currentValue);
                    });
                };

                updateSelection();
                container.appendChild(section);
            };

            // ─────────────────────────────────────────────────────────────
            // VIDEO ROW
            // ─────────────────────────────────────────────────────────────
            const videoRow = buildComponentRow('VIDEO', 'video', getVideoOutput, setVideoOutput, (submenu) => {
                buildDetailSection(submenu, 'Quality', VIDEO_QUALITIES, getVideoQuality, setVideoQuality);
                if (showCodecOption) {
                    buildDetailSection(submenu, 'Codec', VIDEO_CODECS, getVideoCodec, setVideoCodec);
                }
            });
            this.element.appendChild(videoRow);

            // ─────────────────────────────────────────────────────────────
            // AUDIO ROW
            // ─────────────────────────────────────────────────────────────
            const audioRow = buildComponentRow('AUDIO', 'audio', getAudioOutput, setAudioOutput, (submenu) => {
                buildDetailSection(submenu, 'Quality', AUDIO_QUALITIES, getAudioQuality, setAudioQuality);
            });
            this.element.appendChild(audioRow);

            // ─────────────────────────────────────────────────────────────
            // SUBTITLE ROW
            // ─────────────────────────────────────────────────────────────
            const subtitleRow = buildComponentRow('SUBTITLE', 'subs', getSubsOutput, setSubsOutput, (submenu) => {
                buildDetailSection(submenu, 'Format', SUBTITLE_FORMATS, getSubsFormat, setSubsFormat);
            });
            this.element.appendChild(subtitleRow);

            // ─────────────────────────────────────────────────────────────
            // COMMENTS ROW
            // ─────────────────────────────────────────────────────────────
            const commentsRow = document.createElement('div');
            commentsRow.className = 'ytdlp-menu-row';
            commentsRow.textContent = '💬 Comments Only';
            commentsRow.onclick = (e) => {
                e.preventDefault();
                e.stopPropagation();
                onDownload('comments');
            };
            this.element.appendChild(commentsRow);

            // ─────────────────────────────────────────────────────────────
            // DOWNLOAD BUTTON
            // ─────────────────────────────────────────────────────────────
            downloadBtn = document.createElement('div');
            downloadBtn.className = 'ytdlp-download-btn';
            downloadBtn.textContent = '⬇ Download';

            downloadBtn.onclick = (e) => {
                e.preventDefault();
                e.stopPropagation();
                if (downloadBtn.classList.contains('disabled')) return;
                if (!validateSettings().valid) return;
                onDownload('media');
            };

            this.element.appendChild(downloadBtn);
            updateDownloadState();

            // Initial update of all states
            updateAllStates();

            // Position menu
            this.element.classList.add('visible');
            requestAnimationFrame(() => {
                try {
                    const rect = this.element.getBoundingClientRect();
                    let left = x - rect.width - 10;
                    let top = y;
                    if (left < 10) left = 10;
                    if (top + rect.height > window.innerHeight - 10) top = window.innerHeight - rect.height - 10;
                    if (top < 10) top = 10;
                    this.element.style.left = left + 'px';
                    this.element.style.top = top + 'px';
                } catch (e) {
                    this.element.style.left = '10px';
                    this.element.style.top = '10px';
                }
            });

            // Outside click handler (only way to close)
            if (this.closeHandler) {
                document.removeEventListener('mousedown', this.closeHandler, true);
            }
            const self = this;
            this.closeHandler = (e) => {
                if (!self.isOpen) return;
                try {
                    if (e.composedPath().includes(self.shadowHost)) return;
                } catch (err) {
                    // composedPath might fail in some edge cases
                    if (self.shadowHost.contains(e.target)) return;
                }
                self.hide();
            };
            setTimeout(() => document.addEventListener('mousedown', this.closeHandler, true), 100);
        },

        hide() {
            if (this.element) {
                this.element.classList.remove('visible');
            }
            this.isOpen = false;
            this.updateFunctions = [];
            if (this.closeHandler) {
                document.removeEventListener('mousedown', this.closeHandler, true);
                this.closeHandler = null;
            }
        },

        isVisible() { return this.isOpen; }
    };

    //================================================================================
    // BATCH FILE GENERATION
    //================================================================================

    const CONFIG_UI = {
        button: {
            size: 24,
            iconStyle: {
                shadow: { enabled: true, blur: 2, color: 'rgba(255, 255, 255, 0.8)' },
                background: { enabled: true, color: 'rgba(128, 128, 128, 0.25)', borderRadius: '50%' }
            },
            position: { vertical: 'bottom', horizontal: 'right', offsetX: 1, offsetY: 29 },
            opacity: { default: 0.15, hover: 1, active: 0.7 },
            scale: { default: 1, hover: 1.1, active: 0.95 },
            zIndex: 2147483646
        },
        timing: {
            doubleClickThreshold: 350,
            hideTemporarilyDuration: 5000,
            hoverCheckInterval: 100
        }
    };

    //================================================================================
    // GLOBAL STATE
    //================================================================================

    const hostname = window.location.hostname;
    const complex = COMPLEX_SITES[hostname];
    const siteCondition = SITE_CONDITIONS[hostname];
    const COOKIE_FILE = complex?.cookieFile || `${hostname}_cookies.txt`;

    let shadowRoot = null;
    let notificationElement = null;
    let containerElement = null;

    const STATE = {
        hidden: false,
        lastUrl: window.location.href,
        checkInterval: null
    };

    //================================================================================
    // HELPER FUNCTIONS
    //================================================================================

    const sanitize = (str, max = 80) =>
        (str || 'video').replace(/[^a-zA-Z0-9]/g, '_').replace(/_+/g, '_').substring(0, max);

    function getVideoInfo() {
        if (complex?.extractUrl) {
            const result = complex.extractUrl();
            if (result.error) return result;
            return { url: result.url, filename: sanitize(document.title), cookieFile: COOKIE_FILE };
        }
        return { url: window.location.href, filename: sanitize(document.title), cookieFile: COOKIE_FILE };
    }

    const getOverwriteFlag = () => FORCE_OVERWRITE ? '--force-overwrites ' : '';

    function generateBatch(url, filename, cookieFile, mode) {
        const overwriteFlag = getOverwriteFlag();

        // ═══════════════════════════════════════════════════════════════
        // COMMENTS MODE
        // ═══════════════════════════════════════════════════════════════
        if (mode === 'comments') {
            return `@echo off
setlocal EnableDelayedExpansion
cd /d "%~dp0"
title Downloading Comments: ${filename}

echo ============================================
echo   yt-dlp Comments Downloader
echo ============================================
echo.

where yt-dlp >nul 2>nul
if %errorlevel% neq 0 (
    echo [ERROR] yt-dlp not found
    echo Install: winget install yt-dlp
    pause
    exit /b 1
)

if exist "${cookieFile}" (
    set "COOKIES=--cookies ${cookieFile}"
) else (
    set "COOKIES="
)

echo URL: ${url}
echo.

yt-dlp ${overwriteFlag}!COOKIES! "${url}" --skip-download --write-comments --no-playlist --extractor-args "youtube:comment_sort=top;max_comments=all,all,all,all" -o "${filename}.%%(ext)s"

echo.
if !errorlevel! neq 0 (
    echo [ERROR] Download failed
) else (
    echo [SUCCESS] Comments downloaded!
)
pause
`;
        }

        // ═══════════════════════════════════════════════════════════════
        // MEDIA MODE
        // ═══════════════════════════════════════════════════════════════
        const videoQuality = getVideoQuality();
        const videoCodec = getVideoCodec();
        const videoOutput = getVideoOutput();
        const audioQuality = getAudioQuality();
        const audioOutput = getAudioOutput();
        const subsFormat = getSubsFormat();
        const subsOutput = getSubsOutput();

        const hasVideo = isEnabled(videoOutput);
        const hasAudio = isEnabled(audioOutput);
        const hasSubs = isEnabled(subsOutput);

        const videoMerge = hasMergeFlag(videoOutput);
        const videoSeparate = hasSeparateFlag(videoOutput);
        const audioMerge = hasMergeFlag(audioOutput);
        const audioSeparate = hasSeparateFlag(audioOutput);
        const subsMerge = hasMergeFlag(subsOutput);
        const subsSeparate = hasSeparateFlag(subsOutput);

        // needsMerge: at least 2 components have merge flag
        // (If only 1 has merge, cleanupLoneMerge should have removed it)
        const mergeCount = [videoMerge, audioMerge, subsMerge].filter(Boolean).length;
        const needsMerge = mergeCount >= 2;

        // Build format string
        const videoFormat = findOption(VIDEO_QUALITIES, videoQuality)?.format || 'bestvideo';
        const audioFormat = findOption(AUDIO_QUALITIES, audioQuality)?.format || 'bestaudio';
        const codecArg = (hasVideo && isYouTubeDomain() && videoCodec !== 'default')
            ? findOption(VIDEO_CODECS, videoCodec)?.sortArg || ''
            : '';
        const subsConvertArg = findOption(SUBTITLE_FORMATS, subsFormat)?.convertArg || '';

        // Generate summary labels
        const videoLabel = hasVideo ? `${getLabel(VIDEO_QUALITIES, videoQuality)}${videoCodec !== 'default' ? ` [${getLabel(VIDEO_CODECS, videoCodec)}]` : ''}` : 'None';
        const audioLabel = hasAudio ? getLabel(AUDIO_QUALITIES, audioQuality) : 'None';
        const subsLabel = hasSubs ? getLabel(SUBTITLE_FORMATS, subsFormat) : 'None';
        const getOutputLabel = (output) => getLabel(OUTPUT_MODES, output);

        return `@echo off
setlocal EnableDelayedExpansion
cd /d "%~dp0"
title Downloading: ${filename}

echo ============================================
echo   yt-dlp Media Downloader
echo ============================================
echo.
echo URL: ${url}
echo.
echo Configuration:
echo   Video:     ${videoLabel} [${getOutputLabel(videoOutput)}]
echo   Audio:     ${audioLabel} [${getOutputLabel(audioOutput)}]
echo   Subtitles: ${subsLabel} [${getOutputLabel(subsOutput)}]
echo.
echo ============================================

REM === Check tools ===
where yt-dlp >nul 2>nul
if %errorlevel% neq 0 (
    echo [ERROR] yt-dlp not found
    echo Install: winget install yt-dlp
    set /p "=Press Enter to exit..." <nul & pause >nul
    exit /b 1
)
echo [OK] yt-dlp found

if exist "${cookieFile}" (
    echo [OK] Cookie file found
    set "COOKIES=--cookies ${cookieFile}"
) else (
    echo [INFO] No cookie file
    set "COOKIES="
)

${needsMerge ? `
set "HAS_MKVMERGE=0"
where mkvmerge >nul 2>nul
if %errorlevel% equ 0 (
    set "HAS_MKVMERGE=1"
    echo [OK] mkvmerge found
) else (
    echo [INFO] mkvmerge not found - using FFmpeg
)
` : ''}

${hasSubs ? `
echo.
echo ============================================
echo   Available Subtitles
echo ============================================
echo.
yt-dlp --list-subs !COOKIES! "${url}"
echo.
echo ============================================
echo Enter language code (e.g., en, de, ja)
echo Or type 'all' for all languages
echo Or press Enter to skip subtitles
echo ============================================
echo.
set "SUB_LANG="
set /p "SUB_LANG=Language code: "

if "!SUB_LANG!"=="" (
    echo.
    echo Skipping subtitles...
    set "DO_SUBS=0"
) else (
    set "DO_SUBS=1"
    set "SUB_ARGS=--write-subs --write-auto-subs --sub-langs !SUB_LANG!${subsConvertArg ? ` ${subsConvertArg}` : ''}"
)
` : 'set "DO_SUBS=0"'}

echo.
echo ============================================
echo   Starting Download...
echo ============================================
echo.

${generateDownloadCommands(url, filename, {
    hasVideo, hasAudio, hasSubs,
    videoMerge, videoSeparate, audioMerge, audioSeparate, subsMerge, subsSeparate,
    needsMerge,
    videoFormat, audioFormat, codecArg,
    overwriteFlag
})}

echo.
if !errorlevel! neq 0 (
    echo ============================================
    echo   [ERROR] Download failed
    echo ============================================
    echo.
    echo Press Enter to exit...
    set /p "="
) else (
    echo ============================================
    echo   [SUCCESS] Download complete!
    echo ============================================
    echo.
    echo Saved to: %~dp0
    echo.
    echo Press Enter to exit...
    set /p "="
)
`;
    }

    function generateDownloadCommands(url, filename, opts) {
        const {
            hasVideo, hasAudio, hasSubs,
            videoMerge, videoSeparate, audioMerge, audioSeparate, subsMerge, subsSeparate,
            needsMerge,
            videoFormat, audioFormat, codecArg,
            overwriteFlag
        } = opts;

        // Check if this is YouTube - affects format string strategy
        const isYouTube = isYouTubeDomain();

        // Determine merged output extension
        const mergedExt = (hasVideo && videoMerge) ? 'mkv' : 'mka';

        // ═══════════════════════════════════════════════════════════════
        // CASE 1: Subtitles ONLY (no video, no audio)
        // ═══════════════════════════════════════════════════════════════
        if (!hasVideo && !hasAudio && hasSubs) {
            return `
if "!DO_SUBS!"=="1" (
    echo Downloading subtitles only...
    yt-dlp ${overwriteFlag}!COOKIES! "${url}" --skip-download !SUB_ARGS! -o "${filename}.%%(ext)s"
    if !errorlevel! neq 0 (
        echo [ERROR] Subtitle download failed
    ) else (
        echo [OK] Subtitles downloaded
    )
) else (
    echo No subtitles requested. Nothing to download.
)`;
        }

        // ═══════════════════════════════════════════════════════════════
        // COMBINED STREAM SITES: Use simpler approach with /best fallback
        // These sites don't have separate video/audio streams
        // ═══════════════════════════════════════════════════════════════
        if (isCombinedStreamSite()) {
            // Build format string with fallback for combined streams
            let formatStr = 'bestvideo+bestaudio/best';
            if (hasVideo && !hasAudio) {
                formatStr = 'bestvideo/best';
            } else if (!hasVideo && hasAudio) {
                formatStr = 'bestaudio/best';
            }

            const formatArg = `-f "${formatStr}"`;
            const mergeArg = hasVideo ? '--merge-output-format mkv' : '';

            if (hasSubs) {
                return `echo Downloading from combined-stream site...
if "!DO_SUBS!"=="1" (
    yt-dlp ${overwriteFlag}!COOKIES! "${url}" ${formatArg} ${mergeArg} !SUB_ARGS! --embed-subs -o "${filename}.%%(ext)s"
) else (
    yt-dlp ${overwriteFlag}!COOKIES! "${url}" ${formatArg} ${mergeArg} -o "${filename}.%%(ext)s"
)
if !errorlevel! neq 0 (
    echo [ERROR] Download failed
) else (
    echo [OK] Download complete
)`;
            } else {
                return `echo Downloading from combined-stream site...
yt-dlp ${overwriteFlag}!COOKIES! "${url}" ${formatArg} ${mergeArg} -o "${filename}.%%(ext)s"
if !errorlevel! neq 0 (
    echo [ERROR] Download failed
) else (
    echo [OK] Download complete
)`;
            }
        }

        // ═══════════════════════════════════════════════════════════════
        // YOUTUBE: Build format string for separate streams
        // ═══════════════════════════════════════════════════════════════
        const formats = [];
        if (hasVideo) formats.push(videoFormat);
        if (hasAudio) formats.push(audioFormat);
        const formatArg = formats.length > 0 ? `-f "${formats.join(',')}"` : '';

        // ═══════════════════════════════════════════════════════════════
        // CASE 2: No merging needed - direct download with clean names
        // ═══════════════════════════════════════════════════════════════
        if (!needsMerge) {
            // All components are either None or Separate - no merge step needed
            // Download directly to final filenames

            let downloadCmd = '';

            if (hasVideo && hasAudio) {
                // Both video and audio - use descriptive suffixes
                downloadCmd = `yt-dlp ${overwriteFlag}!COOKIES! "${url}" ${formatArg}${codecArg ? ` ${codecArg}` : ''} -o "${filename}.video.%%(ext)s" -o "audio:${filename}.audio.%%(ext)s"`;
            } else if (hasVideo) {
                downloadCmd = `yt-dlp ${overwriteFlag}!COOKIES! "${url}" ${formatArg}${codecArg ? ` ${codecArg}` : ''} -o "${filename}.%%(ext)s"`;
            } else if (hasAudio) {
                downloadCmd = `yt-dlp ${overwriteFlag}!COOKIES! "${url}" ${formatArg}${codecArg ? ` ${codecArg}` : ''} -o "${filename}.%%(ext)s"`;
            }

            if (hasSubs) {
                // Add subtitle args if subs enabled
                return `echo Downloading${hasVideo ? ' video' : ''}${hasAudio ? (hasVideo ? ' + audio' : ' audio') : ''}${hasSubs ? ' + subtitles' : ''}...
if "!DO_SUBS!"=="1" (
    ${downloadCmd} !SUB_ARGS! -o "subtitle:${filename}.%%(ext)s"
) else (
    ${downloadCmd}
)
if !errorlevel! neq 0 (
    echo [ERROR] Download failed
) else (
    echo [OK] Download complete
)`;
            } else {
                return `echo Downloading${hasVideo ? ' video' : ''}${hasAudio ? (hasVideo ? ' + audio' : ' audio') : ''}...
${downloadCmd}
if !errorlevel! neq 0 (
    echo [ERROR] Download failed
) else (
    echo [OK] Download complete
)`;
            }
        }

        // ═══════════════════════════════════════════════════════════════
        // CASE 3: YouTube merging required - complex post-processing
        // Download to temp files, identify, merge selectively, copy separates, cleanup
        // ═══════════════════════════════════════════════════════════════

        return `
set "TEMP_BASE=_ytdlp_tmp_%RANDOM%%RANDOM%"
set "DL_ERROR=0"

REM ════════════════════════════════════════════════════════════════════════════
REM PHASE 1: Download all components in ONE call (single metadata fetch)
REM ════════════════════════════════════════════════════════════════════════════
echo.
echo [PHASE 1] Downloading all components...
${hasSubs ? `if "!DO_SUBS!"=="1" (
    yt-dlp ${overwriteFlag}!COOKIES! "${url}" ${formatArg}${codecArg ? ` ${codecArg}` : ''} !SUB_ARGS! -o "!TEMP_BASE!.%%(format_id)s.%%(ext)s" -o "subtitle:!TEMP_BASE!_sub.%%(ext)s"
    if !errorlevel! neq 0 set "DL_ERROR=1"
) else (
    yt-dlp ${overwriteFlag}!COOKIES! "${url}" ${formatArg}${codecArg ? ` ${codecArg}` : ''} -o "!TEMP_BASE!.%%(format_id)s.%%(ext)s"
    if !errorlevel! neq 0 set "DL_ERROR=1"
)` : `yt-dlp ${overwriteFlag}!COOKIES! "${url}" ${formatArg}${codecArg ? ` ${codecArg}` : ''} -o "!TEMP_BASE!.%%(format_id)s.%%(ext)s"
if !errorlevel! neq 0 set "DL_ERROR=1"`}

if "!DL_ERROR!"=="1" (
    echo.
    echo [ERROR] Download failed
    del /Q "!TEMP_BASE!*.*" 2>nul
    goto :script_end
)

REM ════════════════════════════════════════════════════════════════════════════
REM PHASE 2: Identify downloaded files using subroutine
REM ════════════════════════════════════════════════════════════════════════════
echo.
echo [PHASE 2] Identifying downloaded files...
set "VIDEO_FILE="
set "AUDIO_FILE="
set "SUB_COUNT=0"

REM Check for ffprobe availability (for ambiguous formats)
set "HAS_FFPROBE=0"
where ffprobe >nul 2>nul && set "HAS_FFPROBE=1"

REM Process each downloaded file
for %%f in ("!TEMP_BASE!*.*") do call :identify_file "%%f"
goto :after_identify

:identify_file
set "fpath=%~1"
set "fname=%~n1"
set "fext=%~x1"

REM Check if this is a subtitle file (filename contains _sub)
echo !fname! | findstr /i "_sub" >nul
if !errorlevel! equ 0 (
    set /a "SUB_COUNT+=1"
    echo   [SUB !SUB_COUNT!] %~nx1
    goto :eof
)

REM === Determine if VIDEO or AUDIO based on extension ===
REM Audio-only extensions
if /i "!fext!"==".m4a" (
    set "AUDIO_FILE=!fpath!"
    echo   [AUDIO] %~nx1
    goto :eof
)
if /i "!fext!"==".mp3" (
    set "AUDIO_FILE=!fpath!"
    echo   [AUDIO] %~nx1
    goto :eof
)
if /i "!fext!"==".opus" (
    set "AUDIO_FILE=!fpath!"
    echo   [AUDIO] %~nx1
    goto :eof
)
if /i "!fext!"==".ogg" (
    set "AUDIO_FILE=!fpath!"
    echo   [AUDIO] %~nx1
    goto :eof
)
if /i "!fext!"==".aac" (
    set "AUDIO_FILE=!fpath!"
    echo   [AUDIO] %~nx1
    goto :eof
)
if /i "!fext!"==".flac" (
    set "AUDIO_FILE=!fpath!"
    echo   [AUDIO] %~nx1
    goto :eof
)
if /i "!fext!"==".wav" (
    set "AUDIO_FILE=!fpath!"
    echo   [AUDIO] %~nx1
    goto :eof
)

REM Video-only extensions
if /i "!fext!"==".mp4" (
    set "VIDEO_FILE=!fpath!"
    echo   [VIDEO] %~nx1
    goto :eof
)
if /i "!fext!"==".mkv" (
    set "VIDEO_FILE=!fpath!"
    echo   [VIDEO] %~nx1
    goto :eof
)
if /i "!fext!"==".avi" (
    set "VIDEO_FILE=!fpath!"
    echo   [VIDEO] %~nx1
    goto :eof
)
if /i "!fext!"==".mov" (
    set "VIDEO_FILE=!fpath!"
    echo   [VIDEO] %~nx1
    goto :eof
)
if /i "!fext!"==".flv" (
    set "VIDEO_FILE=!fpath!"
    echo   [VIDEO] %~nx1
    goto :eof
)
if /i "!fext!"==".ts" (
    set "VIDEO_FILE=!fpath!"
    echo   [VIDEO] %~nx1
    goto :eof
)
if /i "!fext!"==".3gp" (
    set "VIDEO_FILE=!fpath!"
    echo   [VIDEO] %~nx1
    goto :eof
)

REM === Ambiguous extension (.webm can be video or audio) ===
REM Try ffprobe first if available
if "!HAS_FFPROBE!"=="1" (
    ffprobe -v error -select_streams v:0 -show_entries stream=codec_type -of csv=p=0 "!fpath!" 2>nul | findstr /i "video" >nul
    if !errorlevel! equ 0 (
        set "VIDEO_FILE=!fpath!"
        echo   [VIDEO] %~nx1 ^(detected via ffprobe^)
    ) else (
        set "AUDIO_FILE=!fpath!"
        echo   [AUDIO] %~nx1 ^(detected via ffprobe^)
    )
    goto :eof
)

REM Fallback: if VIDEO_FILE not set, assume this is video
if not defined VIDEO_FILE (
    set "VIDEO_FILE=!fpath!"
    echo   [VIDEO] %~nx1 ^(assumed^)
) else (
    set "AUDIO_FILE=!fpath!"
    echo   [AUDIO] %~nx1 ^(assumed^)
)
goto :eof

:after_identify

REM Verify we found expected files
${hasVideo ? `if not defined VIDEO_FILE (
    echo [WARNING] Expected video file not found
)` : ''}
${hasAudio ? `if not defined AUDIO_FILE (
    echo [WARNING] Expected audio file not found
)` : ''}

REM ════════════════════════════════════════════════════════════════════════════
REM PHASE 3: Merge files (only components with Merge flag)
REM ════════════════════════════════════════════════════════════════════════════
echo.
echo [PHASE 3] Merging selected components...

if "!HAS_MKVMERGE!"=="1" (
    set "MKV_INPUTS="
    set "MKV_COUNT=0"

    ${videoMerge && hasVideo ? `
    if defined VIDEO_FILE (
        set "MKV_INPUTS=!MKV_INPUTS! "!VIDEO_FILE!""
        set /a "MKV_COUNT+=1"
        echo   Adding to merge: VIDEO
    )` : `REM Video not included in merge`}

    ${audioMerge && hasAudio ? `
    if defined AUDIO_FILE (
        set "MKV_INPUTS=!MKV_INPUTS! "!AUDIO_FILE!""
        set /a "MKV_COUNT+=1"
        echo   Adding to merge: AUDIO
    )` : `REM Audio not included in merge`}

    ${subsMerge && hasSubs ? `
    if "!DO_SUBS!"=="1" (
        for %%s in ("!TEMP_BASE!_sub.*") do (
            if exist "%%s" (
                set "MKV_INPUTS=!MKV_INPUTS! "%%s""
                set /a "MKV_COUNT+=1"
                echo   Adding to merge: %%~nxs
            )
        )
    )` : `REM Subtitles not included in merge`}

    if !MKV_COUNT! geq 2 (
        echo.
        echo   Running mkvmerge...
        mkvmerge -o "${filename}.${mergedExt}" !MKV_INPUTS!
        if !errorlevel! neq 0 (
            echo   [ERROR] mkvmerge failed
            set "DL_ERROR=1"
        ) else (
            echo   [OK] Created: ${filename}.${mergedExt}
        )
    ) else if !MKV_COUNT! equ 1 (
        echo   [WARNING] Only 1 component for merge - copying instead
        for %%f in (!MKV_INPUTS!) do copy "%%~f" "${filename}.${mergedExt}" >nul
        echo   [OK] Created: ${filename}.${mergedExt}
    ) else (
        echo   [WARNING] No components to merge
        set "DL_ERROR=1"
    )
) else (
    echo   mkvmerge not found - using FFmpeg fallback...
    ${(videoMerge && hasVideo && audioMerge && hasAudio) ? `
    REM Re-download with FFmpeg merge (still single metadata fetch from cache)
    yt-dlp ${overwriteFlag}!COOKIES! "${url}" -f "${videoFormat}+${audioFormat}"${codecArg ? ` ${codecArg}` : ''} --merge-output-format mkv${subsMerge && hasSubs ? ` --embed-subs !SUB_ARGS!` : ''} -o "${filename}.%%(ext)s"
    if !errorlevel! neq 0 (
        echo   [ERROR] FFmpeg merge failed
        set "DL_ERROR=1"
    ) else (
        echo   [OK] Created: ${filename}.mkv
    )` : `
    REM FFmpeg fallback for non-standard merge combinations
    echo   [WARNING] Complex merge not supported without mkvmerge
    echo   Please install mkvmerge: https://mkvtoolnix.download/
    set "DL_ERROR=1"`}
)

REM ════════════════════════════════════════════════════════════════════════════
REM PHASE 4: Copy files that need to be kept separately
REM ════════════════════════════════════════════════════════════════════════════
echo.
echo [PHASE 4] Creating separate copies...

${videoSeparate && hasVideo ? `
if defined VIDEO_FILE (
    for %%f in ("!VIDEO_FILE!") do (
        set "vext=%%~xf"
        copy "!VIDEO_FILE!" "${filename}.video!vext!" >nul
        if !errorlevel! equ 0 (
            echo   [OK] Created: ${filename}.video!vext!
        ) else (
            echo   [ERROR] Failed to copy video
        )
    )
)` : `REM Video separate not requested`}

${audioSeparate && hasAudio ? `
if defined AUDIO_FILE (
    for %%f in ("!AUDIO_FILE!") do (
        set "aext=%%~xf"
        copy "!AUDIO_FILE!" "${filename}.audio!aext!" >nul
        if !errorlevel! equ 0 (
            echo   [OK] Created: ${filename}.audio!aext!
        ) else (
            echo   [ERROR] Failed to copy audio
        )
    )
)` : `REM Audio separate not requested`}

${subsSeparate && hasSubs ? `
if "!DO_SUBS!"=="1" (
    for %%f in ("!TEMP_BASE!_sub.*") do (
        if exist "%%f" (
            REM Extract language.ext from TEMP_sub.lang.ext
            set "subname=%%~nxf"
            REM Remove the TEMP_sub. prefix to get lang.ext
            call set "langext=%%subname:*_sub.=%%"
            copy "%%f" "${filename}.!langext!" >nul
            if !errorlevel! equ 0 (
                echo   [OK] Created: ${filename}.!langext!
            ) else (
                echo   [ERROR] Failed to copy subtitle
            )
        )
    )
)` : `REM Subtitle separate not requested`}

REM ════════════════════════════════════════════════════════════════════════════
REM PHASE 5: Cleanup temporary files
REM ════════════════════════════════════════════════════════════════════════════
echo.
echo [PHASE 5] Cleaning up temporary files...

set "CLEANED=0"
for %%f in ("!TEMP_BASE!*.*") do (
    del /Q "%%f" 2>nul
    set /a "CLEANED+=1"
)
echo   Removed !CLEANED! temporary file(s)

:script_end
if "!DL_ERROR!"=="1" (
    echo.
    echo ════════════════════════════════════════════════════════════════════════════
    echo   [WARNING] Some operations may have failed - check messages above
    echo ════════════════════════════════════════════════════════════════════════════
)`;
    }

    //================================================================================
    // NOTIFICATIONS
    //================================================================================

    function showNotification(message, type = 'info') {
        if (!shadowRoot || !notificationElement) return;
        try {
            notificationElement.classList.remove('show');
            notificationElement.textContent = message;
            notificationElement.setAttribute('data-type', type);
            void notificationElement.offsetWidth;
            notificationElement.classList.add('show');
            setTimeout(() => notificationElement.classList.remove('show'), 2500);
        } catch (e) {
            console.warn('[yt-dlp] Notification error:', e);
        }
    }

    //================================================================================
    // MAIN FUNCTIONS
    //================================================================================

    function executeDownload(mode) {
        const info = getVideoInfo();
        if (info.error) {
            showNotification(info.error, 'error');
            return;
        }

        if (mode === 'media') {
            const validation = validateSettings();
            if (!validation.valid) {
                showNotification('Select at least one component', 'error');
                return;
            }
        }

        if (window._ytdlpUpdateTooltip) window._ytdlpUpdateTooltip();

        const content = generateBatch(info.url, info.filename, info.cookieFile, mode);
        const batchFilename = `dl_${info.filename}.bat`;

        try {
            const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
            const blobUrl = URL.createObjectURL(blob);
            const a = Object.assign(document.createElement('a'), { href: blobUrl, download: batchFilename });
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(blobUrl);
            showNotification('⬇ Downloading .bat', 'info');
        } catch (e) {
            console.error("[yt-dlp] Download failed:", e);
            showNotification('Download failed', 'error');
        }
    }

    //================================================================================
    // VISIBILITY & SPA LOGIC
    //================================================================================

    function shouldBeVisible() {
        return !STATE.hidden && (!siteCondition || siteCondition(window.location.href));
    }

    function updateVisibility() {
        if (containerElement) {
            containerElement.classList.toggle('hidden', !shouldBeVisible());
        }
    }

    function handleUrlChange() {
        if (window.location.href !== STATE.lastUrl) {
            STATE.hidden = false;
            STATE.lastUrl = window.location.href;
            updateVisibility();
        }
    }

    function startSPAMonitoring() {
        ['pushState', 'replaceState'].forEach(method => {
            const original = history[method];
            history[method] = function(...args) {
                const result = original.apply(this, args);
                setTimeout(handleUrlChange, 0);
                return result;
            };
        });
        ['popstate', 'hashchange'].forEach(event =>
            window.addEventListener(event, () => setTimeout(handleUrlChange, 0))
        );
        STATE.checkInterval = setInterval(handleUrlChange, 500);
    }

    //================================================================================
    // STYLES
    //================================================================================

    function getStyles() {
        const { size, iconStyle, position: pos, opacity, scale, zIndex } = CONFIG_UI.button;
        const iconSize = size - 4;

        return `
            :host { all: initial; }
            * { box-sizing: border-box; }

            .ytdlp-container {
                position: fixed;
                ${pos.vertical}: ${pos.offsetY}px;
                ${pos.horizontal}: ${pos.offsetX}px;
                z-index: ${zIndex};
                pointer-events: auto;
                user-select: none;
            }

            .ytdlp-container.hidden { display: none !important; }

            .ytdlp-btn {
                position: relative;
                width: ${size}px;
                height: ${size}px;
                background: transparent;
                border: none;
                cursor: pointer;
                opacity: ${opacity.default};
                display: flex;
                align-items: center;
                justify-content: center;
                padding: 0;
                margin: 0;
                transform: scale(${scale.default});
                touch-action: manipulation;
                -webkit-tap-highlight-color: transparent;
            }

            .ytdlp-btn[data-hover="true"] { opacity: ${opacity.hover}; transform: scale(${scale.hover}); }

            .ytdlp-icon-container {
                display: flex;
                align-items: center;
                justify-content: center;
                width: ${size}px;
                height: ${size}px;
                border-radius: ${iconStyle.background.enabled ? iconStyle.background.borderRadius : '0'};
                background-color: ${iconStyle.background.enabled ? iconStyle.background.color : 'transparent'};
                pointer-events: none;
            }

            .ytdlp-icon {
                width: ${iconSize}px;
                height: ${iconSize}px;
                display: block;
                pointer-events: none;
                ${iconStyle.shadow.enabled ? `filter: drop-shadow(0 0 ${iconStyle.shadow.blur}px ${iconStyle.shadow.color}) drop-shadow(0 0 ${iconStyle.shadow.blur * 0.5}px ${iconStyle.shadow.color});` : ''}
            }

            .ytdlp-notification {
                position: fixed;
                ${pos.vertical}: ${pos.offsetY + size + 10}px;
                ${pos.horizontal}: ${pos.offsetX}px;
                padding: 8px 16px;
                color: white;
                border-radius: 6px;
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
                font-size: 13px;
                font-weight: 500;
                z-index: ${zIndex - 1};
                box-shadow: 0 3px 12px rgba(0,0,0,0.25);
                opacity: 0;
                transform: translateY(10px);
                transition: opacity 0.3s, transform 0.3s;
                pointer-events: none;
            }

            .ytdlp-notification.show { opacity: 1; transform: translateY(0); }
            .ytdlp-notification[data-type="success"] { background: #4CAF50; border: 1px solid #388E3C; }
            .ytdlp-notification[data-type="error"] { background: #f44336; border: 1px solid #c62828; }
            .ytdlp-notification[data-type="info"] { background: #2196F3; border: 1px solid #1565C0; }
            .ytdlp-notification[data-type="warning"] { background: #FF9800; border: 1px solid #EF6C00; }

            .ytdlp-btn::after {
                content: attr(data-tooltip);
                position: absolute;
                ${pos.horizontal === 'right' ? 'right' : 'left'}: ${size + 8}px;
                ${pos.vertical === 'bottom' ? 'bottom' : 'top'}: 0;
                background: rgba(30, 30, 30, 0.95);
                color: #fff;
                padding: 8px 12px;
                border-radius: 6px;
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
                font-size: 12px;
                line-height: 1.5;
                white-space: pre;
                pointer-events: none;
                opacity: 0;
                visibility: hidden;
                transition: opacity 0.15s ease, visibility 0.15s ease;
                box-shadow: 0 2px 8px rgba(0,0,0,0.3);
                z-index: ${zIndex + 1};
            }

            .ytdlp-btn[data-hover="true"]::after { opacity: 1; visibility: visible; }
        `;
    }

    //================================================================================
    // FLOATING BUTTON
    //================================================================================

    function addFloatingDownloadButton() {
        if (!document.body) return;

        try {
            const shadowHost = document.createElement('div');
            shadowHost.id = 'ytdlp-downloader-host';
            Object.assign(shadowHost.style, {
                position: 'fixed', top: '0', left: '0', width: '0', height: '0',
                overflow: 'visible', zIndex: CONFIG_UI.button.zIndex.toString(),
                pointerEvents: 'none', userSelect: 'none'
            });

            shadowRoot = shadowHost.attachShadow({ mode: 'closed' });
            shadowRoot.appendChild(Object.assign(document.createElement('style'), { textContent: getStyles() }));

            containerElement = Object.assign(document.createElement('div'), { className: 'ytdlp-container' });

            const btn = Object.assign(document.createElement('div'), { className: 'ytdlp-btn' });

            const updateTooltip = () => {
                const videoOut = getVideoOutput();
                const audioOut = getAudioOutput();
                const subsOut = getSubsOutput();

                const parts = [];
                if (videoOut !== 'none') {
                    parts.push(`Video: ${getLabel(VIDEO_QUALITIES, getVideoQuality())} [${getLabel(OUTPUT_MODES, videoOut)}]`);
                }
                if (audioOut !== 'none') {
                    parts.push(`Audio: ${getLabel(AUDIO_QUALITIES, getAudioQuality())} [${getLabel(OUTPUT_MODES, audioOut)}]`);
                }
                if (subsOut !== 'none') {
                    parts.push(`Subs: ${getLabel(SUBTITLE_FORMATS, getSubsFormat())} [${getLabel(OUTPUT_MODES, subsOut)}]`);
                }
                if (parts.length === 0) {
                    parts.push('(Nothing selected)');
                }

                btn.setAttribute('data-tooltip', [
                    '𝘆𝘁-𝗱𝗹𝗽 𝗗𝗼𝘄𝗻𝗹𝗼𝗮𝗱𝗲𝗿',
                    '',
                    ...parts,
                    '',
                    'Click: Download',
                    'Right-click: Options',
                    'Double-click: Hide 5s'
                ].join('\n'));
            };

            updateTooltip();
            window._ytdlpUpdateTooltip = updateTooltip;

            const iconContainer = Object.assign(document.createElement('div'), { className: 'ytdlp-icon-container' });
            const iconSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
            ['class', 'ytdlp-icon', 'viewBox', '0 0 24 24', 'fill', 'none', 'stroke', '#555', 'stroke-width', '2', 'stroke-linecap', 'round', 'stroke-linejoin', 'round']
                .reduce((acc, val, i, arr) => (i % 2 === 0 && iconSvg.setAttribute(val, arr[i + 1]), acc), null);

            const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
            [['x', '2'], ['y', '6'], ['width', '14'], ['height', '12'], ['rx', '2'], ['ry', '2']].forEach(([k, v]) => rect.setAttribute(k, v));

            const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
            polygon.setAttribute('points', '22 8 16 12 22 16 22 8');

            iconSvg.append(rect, polygon);
            iconContainer.appendChild(iconSvg);
            btn.appendChild(iconContainer);
            containerElement.appendChild(btn);

            ContextMenu.init();
            shadowRoot.appendChild(containerElement);

            notificationElement = Object.assign(document.createElement('div'), { className: 'ytdlp-notification' });
            notificationElement.setAttribute('data-type', 'info');
            shadowRoot.appendChild(notificationElement);

            document.body.appendChild(shadowHost);

            //================================================================================
            // INTERACTION HANDLERS
            //================================================================================

            let lastClickTime = 0, hideTimeout = null;
            let hoverCheckInterval = null, lastMouseX = 0, lastMouseY = 0, isHovering = false;

            const setAttr = (attr, val) => btn.setAttribute(`data-${attr}`, val ? 'true' : 'false');
            const setHover = (val) => { if (isHovering !== val) { isHovering = val; setAttr('hover', val); } };

            const isInsideButton = (x, y) => {
                try {
                    const r = btn.getBoundingClientRect();
                    return x >= r.left - 2 && x <= r.right + 2 && y >= r.top - 2 && y <= r.bottom + 2;
                } catch (e) {
                    return false;
                }
            };

            const onGlobalMouseMove = (e) => { lastMouseX = e.clientX; lastMouseY = e.clientY; };

            const startHoverTracking = () => {
                if (hoverCheckInterval) return;
                document.addEventListener('mousemove', onGlobalMouseMove, { passive: true });
                hoverCheckInterval = setInterval(() => {
                    if (!isInsideButton(lastMouseX, lastMouseY)) { setHover(false); stopHoverTracking(); }
                }, CONFIG_UI.timing.hoverCheckInterval);
            };

            const stopHoverTracking = () => {
                if (hoverCheckInterval) { clearInterval(hoverCheckInterval); hoverCheckInterval = null; }
                document.removeEventListener('mousemove', onGlobalMouseMove);
            };

            btn.addEventListener('mouseenter', (e) => { lastMouseX = e.clientX; lastMouseY = e.clientY; setHover(true); startHoverTracking(); });
            btn.addEventListener('mouseleave', () => {});

            btn.addEventListener('click', (e) => {
                e.preventDefault();
                const now = Date.now();
                const timeSinceLastClick = now - lastClickTime;
                lastClickTime = now;

                if (ContextMenu.isVisible()) {
                    ContextMenu.hide();
                    return;
                }

                // Double-click to hide
                if (timeSinceLastClick < CONFIG_UI.timing.doubleClickThreshold) {
                    window.getSelection?.().removeAllRanges();
                    STATE.hidden = true;
                    stopHoverTracking();
                    setHover(false);
                    updateVisibility();
                    clearTimeout(hideTimeout);
                    hideTimeout = setTimeout(() => { STATE.hidden = false; updateVisibility(); }, CONFIG_UI.timing.hideTemporarilyDuration);
                    return;
                }

                // Single click - download with current settings
                executeDownload('media');
            });

            btn.addEventListener('contextmenu', (e) => {
                e.preventDefault();
                e.stopPropagation();
                ContextMenu.show(e.clientX, e.clientY, (mode) => executeDownload(mode));
            });

            window.addEventListener('beforeunload', stopHoverTracking);

            updateVisibility();
            startSPAMonitoring();
            console.log('[yt-dlp Downloader v7.8] Initialized');
        } catch (e) {
            console.error('[yt-dlp] Failed to initialize:', e);
        }
    }

    //================================================================================
    // INIT
    //================================================================================

    if (document.body) addFloatingDownloadButton();
    else document.addEventListener('DOMContentLoaded', addFloatingDownloadButton);

})();