Files
DepressedCat 5f2aad00f4
Check extension and helper / check (push) Canceled after 0s
Publish YouTubeDL 1.4.5 and website on Gitea
2026-10-10 23:53:17 +03:00

262 lines
19 KiB
JavaScript

(() => {
const controls = new Set();
const previews = new WeakMap();
const previewLayouts = new Set();
let layoutScheduled = false;
const previewResize = new ResizeObserver(schedulePreviewLayout);
let watchButton = null, shortsButton = null, shortsRenderer = null;
let menu = null, menuAnchor = null, menuSize = { width: 270, height: 142 };
const icon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/></svg>';
const gear = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 3-.6 2.4-2.1 1.2L4 6l-2 3.5 1.8 1.7v2.4L2 15.3 4 19l2.3-.6 2.1 1.2L9 22h4l.6-2.4 2.1-1.2 2.3.6 2-3.7-1.8-1.7v-2.4L20 9.5 18 6l-2.3.6-2.1-1.2L13 3Z" transform="translate(1 -.5) scale(.95)"/><circle cx="12" cy="12" r="3"/></svg>';
const themeStyle = ':host{font-family:Roboto,"Segoe UI",sans-serif;--bg:#fff;--fg:#0f0f0f;--muted:#606060;--button:#0000000d;--hover:#0000001a;--pressed:#00000026;--line:#0000001a}:host([dark]){--bg:#212121;--fg:#f1f1f1;--muted:#aaa;--button:#ffffff1a;--hover:#ffffff33;--pressed:#ffffff40;--line:#ffffff1a}*{box-sizing:border-box}button{font:inherit;cursor:pointer}';
const dark = () => document.documentElement.hasAttribute('dark') || document.documentElement.dataset.theme === 'dark';
function canonical(value) {
try {
const parsed = new URL(value, location.href);
if (parsed.protocol !== 'https:' || !['www.youtube.com', 'm.youtube.com', 'youtube.com', 'youtu.be'].includes(parsed.hostname)) return null;
const match = parsed.pathname.match(/^\/(?:shorts|live)\/([\w-]{11})(?:\/|$)/);
const id = parsed.hostname === 'youtu.be' ? parsed.pathname.slice(1) : parsed.pathname === '/watch' ? parsed.searchParams.get('v') : match?.[1];
return /^[\w-]{11}$/.test(id || '') ? `https://www.youtube.com/watch?v=${id}` : null;
} catch { return null; }
}
function theme() {
for (const control of controls) {
if (!control.isConnected) { controls.delete(control); continue; }
control.toggleAttribute('dark', control.dataset.kind === 'thumbnail' || dark());
}
menu?.toggleAttribute('dark', dark());
menu?.querySelector('iframe')?.contentWindow?.postMessage({ type: 'YTDL_THEME', theme: dark() ? 'dark' : 'light' }, chrome.runtime.getURL('').slice(0, -1));
}
function closeMenu() {
if (!menu) return;
const closing = menu; menu = null; menuAnchor = null;
closing.id = ''; closing.style.animation = 'none'; closing.classList.add('ytdl-closing');
setTimeout(() => closing.remove(), 150);
}
function positionMenu() {
if (!menu || !menuAnchor) return;
if (!menuAnchor.isConnected) { closeMenu(); return; }
const rect = menuAnchor.getBoundingClientRect();
const width = Math.min(menuSize.width, innerWidth - 24);
const height = Math.min(menuSize.height, innerHeight - 32);
const left = Math.min(Math.max(12, rect.left), innerWidth - width - 12);
const top = rect.bottom + height + 16 < innerHeight ? rect.bottom + 8
: Math.max(12, Math.min(rect.top - height - 8, innerHeight - height - 16));
Object.assign(menu.style, { width: `${width}px`, height: `${height}px`, left: `${left}px`, top: `${top}px` });
}
function newMenu(trigger, size) {
closeMenu(); menuAnchor = trigger; menuSize = size;
menu = document.createElement('div'); menu.id = 'ytdl-menu';
menu.style.cssText = 'position:fixed;z-index:2147483646;border-radius:14px;overflow:hidden;box-shadow:0 12px 40px #0005,0 0 0 1px #80808033;animation:ytdl-menu-in .16s ease-out both';
document.documentElement.append(menu); theme(); positionMenu();
return menu;
}
async function openSetup(trigger, url, notice = '') {
let context;
try { context = await chrome.runtime.sendMessage({ type: 'GET_CONTEXT' }); } catch { return; }
if (!trigger.isConnected) return;
const panel = newMenu(trigger, { width: 460, height: 780 });
const frame = document.createElement('iframe');
const params = new URLSearchParams({ url, tabId: String(context?.tabId || ''), embedded: '1', theme: dark() ? 'dark' : 'light', notice });
frame.src = chrome.runtime.getURL(`downloader.html?${params}`);
frame.title = 'Настроить скачивание'; frame.style.cssText = 'border:0;width:100%;height:100%;display:block';
panel.append(frame);
}
async function download(trigger, url) {
if (!url) return;
closeMenu();
try {
const response = await chrome.runtime.sendMessage({ type: 'QUICK_DOWNLOAD', url });
if (response?.needSetup) openSetup(trigger, url, response.error || 'Выберите папку и сохраните настройки.');
} catch (error) { openSetup(trigger, url, error.message); }
}
function makeControl(kind, resolveUrl) {
const host = document.createElement('span'); host.dataset.ytdlControl = 'true'; host.dataset.kind = kind;
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = `<style>${themeStyle}.pair{display:flex;align-items:center;gap:6px}button{display:flex;align-items:center;justify-content:center;gap:6px;height:36px;padding:0 16px;border:0;border-radius:999px;background:var(--button);color:var(--fg);font-size:14px;font-weight:500;white-space:nowrap}button:hover{background:var(--hover)}button:active{background:var(--pressed)}button:focus-visible{outline:2px solid var(--fg);outline-offset:2px}.glyph{display:grid;place-items:center}svg{width:20px;height:20px}.settings{width:36px;padding:0}:host([data-kind=thumbnail]) button{width:34px;height:34px;padding:0;background:#0f0f0fbb;color:#fff;box-shadow:0 1px 5px #0003}:host([data-kind=thumbnail]) button:hover{background:#303030}:host([data-kind=thumbnail]) .label{display:none}:host([data-kind=shorts]) .pair{flex-direction:column;gap:9px}:host([data-kind=shorts]) .download{background:transparent;height:auto;padding:0;flex-direction:column;gap:6px;color:var(--fg)}:host([data-kind=shorts]) .glyph{width:48px;height:48px;border-radius:50%;background:var(--button)}:host([data-kind=shorts]) .download:hover .glyph{background:var(--hover)}:host([data-kind=shorts]) .download:active .glyph{background:var(--pressed)}:host([data-kind=shorts]) svg{width:24px;height:24px}:host([data-kind=shorts]) .settings svg{width:19px;height:19px}:host([data-kind=shorts]) .label{font-size:12px}</style><div class="pair"><button class="download" type="button" title="Скачать по сохранённым настройкам" aria-label="Скачать по сохранённым настройкам"><span class="glyph">${icon}</span><span class="label">Скачать</span></button><button class="settings" type="button" title="Настройки скачивания" aria-label="Настройки скачивания">${gear}</button></div>`;
for (const button of shadow.querySelectorAll('button')) {
button.addEventListener('pointerdown', event => { event.stopPropagation(); });
button.addEventListener('keydown', event => { event.stopPropagation(); });
button.addEventListener('click', event => {
event.preventDefault(); event.stopPropagation();
const url = resolveUrl(); if (!url) return;
if (button.classList.contains('download')) download(host, url);
else if (menuAnchor === host) closeMenu(); else openSetup(host, url);
});
}
controls.add(host); return host;
}
function attachWatch() {
if (!canonical(location.href) || location.pathname.startsWith('/shorts/')) { watchButton?.remove(); watchButton = null; return; }
if (watchButton?.isConnected) return;
const subscribe = document.querySelector('ytd-watch-metadata #subscribe-button, ytd-video-owner-renderer #subscribe-button, ytm-slim-video-metadata-renderer ytm-subscribe-button-renderer');
if (!subscribe) return;
watchButton = makeControl('watch', () => canonical(location.href)); watchButton.id = 'ytdl-subscribe-download';
watchButton.style.cssText = 'display:inline-flex;align-items:center;flex-shrink:0;margin-inline-start:8px;vertical-align:middle';
subscribe.after(watchButton);
}
function styleValue(node, key, value) { if (node.style[key] !== value) node.style[key] = value; }
function usableRect(element) {
if (!element?.isConnected || getComputedStyle(element).visibility === 'hidden') return null;
const rect = element.getBoundingClientRect();
return rect.width >= 90 && rect.height >= 50 ? rect : null;
}
function picture(link, root) {
// A title link is a video URL too, but must never become a positioning anchor.
const media = [...link.querySelectorAll('img, video')].find(element => usableRect(element));
const frame = link.closest('ytd-thumbnail, yt-thumbnail-view-model') || link.querySelector('yt-thumbnail-view-model, yt-image');
const element = media || (usableRect(frame) ? frame : null);
if (!element) return null;
let rect = usableRect(element);
const crop = usableRect(frame);
if (crop && rect) {
const left = Math.max(rect.left, crop.left), top = Math.max(rect.top, crop.top);
const right = Math.min(rect.right, crop.right), bottom = Math.min(rect.bottom, crop.bottom);
rect = { left, top, right, bottom, width: right - left, height: bottom - top };
}
return rect?.width >= 90 && rect.height >= 50 && root.contains(element) ? { element, rect } : null;
}
function observePicture(preview, element) {
if (preview.element === element) return;
if (preview.element && preview.element !== preview.root) previewResize.unobserve(preview.element);
preview.element = element; previewResize.observe(element);
}
function visibleOverlay(root, left, top) {
let clip = { left: 0, top: 0, right: innerWidth, bottom: innerHeight };
// YouTube's absolutely positioned ytd-app leaves BODY with height 0.
// BODY/HTML overflow applies to the viewport, already handled above.
for (let element = root; element && element !== document.body && element !== document.documentElement; element = element.parentElement) {
const css = getComputedStyle(element);
if (css.display === 'contents' || css.display === 'inline') continue;
const x = /hidden|clip|auto|scroll/.test(css.overflowX), y = /hidden|clip|auto|scroll/.test(css.overflowY);
if (!x && !y) continue;
const rect = element.getBoundingClientRect();
if (x) { clip.left = Math.max(clip.left, rect.left); clip.right = Math.min(clip.right, rect.right); }
if (y) { clip.top = Math.max(clip.top, rect.top); clip.bottom = Math.min(clip.bottom, rect.bottom); }
}
return left >= clip.left && left + 74 <= clip.right && top >= clip.top && top + 34 <= clip.bottom;
}
function positionPreview(preview) {
const { root, host, floating } = preview;
if (!root.isConnected) {
previewResize.unobserve(root);
if (preview.element) previewResize.unobserve(preview.element);
previewLayouts.delete(preview); previews.delete(root); controls.delete(host); host.remove(); return;
}
let measured = preview.link?.isConnected && picture(preview.link, root);
if (!measured && floating) {
const link = [...root.querySelectorAll('a[href]')].find(candidate => canonical(candidate.href) && picture(candidate, root));
if (link) { preview.link = link; measured = picture(link, root); }
}
const base = root.getBoundingClientRect();
if (measured) {
observePicture(preview, measured.element);
const scale = root.offsetWidth ? base.width / root.offsetWidth : 1;
preview.box = { left: (measured.rect.left - base.left) / scale, top: (measured.rect.top - base.top) / scale,
width: measured.rect.width / scale, height: measured.rect.height / scale, rootWidth: root.offsetWidth };
}
// A Shorts hover player may remove or hide the original picture. Its buttons
// live outside that subtree and retain the last measured image rectangle.
let rect = measured?.rect;
if (!rect && floating && preview.box && base.width > 0) {
const factor = base.width / preview.box.rootWidth;
rect = { left: base.left + preview.box.left * factor, top: base.top + preview.box.top * factor,
width: preview.box.width * factor, height: preview.box.height * factor };
rect.right = rect.left + rect.width; rect.bottom = rect.top + rect.height;
}
if (!rect || rect.width < 90 || rect.height < 50) { styleValue(host, 'display', 'none'); return; }
if (floating) {
const top = rect.bottom - 42, left = rect.left + 8;
const visible = visibleOverlay(root, left, top);
styleValue(host, 'display', visible ? 'inline-flex' : 'none');
styleValue(host, 'left', `${left}px`); styleValue(host, 'top', `${top}px`);
} else {
styleValue(host, 'display', 'inline-flex');
const parent = host.offsetParent;
if (!parent) return;
const origin = parent.getBoundingClientRect();
const sx = parent.offsetWidth ? origin.width / parent.offsetWidth : 1;
const sy = parent.offsetHeight ? origin.height / parent.offsetHeight : sx;
styleValue(host, 'left', `${(rect.left - origin.left) / sx - parent.clientLeft + parent.scrollLeft + 8}px`);
styleValue(host, 'top', `${(rect.bottom - origin.top) / sy - parent.clientTop + parent.scrollTop - 42}px`);
}
}
function schedulePreviewLayout() {
if (layoutScheduled) return;
layoutScheduled = true;
requestAnimationFrame(() => { layoutScheduled = false; for (const preview of previewLayouts) positionPreview(preview); positionMenu(); });
}
let previewSequence = 0;
function attachThumbnails() {
const links = document.querySelectorAll('a#thumbnail[href], ytd-thumbnail a[href], yt-thumbnail-view-model a[href], a.yt-lockup-view-model__content-image[href], a.ytLockupViewModelContentImage[href], yt-shorts-lockup-view-model a[href], ytm-shorts-lockup-view-model a[href], ytd-reel-item-renderer a[href]');
for (const link of links) {
const url = canonical(link.href);
if (!url) continue;
const card = link.closest('ytm-shorts-lockup-view-model, yt-shorts-lockup-view-model, ytd-reel-item-renderer');
const root = card || link.closest('ytd-thumbnail, yt-thumbnail-view-model') || link.querySelector('yt-thumbnail-view-model') || link;
const measured = picture(link, root);
// Ignore metadata links even when a multiline title has thumbnail-like dimensions.
if (!measured) continue;
let preview = previews.get(root);
if (!preview?.host.isConnected) {
const host = makeControl('thumbnail', () => {
const current = previews.get(root);
const live = current?.link?.isConnected ? canonical(current.link.href) : null;
return live || [...root.querySelectorAll('a[href]')].map(anchor => canonical(anchor.href)).find(Boolean) || current?.url;
});
host.className = 'ytdl-thumbnail-download';
const key = `ytdl-preview-${++previewSequence}`; root.dataset.ytdlPreview = key; host.dataset.ytdlPreviewFor = key;
host.style.cssText = `position:${card ? 'fixed' : 'absolute'};left:0;top:0;width:74px;height:34px;margin:0;padding:0;line-height:1;z-index:2147483644;display:inline-flex`;
if (card) document.documentElement.append(host);
else {
if (getComputedStyle(root).position === 'static') root.style.position = 'relative';
if (getComputedStyle(root).display === 'inline') root.style.display = 'block';
root.append(host);
}
preview = { root, host, floating: Boolean(card), url, link };
previews.set(root, preview); previewLayouts.add(preview); previewResize.observe(root);
}
preview.url = url; preview.link = link; observePicture(preview, measured.element); positionPreview(preview);
}
schedulePreviewLayout();
}
function attachShorts() {
if (!location.pathname.startsWith('/shorts/') || !canonical(location.href)) {
shortsButton?.remove(); shortsButton = null; shortsRenderer = null; return;
}
const renderer = document.querySelector('ytd-reel-video-renderer[is-active], ytd-reel-video-renderer[active], ytd-shorts ytd-reel-video-renderer, ytm-reel-video-renderer');
const rail = renderer?.querySelector('reel-action-bar-view-model, #actions, #actions-container, .action-container, .reel-player-overlay-actions');
if (shortsButton?.isConnected && renderer === shortsRenderer && (!rail || shortsButton.parentElement === rail)) return;
shortsButton?.remove(); shortsRenderer = renderer;
shortsButton = makeControl('shorts', () => canonical(location.href)); shortsButton.id = 'ytdl-shorts-download';
if (rail) {
shortsButton.style.cssText = 'display:flex;justify-content:center;margin-top:16px;flex-shrink:0';
rail.append(shortsButton);
} else {
shortsButton.style.cssText = 'position:fixed;right:16px;bottom:180px;z-index:2147483645;display:inline-flex';
document.documentElement.append(shortsButton);
}
}
function attach() { attachWatch(); attachThumbnails(); attachShorts(); theme(); }
chrome.runtime.onMessage.addListener(message => { if (message.type === 'CLOSE_MENU') closeMenu(); });
document.addEventListener('pointerdown', event => {
if (menu && !event.composedPath().includes(menu) && !event.composedPath().includes(menuAnchor)) closeMenu();
}, true);
document.addEventListener('keydown', event => { if (event.key === 'Escape') closeMenu(); });
window.addEventListener('resize', () => { positionMenu(); schedulePreviewLayout(); });
window.addEventListener('scroll', () => { positionMenu(); schedulePreviewLayout(); }, { passive: true, capture: true });
document.addEventListener('yt-navigate-finish', () => { closeMenu(); attach(); });
window.addEventListener('popstate', () => { closeMenu(); attach(); });
let scheduled = false;
new MutationObserver(records => {
const relevant = records.filter(record => !record.target.closest?.('[data-ytdl-control], #ytdl-menu, #ytdl-notifications'));
if (!relevant.length) return;
schedulePreviewLayout();
if (!relevant.some(record => record.type === 'childList' || ['dark', 'is-active', 'href'].includes(record.attributeName))) return;
if (!scheduled) { scheduled = true; setTimeout(() => { scheduled = false; attach(); }, 200); }
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['dark', 'is-active', 'href', 'class', 'style', 'hidden'] });
const style = document.createElement('style');
style.textContent = '@keyframes ytdl-menu-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.ytdl-closing{opacity:0;transform:translateY(-4px);transition:opacity .15s,transform .15s}@media(prefers-reduced-motion:reduce){#ytdl-menu{animation:none!important;transition:none!important}}';
document.documentElement.append(style); attach();
})();