265 lines
19 KiB
JavaScript
265 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 };
|
|
// Above hover playback, below YouTube's masthead (2020) and popups (2202).
|
|
// Shorts controls live at the document root to survive preview replacement.
|
|
const pageControlLayer = 2000;
|
|
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=\"M8.939 4.609L10.2 4.205L10.264 2.152L13.736 2.152L13.8 4.205L15.061 4.609L16.239 5.216L17.736 3.808L20.192 6.264L18.784 7.761L19.391 8.939L19.795 10.2L21.848 10.264L21.848 13.736L19.795 13.8L19.391 15.061L18.784 16.239L20.192 17.736L17.736 20.192L16.239 18.784L15.061 19.391L13.8 19.795L13.736 21.848L10.264 21.848L10.2 19.795L8.939 19.391L7.761 18.784L6.264 20.192L3.808 17.736L5.216 16.239L4.609 15.061L4.205 13.8L2.152 13.736L2.152 10.264L4.205 10.2L4.609 8.939L5.216 7.761L3.808 6.264L6.264 3.808L7.761 5.216L8.939 4.609Z\"/><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{display:block;flex-shrink:0;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:${pageControlLayer};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:${pageControlLayer};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();
|
|
})();
|