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.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Advertisement:

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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);
})();