yt-dlp Command Generator

Build a customizable yt-dlp command (quality, codec, fps, clip, mp3, subs, SponsorBlock) from the current page, then copy & run it locally. Opens from the Tampermonkey menu.

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

// ==UserScript==
// @name         yt-dlp Command Generator
// @namespace    https://gist.github.com/Jazzmedo/1efd27a8a2db4ab799de8f06877ab016
// @version      1.1.1
// @description  Build a customizable yt-dlp command (quality, codec, fps, clip, mp3, subs, SponsorBlock) from the current page, then copy & run it locally. Opens from the Tampermonkey menu.
// @author       JazzMedo
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_setClipboard
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @run-at       document-idle
// @license      MIT
// @noframes
// ==/UserScript==

/* global GM_setValue, GM_getValue, GM_setClipboard, GM_addStyle, GM_registerMenuCommand */

(function () {
    'use strict';

    // ---------------------------------------------------------------------
    // Defaults & persisted state
    // ---------------------------------------------------------------------
    const STORE_KEY = 'ytdlpgen_state_v1';

    const DEFAULTS = {
        mode: 'video',            // 'video' | 'audio'
        quality: '1080',          // height cap or 'best'
        fps: 'any',               // 'any' | '30' | '60'
        vcodec: 'avc1',           // 'avc1' | 'vp9' | 'av01' | 'any'
        acodec: 'mp4a',           // 'mp4a' | 'opus' | 'any'
        mp4Recode: false,         // --recode-video mp4 (re-encode, slow)
        audioFormat: 'mp3',       // mp3 | m4a | opus | wav | flac
        audioQuality: '0',        // 0 (best) | 320K | 256K | 192K | 128K
        clipStart: '',
        clipEnd: '',
        subs: 'off',              // 'off' | 'embed' | 'file'
        subLang: 'en.*',
        subAuto: false,
        embedThumb: false,
        embedChapters: false,
        sponsorblock: 'off',      // 'off' | 'sponsor' | 'all'
        cookies: 'none',          // none | chrome | firefox | edge | brave | chromium | opera | vivaldi | safari
        noPlaylist: true,
        fastDownload: false,
        output: '%(title)s [%(id)s].%(ext)s',
    };

    function loadState() {
        let saved = {};
        try {
            saved = GM_getValue(STORE_KEY, {}) || {};
            if (typeof saved === 'string') saved = JSON.parse(saved);
        } catch (e) {
            saved = {};
        }
        return Object.assign({}, DEFAULTS, saved);
    }

    function saveState(s) {
        try {
            GM_setValue(STORE_KEY, s);
        } catch (e) {
            /* ignore */
        }
    }

    const state = loadState();

    // ---------------------------------------------------------------------
    // Source URL handling
    // ---------------------------------------------------------------------
    function getSourceUrl() {
        const host = location.hostname;

        // Normalize YouTube to a clean single-video watch URL.
        if (/(^|\.)youtube\.com$/.test(host)) {
            const params = new URLSearchParams(location.search);
            const v = params.get('v');
            if (v) {
                let url = 'https://www.youtube.com/watch?v=' + v;
                if (!state.noPlaylist) {
                    const list = params.get('list');
                    if (list) url += '&list=' + list;
                }
                return url;
            }
            return location.origin + location.pathname;
        }
        if (host === 'youtu.be') {
            const id = location.pathname.replace(/^\//, '').split('/')[0];
            if (id) return 'https://youtu.be/' + id;
        }

        return location.href;
    }

    // ---------------------------------------------------------------------
    // Command builder
    // ---------------------------------------------------------------------
    function quote(s) {
        return '"' + String(s).replace(/"/g, '\\"') + '"';
    }

    function normalizeTime(t) {
        return String(t).trim();
    }

    function buildFormatString() {
        const h = state.quality;
        const heightFilter = h !== 'best' ? `[height<=${h}]` : '';
        const fpsFilter = state.fps !== 'any' ? `[fps<=${state.fps}]` : '';
        const vFilter = state.vcodec !== 'any' ? `[vcodec^=${state.vcodec}]` : '';
        const aFilter = state.acodec !== 'any' ? `[acodec^=${state.acodec}]` : '';

        const strict = `bestvideo${heightFilter}${fpsFilter}${vFilter}+bestaudio${aFilter}`;
        const loose = `bestvideo${heightFilter}${fpsFilter}+bestaudio`;
        const fallback = `best${heightFilter}`;
        return `${strict}/${loose}/${fallback}`;
    }

    // Produce the command as a list of typed tokens so it can be rendered as
    // both a plain string (for copying) and highlighted HTML.
    //   prog = program, flag = option, str = quoted argument,
    //   val = bare value, url = the source URL
    function commandTokens() {
        const toks = [];
        const prog = (v) => toks.push({ t: 'prog', v });
        const flag = (v) => toks.push({ t: 'flag', v });
        const val = (v) => toks.push({ t: 'val', v });
        const str = (v) => toks.push({ t: 'str', v: quote(v) });
        const url = (v) => toks.push({ t: 'url', v: quote(v) });

        prog('yt-dlp');

        if (state.mode === 'audio') {
            flag('-x');
            flag('--audio-format'); val(state.audioFormat);
            flag('--audio-quality'); val(state.audioQuality);
        } else {
            flag('-f'); str(buildFormatString());
            if (state.vcodec === 'avc1') { flag('--merge-output-format'); val('mp4'); }
            if (state.mp4Recode) { flag('--recode-video'); val('mp4'); }
        }

        const start = normalizeTime(state.clipStart);
        const end = normalizeTime(state.clipEnd);
        if (start || end) {
            const s = start || '0';
            const e = end || 'inf';
            flag('--download-sections'); str(`*${s}-${e}`);
            flag('--force-keyframes-at-cuts');
        }

        if (state.subs !== 'off') {
            flag(state.subs === 'embed' ? '--embed-subs' : '--write-subs');
            flag('--sub-langs'); str(state.subLang || 'en.*');
            if (state.subAuto) flag('--write-auto-subs');
        }

        if (state.embedThumb) { flag('--embed-thumbnail'); flag('--embed-metadata'); }
        if (state.embedChapters) flag('--embed-chapters');
        if (state.sponsorblock === 'sponsor') { flag('--sponsorblock-remove'); val('sponsor'); }
        else if (state.sponsorblock === 'all') { flag('--sponsorblock-remove'); val('all'); }
        if (state.cookies !== 'none') { flag('--cookies-from-browser'); val(state.cookies); }
        if (state.noPlaylist) flag('--no-playlist');
        if (state.fastDownload) { flag('-N'); val('4'); }
        if (state.output && state.output.trim()) { flag('-o'); str(state.output.trim()); }

        url(getSourceUrl());
        return toks;
    }

    function buildCommand() {
        return commandTokens().map((tk) => tk.v).join(' ');
    }

    // ---------------------------------------------------------------------
    // Size estimation (rough — yt-dlp can't be queried from the browser, so we
    // estimate from duration x a typical bitrate for the chosen settings).
    // ---------------------------------------------------------------------
    const TIERS = [360, 480, 720, 1080, 1440, 2160, 4320];
    // Typical bitrate (kbps) of the H.264 stream YouTube actually *delivers* at
    // ~30fps, per resolution tier. These are streaming bitrates, NOT upload /
    // encoding recommendations — the latter are several times higher and badly
    // overestimate the download size.
    const VIDEO_KBPS = {
        360: 300, 480: 600, 720: 1500, 1080: 3000,
        1440: 6000, 2160: 14000, 4320: 30000,
    };

    function parseTime(t) {
        if (t == null) return null;
        t = String(t).trim();
        if (t === '') return null;
        if (/^\d+(\.\d+)?$/.test(t)) return parseFloat(t); // plain seconds
        const parts = t.split(':').map(Number);
        if (parts.some((n) => isNaN(n))) return null;
        return parts.reduce((acc, n) => acc * 60 + n, 0);
    }

    function nearestTier(px) {
        return TIERS.reduce((best, tier) =>
            Math.abs(tier - px) < Math.abs(best - px) ? tier : best, TIERS[0]);
    }

    // Duration in seconds for the selection: the clip range if set, else the
    // full video duration read from the page's <video> element. null if unknown.
    function getDurationSeconds() {
        const v = document.querySelector('video');
        const full = v && isFinite(v.duration) && v.duration > 0 ? v.duration : null;
        const s = parseTime(state.clipStart);
        const e = parseTime(state.clipEnd);
        if (s != null || e != null) {
            const start = s != null ? s : 0;
            const end = e != null ? e : full;
            if (end == null) return null; // open-ended clip, full length unknown
            return Math.max(0, end - start);
        }
        return full;
    }

    function videoKbps() {
        let h = state.quality;
        if (h === 'best') {
            const v = document.querySelector('video');
            h = v && v.videoHeight ? nearestTier(v.videoHeight) : 1080;
        }
        let base = VIDEO_KBPS[h] || 8000;
        if (state.fps === '60') base *= 1.4;
        const codecMul = state.vcodec === 'vp9' ? 0.65 : state.vcodec === 'av01' ? 0.5 : 1.0;
        return base * codecMul;
    }

    function audioKbps() {
        if (state.mode === 'audio') {
            switch (state.audioFormat) {
                case 'wav': return 1411;
                case 'flac': return 900;
                case 'opus': return 160;
                case 'm4a': return 192;
                case 'mp3':
                default:
                    if (state.audioQuality === '0') return 245; // best VBR ≈ V0
                    return parseInt(state.audioQuality, 10) || 192; // "320K" -> 320
            }
        }
        return 128; // embedded audio track (AAC ~128k / Opus ~130k)
    }

    function estimateBytes() {
        const dur = getDurationSeconds();
        if (dur == null || !isFinite(dur) || dur <= 0) return null;
        const kbps = (state.mode === 'audio' ? 0 : videoKbps()) + audioKbps();
        return (kbps * 1000 / 8) * dur;
    }

    // Real bitrates vary a lot with content (motion, grain), so report a range
    // rather than a falsely precise figure. Audio-only at a fixed bitrate is
    // much more predictable, so it gets a tighter spread.
    function estimateRange() {
        const mid = estimateBytes();
        if (mid == null) return null;
        const spread = state.mode === 'audio' ? 0.12 : 0.35;
        return { lo: mid * (1 - spread), hi: mid * (1 + spread) };
    }

    function humanSize(bytes) {
        if (bytes >= 1e9) return (bytes / 1e9).toFixed(2) + ' GB';
        if (bytes >= 1e6) return Math.round(bytes / 1e6) + ' MB';
        return Math.max(1, Math.round(bytes / 1e3)) + ' KB';
    }

    // Render tokens as real DOM nodes (no innerHTML — some sites like YouTube
    // enforce Trusted Types, which blocks assigning HTML strings to a sink).
    function renderTokens(container, toks) {
        container.textContent = '';
        toks.forEach((tk, i) => {
            if (i > 0) container.appendChild(document.createTextNode(' '));
            const span = document.createElement('span');
            span.className = 'tok-' + tk.t;
            span.textContent = tk.v;
            container.appendChild(span);
        });
    }

    // ---------------------------------------------------------------------
    // Styles
    // ---------------------------------------------------------------------
    // Load the Inter font (falls back to system UI fonts if a site's CSP blocks it).
    try {
        const fl = document.createElement('link');
        fl.rel = 'stylesheet';
        fl.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap';
        (document.head || document.documentElement).appendChild(fl);
    } catch (e) { /* ignore */ }

    GM_addStyle(`
        #ytg-overlay {
            position: fixed; inset: 0; z-index: 2147483647;
            display: flex; align-items: center; justify-content: center;
            background: rgba(8,10,14,0);
            backdrop-filter: blur(0px);
            opacity: 0; visibility: hidden; pointer-events: none;
            transition: opacity .28s ease, background .28s ease,
                        backdrop-filter .28s ease, visibility 0s linear .28s;
        }
        #ytg-overlay.open {
            opacity: 1; visibility: visible; pointer-events: auto;
            background: rgba(8,10,14,.55);
            backdrop-filter: blur(4px);
            transition: opacity .28s ease, background .28s ease,
                        backdrop-filter .28s ease, visibility 0s linear 0s;
        }

        #ytg-panel {
            width: 380px; max-width: calc(100vw - 32px); max-height: 86vh;
            overflow: hidden; display: flex; flex-direction: column;
            background: #16181d; color: #e9edf2;
            border: 1px solid rgba(255,255,255,.06);
            border-radius: 16px;
            box-shadow: 0 24px 60px rgba(0,0,0,.55), 0 2px 8px rgba(0,0,0,.4);
            font: 13.5px/1.45 'Inter', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
            transform: translateY(14px) scale(.96); opacity: 0;
            transition: transform .3s cubic-bezier(.16,1,.3,1), opacity .24s ease;
            box-sizing: border-box;
        }
        #ytg-overlay.open #ytg-panel { transform: translateY(0) scale(1); opacity: 1; }

        #ytg-header {
            display: flex; align-items: center; gap: 10px;
            padding: 14px 16px; cursor: grab;
            background: linear-gradient(135deg, #cc0000 0%, #7a0f2b 100%);
            user-select: none;
        }
        #ytg-header:active { cursor: grabbing; }
        #ytg-header .ttl { font-weight: 650; font-size: 14.5px; flex: 1; letter-spacing: .2px; }
        #ytg-header .x {
            width: 26px; height: 26px; border-radius: 7px; border: none;
            background: rgba(255,255,255,.12); color: #fff; font-size: 17px;
            cursor: pointer; display: flex; align-items: center; justify-content: center;
            transition: background .15s ease, transform .15s ease;
        }
        #ytg-header .x:hover { background: rgba(255,255,255,.28); transform: rotate(90deg); }

        #ytg-body { padding: 14px 16px 4px; overflow-y: auto; }
        #ytg-body::-webkit-scrollbar { width: 9px; }
        #ytg-body::-webkit-scrollbar-thumb { background: #33373f; border-radius: 9px; border: 2px solid #16181d; }

        .ytg-sec {
            font-size: 10.5px; text-transform: uppercase; letter-spacing: 1.2px;
            color: #7c8798; font-weight: 700; margin: 14px 0 8px;
        }
        .ytg-sec:first-child { margin-top: 0; }

        .ytg-row { margin-bottom: 11px; }
        .ytg-row > label.lbl {
            display: block; margin-bottom: 5px; font-size: 12px;
            color: #aeb6c2; font-weight: 500;
        }
        .ytg-row select, .ytg-row input[type=text] {
            width: 100%; box-sizing: border-box;
            background: #1f2229; color: #e9edf2;
            border: 1px solid #2c313b; border-radius: 9px;
            padding: 9px 10px; font-size: 13px; outline: none;
            transition: border-color .15s ease, box-shadow .15s ease, background .15s ease;
        }
        .ytg-row select:hover, .ytg-row input[type=text]:hover { border-color: #3a4150; }
        .ytg-row select:focus, .ytg-row input[type=text]:focus {
            border-color: #e0384f; background: #22262e;
            box-shadow: 0 0 0 3px rgba(224,56,79,.18);
        }
        .ytg-inline { display: flex; gap: 8px; }
        .ytg-inline > * { flex: 1; min-width: 0; }

        .ytg-check {
            display: flex; align-items: center; gap: 10px; margin: 0 0 9px;
            padding: 8px 10px; border-radius: 9px; cursor: pointer;
            background: #1b1e24; border: 1px solid transparent;
            transition: background .15s ease, border-color .15s ease;
        }
        .ytg-check:hover { background: #20242c; border-color: #2c313b; }
        .ytg-check input { appearance: none; -webkit-appearance: none;
            width: 18px; height: 18px; flex: none; border-radius: 5px;
            border: 1.5px solid #3a4150; background: #12141a; cursor: pointer;
            position: relative; transition: background .15s ease, border-color .15s ease; }
        .ytg-check input:checked { background: #e0384f; border-color: #e0384f; }
        .ytg-check input:checked::after {
            content: ''; position: absolute; left: 5px; top: 1.5px;
            width: 4px; height: 9px; border: solid #fff; border-width: 0 2px 2px 0;
            transform: rotate(45deg);
        }
        .ytg-check label { cursor: pointer; flex: 1; }

        .ytg-hint { font-size: 11px; color: #7c8798; margin-top: 4px; line-height: 1.4; }

        .ytg-btn-time {
            background: #2a2e37; color: #dfe4ec; border: 1px solid #3a4150;
            border-radius: 8px; padding: 0 10px; cursor: pointer; font-size: 11.5px;
            white-space: nowrap; transition: background .15s ease, transform .1s ease;
        }
        .ytg-btn-time:hover { background: #353a45; }
        .ytg-btn-time:active { transform: scale(.94); }

        #ytg-footer {
            padding: 12px 16px 14px; border-top: 1px solid rgba(255,255,255,.06);
            background: #14161b;
        }
        #ytg-size-row {
            display: flex; align-items: center; justify-content: space-between;
            margin: 0 0 9px; gap: 8px;
        }
        #ytg-size {
            font-size: 14px; font-weight: 700; color: #8be48b;
            letter-spacing: .2px;
        }
        #ytg-size.unknown { font-size: 12px; font-weight: 500; color: #7c8798; }
        .ytg-size-note {
            font-size: 10px; text-transform: uppercase; letter-spacing: .8px;
            color: #7c8798; background: #1b1e24; border: 1px solid #2c313b;
            padding: 3px 7px; border-radius: 20px; white-space: nowrap;
        }
        #ytg-preview {
            width: 100%; box-sizing: border-box; min-height: 84px; max-height: 200px;
            overflow: auto; background: #0e1014; color: #d4d8df;
            border: 1px solid #262b33; border-radius: 10px;
            padding: 10px; margin: 0;
            font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
            font-size: 12px; line-height: 1.55; white-space: pre-wrap; word-break: break-all;
            outline: none; transition: border-color .15s ease;
        }
        /* command syntax highlighting */
        #ytg-preview .tok-prog { color: #ff6b81; font-weight: 700; }
        #ytg-preview .tok-flag { color: #6cb6ff; }
        #ytg-preview .tok-str  { color: #e5b567; }
        #ytg-preview .tok-url  { color: #8be48b; text-decoration: underline; text-decoration-color: rgba(139,228,139,.4); }
        #ytg-preview .tok-val  { color: #c8a6ff; }
        #ytg-copy {
            width: 100%; margin-top: 10px; padding: 11px; border: none;
            border-radius: 10px; background: #cc0000; color: #fff; font-size: 14px;
            font-weight: 650; cursor: pointer; letter-spacing: .3px;
            transition: background .18s ease, transform .1s ease, box-shadow .18s ease;
            box-shadow: 0 6px 16px rgba(204,0,0,.28);
        }
        #ytg-copy:hover { background: #e00b0b; box-shadow: 0 8px 22px rgba(224,11,11,.36); }
        #ytg-copy:active { transform: translateY(1px) scale(.99); }
        #ytg-copy.done { background: #1f9d4f; box-shadow: 0 6px 16px rgba(31,157,79,.3); }

        .ytg-divider { border-top: 1px solid rgba(255,255,255,.06); margin: 4px 0 0; }
        .ytg-disabled { opacity: .38; pointer-events: none; filter: saturate(.4); }

        @keyframes ytg-fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
    `);

    // ---------------------------------------------------------------------
    // DOM helpers
    // ---------------------------------------------------------------------
    function el(tag, attrs, children) {
        const node = document.createElement(tag);
        if (attrs) {
            for (const k in attrs) {
                if (k === 'text') node.textContent = attrs[k];
                else node.setAttribute(k, attrs[k]);
            }
        }
        (children || []).forEach((c) => node.appendChild(c));
        return node;
    }

    function selectRow(label, options, value, onChange) {
        const sel = el('select');
        options.forEach(([val, txt]) => {
            const o = el('option', { value: val, text: txt });
            if (val === value) o.selected = true;
            sel.appendChild(o);
        });
        sel.addEventListener('change', () => onChange(sel.value));
        const row = el('div', { class: 'ytg-row' }, [el('label', { class: 'lbl', text: label }), sel]);
        return { row, sel };
    }

    function textRow(label, value, placeholder, onChange) {
        const inp = el('input', { type: 'text', value: value || '', placeholder: placeholder || '' });
        inp.addEventListener('input', () => onChange(inp.value));
        const row = el('div', { class: 'ytg-row' }, [el('label', { class: 'lbl', text: label }), inp]);
        return { row, inp };
    }

    function checkRow(label, checked, onChange) {
        const box = el('input', { type: 'checkbox' });
        box.checked = !!checked;
        box.addEventListener('change', () => onChange(box.checked));
        const wrap = el('label', { class: 'ytg-check' }, [box, el('span', { text: label })]);
        return { row: wrap, box };
    }

    function sectionTitle(text) { return el('div', { class: 'ytg-sec', text }); }

    // ---------------------------------------------------------------------
    // Panel (built lazily on first open)
    // ---------------------------------------------------------------------
    let overlay = null;
    let preview = null;      // highlighted <pre>
    let sizeEl = null;       // estimated size line
    let plainCommand = '';   // raw string for the clipboard
    const videoOnlyRows = [];
    const audioOnlyRows = [];

    function refresh() {
        saveState(state);
        const toks = commandTokens();
        plainCommand = toks.map((tk) => tk.v).join(' ');
        if (preview) renderTokens(preview, toks);
        if (sizeEl) {
            const r = estimateRange();
            sizeEl.textContent = r != null
                ? `≈ ${humanSize(r.lo)} – ${humanSize(r.hi)}`
                : 'Size estimate unavailable (video duration not detected)';
            sizeEl.classList.toggle('unknown', r == null);

            // These options change the final size in ways we can't predict.
            const caveats = [];
            if (state.sponsorblock !== 'off') caveats.push('SponsorBlock cuts segments out');
            if (state.mp4Recode) caveats.push('re-encoding changes the size');
            sizeEl.title = caveats.length
                ? 'Rough estimate from duration x typical bitrate. Will likely be smaller: ' + caveats.join(', ') + '.'
                : 'Rough estimate from duration x typical bitrate. Actual size varies with the video content.';
        }
        videoOnlyRows.forEach((r) => r.classList.toggle('ytg-disabled', state.mode !== 'video'));
        audioOnlyRows.forEach((r) => r.classList.toggle('ytg-disabled', state.mode !== 'audio'));
    }

    function buildOverlay() {
        const body = el('div', { id: 'ytg-body' });

        // ---- Format section ----
        body.appendChild(sectionTitle('Format'));

        const mode = selectRow('Mode', [
            ['video', 'Video + Audio'],
            ['audio', 'Audio only (extract)'],
        ], state.mode, (v) => { state.mode = v; refresh(); });
        body.appendChild(mode.row);

        const q = selectRow('Video quality (max height)', [
            ['best', 'Best available'],
            ['4320', '4320p (8K)'],
            ['2160', '2160p (4K)'],
            ['1440', '1440p (2K)'],
            ['1080', '1080p'],
            ['720', '720p'],
            ['480', '480p'],
            ['360', '360p'],
        ], state.quality, (v) => { state.quality = v; refresh(); });
        body.appendChild(q.row); videoOnlyRows.push(q.row);

        const fps = selectRow('Framerate', [
            ['any', 'Any'],
            ['30', '≤ 30 fps'],
            ['60', '≤ 60 fps'],
        ], state.fps, (v) => { state.fps = v; refresh(); });
        body.appendChild(fps.row); videoOnlyRows.push(fps.row);

        const vcodec = selectRow('Video codec', [
            ['avc1', 'H.264 — best for Facebook/WhatsApp & most apps'],
            ['vp9', 'VP9 — smaller files (YouTube)'],
            ['av01', 'AV1 — smallest, newest devices only'],
            ['any', 'Any'],
        ], state.vcodec, (v) => { state.vcodec = v; refresh(); });
        body.appendChild(vcodec.row); videoOnlyRows.push(vcodec.row);
        vcodec.row.appendChild(el('div', { class: 'ytg-hint',
            text: 'H.264 (+AAC, MP4) plays everywhere — Facebook, WhatsApp, phones. VP9/AV1 may need re-encoding for those.' }));

        const acodec = selectRow('Audio codec', [
            ['mp4a', 'AAC (m4a) — most compatible'],
            ['opus', 'Opus — better quality/size'],
            ['any', 'Any'],
        ], state.acodec, (v) => { state.acodec = v; refresh(); });
        body.appendChild(acodec.row); videoOnlyRows.push(acodec.row);

        const recode = checkRow('Force re-encode to MP4 (guaranteed compatibility, slow)',
            state.mp4Recode, (v) => { state.mp4Recode = v; refresh(); });
        body.appendChild(recode.row); videoOnlyRows.push(recode.row);

        const afmt = selectRow('Audio format', [
            ['mp3', 'MP3'], ['m4a', 'M4A (AAC)'], ['opus', 'Opus'],
            ['wav', 'WAV (lossless)'], ['flac', 'FLAC (lossless)'],
        ], state.audioFormat, (v) => { state.audioFormat = v; refresh(); });
        body.appendChild(afmt.row); audioOnlyRows.push(afmt.row);

        const aq = selectRow('Audio quality', [
            ['0', 'Best (VBR)'], ['320K', '320 kbps'], ['256K', '256 kbps'],
            ['192K', '192 kbps'], ['128K', '128 kbps'],
        ], state.audioQuality, (v) => { state.audioQuality = v; refresh(); });
        body.appendChild(aq.row); audioOnlyRows.push(aq.row);

        // ---- Clip section ----
        body.appendChild(sectionTitle('Clip a segment'));

        const hasVideoEl = !!document.querySelector('video');
        const startInp = el('input', { type: 'text', value: state.clipStart, placeholder: 'HH:MM:SS' });
        startInp.addEventListener('input', () => { state.clipStart = startInp.value; refresh(); });
        const endInp = el('input', { type: 'text', value: state.clipEnd, placeholder: 'HH:MM:SS' });
        endInp.addEventListener('input', () => { state.clipEnd = endInp.value; refresh(); });

        function currentTimeStr() {
            const v = document.querySelector('video');
            if (!v || isNaN(v.currentTime)) return '';
            const total = Math.floor(v.currentTime);
            const hh = String(Math.floor(total / 3600)).padStart(2, '0');
            const mm = String(Math.floor((total % 3600) / 60)).padStart(2, '0');
            const ss = String(total % 60).padStart(2, '0');
            return `${hh}:${mm}:${ss}`;
        }

        const startGroup = el('div', {}, [el('label', { class: 'lbl', text: 'Start' }),
            el('div', { class: 'ytg-inline', style: 'gap:6px' }, [startInp])]);
        const endGroup = el('div', {}, [el('label', { class: 'lbl', text: 'End' }),
            el('div', { class: 'ytg-inline', style: 'gap:6px' }, [endInp])]);
        if (hasVideoEl) {
            const sBtn = el('button', { class: 'ytg-btn-time', text: '⏱' });
            sBtn.title = 'Use current player time';
            sBtn.addEventListener('click', () => { startInp.value = currentTimeStr(); state.clipStart = startInp.value; refresh(); });
            const eBtn = el('button', { class: 'ytg-btn-time', text: '⏱' });
            eBtn.title = 'Use current player time';
            eBtn.addEventListener('click', () => { endInp.value = currentTimeStr(); state.clipEnd = endInp.value; refresh(); });
            startGroup.querySelector('.ytg-inline').appendChild(sBtn);
            endGroup.querySelector('.ytg-inline').appendChild(eBtn);
        }
        body.appendChild(el('div', { class: 'ytg-row' }, [
            el('div', { class: 'ytg-inline' }, [startGroup, endGroup]),
            el('div', { class: 'ytg-hint', text: 'Leave blank to download the full video.' }),
        ]));

        // ---- Subtitles section ----
        body.appendChild(sectionTitle('Subtitles'));
        const subs = selectRow('Subtitles', [
            ['off', 'Off'], ['embed', 'Embed into file'], ['file', 'Save as separate file'],
        ], state.subs, (v) => { state.subs = v; refresh(); });
        body.appendChild(subs.row);
        const subLang = textRow('Language(s)', state.subLang, 'en.*', (v) => { state.subLang = v; refresh(); });
        body.appendChild(subLang.row);
        const subAuto = checkRow('Include auto-generated subtitles', state.subAuto, (v) => { state.subAuto = v; refresh(); });
        body.appendChild(subAuto.row);

        // ---- Extras section ----
        body.appendChild(sectionTitle('Extras'));
        const thumb = checkRow('Embed thumbnail + metadata', state.embedThumb, (v) => { state.embedThumb = v; refresh(); });
        body.appendChild(thumb.row);
        const chapters = checkRow('Embed chapters', state.embedChapters, (v) => { state.embedChapters = v; refresh(); });
        body.appendChild(chapters.row);

        const sb = selectRow('SponsorBlock (auto-cut segments)', [
            ['off', 'Off'], ['sponsor', 'Remove sponsor segments'],
            ['all', 'Remove all (sponsor, intro, self-promo…)'],
        ], state.sponsorblock, (v) => { state.sponsorblock = v; refresh(); });
        body.appendChild(sb.row);

        const cookies = selectRow('Cookies from browser (login-gated content)', [
            ['none', 'None'], ['chrome', 'Chrome'], ['firefox', 'Firefox'], ['edge', 'Edge'],
            ['brave', 'Brave'], ['chromium', 'Chromium'], ['opera', 'Opera'],
            ['vivaldi', 'Vivaldi'], ['safari', 'Safari'],
        ], state.cookies, (v) => { state.cookies = v; refresh(); });
        body.appendChild(cookies.row);

        const noPl = checkRow('Single video only (--no-playlist)', state.noPlaylist, (v) => { state.noPlaylist = v; refresh(); });
        body.appendChild(noPl.row);
        const fast = checkRow('Faster download (4 parallel fragments)', state.fastDownload, (v) => { state.fastDownload = v; refresh(); });
        body.appendChild(fast.row);

        const out = textRow('Output filename template', state.output, DEFAULTS.output, (v) => { state.output = v; refresh(); });
        body.appendChild(out.row);

        // ---- Header ----
        const closeBtn = el('button', { class: 'x', text: '×', title: 'Close (Esc)' });
        const header = el('div', { id: 'ytg-header' }, [
            el('div', { class: 'ttl', text: 'yt-dlp Command Generator' }),
            closeBtn,
        ]);

        // ---- Footer (preview + copy) ----
        preview = el('pre', { id: 'ytg-preview' });
        sizeEl = el('div', { id: 'ytg-size' });
        const copyBtn = el('button', { id: 'ytg-copy', text: 'Copy command' });
        const footer = el('div', { id: 'ytg-footer' }, [
            el('div', { id: 'ytg-size-row' }, [
                sizeEl,
                el('span', { class: 'ytg-size-note', text: 'rough estimate' }),
            ]),
            preview,
            copyBtn,
            el('div', { class: 'ytg-hint', text: 'Run in your terminal. Requires yt-dlp + ffmpeg installed locally.' }),
        ]);

        const panel = el('div', { id: 'ytg-panel' }, [header, body, footer]);
        const ov = el('div', { id: 'ytg-overlay' }, [panel]);

        // ---- Interactions ----
        closeBtn.addEventListener('click', closePanel);
        ov.addEventListener('mousedown', (e) => { if (e.target === ov) closePanel(); });
        copyBtn.addEventListener('click', () => {
            GM_setClipboard(plainCommand, 'text');
            copyBtn.textContent = '✓ Copied to clipboard';
            copyBtn.classList.add('done');
            setTimeout(() => { copyBtn.textContent = 'Copy command'; copyBtn.classList.remove('done'); }, 1500);
        });

        makeDraggable(panel, header);

        return ov;
    }

    // Lightweight drag by the header.
    function makeDraggable(panel, handle) {
        let ox = 0, oy = 0, dx = 0, dy = 0, dragging = false;
        handle.addEventListener('mousedown', (e) => {
            if (e.target.closest('.x')) return;
            dragging = true;
            const r = panel.getBoundingClientRect();
            ox = e.clientX - r.left; oy = e.clientY - r.top;
            panel.style.position = 'fixed';
            panel.style.margin = '0';
            e.preventDefault();
        });
        window.addEventListener('mousemove', (e) => {
            if (!dragging) return;
            dx = e.clientX - ox; dy = e.clientY - oy;
            panel.style.left = Math.max(8, Math.min(dx, window.innerWidth - panel.offsetWidth - 8)) + 'px';
            panel.style.top = Math.max(8, Math.min(dy, window.innerHeight - 40)) + 'px';
        });
        window.addEventListener('mouseup', () => { dragging = false; });
    }

    // ---------------------------------------------------------------------
    // Open / close
    // ---------------------------------------------------------------------
    function openPanel() {
        if (!overlay) {
            overlay = buildOverlay();
            document.body.appendChild(overlay);
        }
        refresh();
        // Force reflow so the transition runs on first open.
        void overlay.offsetWidth;
        overlay.classList.add('open');
        document.addEventListener('keydown', onEsc, true);
    }

    function closePanel() {
        if (!overlay) return;
        overlay.classList.remove('open');
        document.removeEventListener('keydown', onEsc, true);
    }

    function onEsc(e) {
        if (e.key === 'Escape') { e.stopPropagation(); closePanel(); }
    }

    // ---------------------------------------------------------------------
    // Register Tampermonkey menu command
    // ---------------------------------------------------------------------
    GM_registerMenuCommand('⬇  Open Command Generator', openPanel);

    // Refresh the source URL when a SPA navigates (e.g. YouTube).
    let lastUrl = location.href;
    window.addEventListener('yt-navigate-finish', () => { if (overlay) refresh(); });
    setInterval(() => {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            if (overlay) refresh();
        }
    }, 1000);
})();