(() => { 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 = ''; const gear = ''; 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 = `
`; 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(); })();