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