Youtube Video Downloader 2025

Enhanced downloader UI: format popup, active downloads dropdown, persistent progress.

目前為 2025-04-22 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Youtube Video Downloader 2025
// @namespace    http://tampermonkey.net/
// @author       fb
// @version      1.3.0
// @description  Enhanced downloader UI: format popup, active downloads dropdown, persistent progress.
// @match        https://www.youtube.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @connect      p.oceansaver.in
// @license      GPL-3.0-or-later
// @run-at       document-end
// ==/UserScript==

(function() {
    'use strict';

    const STORAGE_FORMAT = 'selectedFormat';
    const STORAGE_DOWNLOADS = 'ytDownloads';
    const UI_WRAPPER_ID = 'yt-downloader-wrapper';
    const FORMAT_BUTTON_ID = 'yt-downloader-format-button';
    const FORMAT_POPUP_ID = 'yt-downloader-format-popup';
    const COMBINED_BUTTON_ID = 'yt-downloader-combined-button';
    const DOWNLOAD_ACTION_ID = 'yt-downloader-action-part';
    const DROPDOWN_ACTION_ID = 'yt-downloader-dropdown-part';
    const DOWNLOAD_POPUP_ID = 'download-dropdown-popup';
    const POLL_INTERVALS = {};

    // Format definitions
    const FORMAT_GROUPS = [
        { label: 'Audio', options: [['mp3','MP3'],['m4a','M4A'],['webm','WEBM'],['aac','AAC'],['flac','FLAC'],['opus','OPUS'],['ogg','OGG'],['wav','WAV']] },
        { label: 'Video', options: [['360','MP4 (360p)'],['480','MP4 (480p)'],['720','MP4 (720p)'],['1080','MP4 (1080p)'],['1440','MP4 (1440p)'],['4k','WEBM (4K)']] }
    ];
    const DEFAULT_FORMAT = '1080';

    function getFormatText(value) {
        for (const group of FORMAT_GROUPS) {
            for (const [val, text] of group.options) {
                if (val === value) return text;
            }
        }
        return 'Select Format';
    }

    function isDarkTheme() {
        return document.documentElement.hasAttribute('dark');
    }

    function isYouTubeLiveStream() {
        const ypr = window.ytInitialPlayerResponse || {};
        return !!(
            ypr.videoDetails?.isLiveContent === true ||
            ypr.microformat?.playerMicroformatRenderer?.liveBroadcastDetails ||
            document.querySelector('meta[itemprop="isLiveBroadcast"][content="True"]') ||
            document.querySelector('.ytp-live')
        );
    }

    function checkPageAndInjectUI() {
        const existing = document.getElementById(UI_WRAPPER_ID);
        const container = document.querySelector('#end');

        if (container && !existing) injectUI(container);
        else if (!container && existing) removeUI();

        updateUIState();
    }

    document.addEventListener('yt-navigate-finish', checkPageAndInjectUI);
    window.addEventListener('load', checkPageAndInjectUI);

    function updateUIState() {
        const wrapper = document.getElementById(UI_WRAPPER_ID);
        if (!wrapper) return;
        const isWatchOrShorts = window.location.pathname === '/watch' || window.location.pathname.startsWith('/shorts/');
        const disabled = !isWatchOrShorts || isYouTubeLiveStream();

        const formatButton = wrapper.querySelector(`#${FORMAT_BUTTON_ID}`);
        const downloadActionPart = wrapper.querySelector(`#${DOWNLOAD_ACTION_ID}`);

        if(formatButton) {
             formatButton.disabled = disabled;
             formatButton.style.opacity = disabled ? 0.6 : 1;
             formatButton.style.cursor = disabled ? 'not-allowed' : 'var(--btn-cursor)';
        }
        if(downloadActionPart) {
            downloadActionPart.classList.toggle('disabled', disabled);
            downloadActionPart.style.pointerEvents = disabled ? 'none' : 'auto';
        }
    }

    function injectUI(container) {
        if (document.getElementById(UI_WRAPPER_ID)) return;
        const wrapper = document.createElement('div');
        wrapper.id = UI_WRAPPER_ID;
        wrapper.style.display = 'flex';
        wrapper.style.alignItems = 'center';
        wrapper.style.marginRight = '10px';
        wrapper.style.position = 'relative';

        // --- Format Button ---
        const formatButton = document.createElement('button');
        formatButton.id = FORMAT_BUTTON_ID;
        formatButton.style.marginRight = '8px';
        const savedFormat = GM_getValue(STORAGE_FORMAT, DEFAULT_FORMAT);
        formatButton.dataset.value = savedFormat;
        formatButton.textContent = getFormatText(savedFormat);
        formatButton.addEventListener('click', toggleFormatPopup);
        wrapper.appendChild(formatButton);

        // --- Combined Download/Dropdown Button ---
        const combinedBtn = document.createElement('div');
        combinedBtn.id = COMBINED_BUTTON_ID;
        combinedBtn.style.display = 'inline-flex';
        combinedBtn.style.alignItems = 'stretch';
        combinedBtn.style.height = '36px';
        combinedBtn.style.borderRadius = 'var(--btn-radius)';
        combinedBtn.style.backgroundColor = 'var(--btn-bg)';
        combinedBtn.style.cursor = 'default';
        combinedBtn.style.position = 'relative';

        
        const downloadPart = document.createElement('div');
        downloadPart.id = DOWNLOAD_ACTION_ID;
        downloadPart.title = 'Download Video/Audio';
        downloadPart.style.display = 'inline-flex';
        downloadPart.style.alignItems = 'center';
        downloadPart.style.padding = '0 12px 0 8px';
        downloadPart.style.cursor = 'var(--btn-cursor)';
        downloadPart.style.transition = 'background-color .2s ease';
        downloadPart.style.borderRadius = 'var(--btn-radius) 0 0 var(--btn-radius)';

        // Create SVG element programmatically
        const downloadSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        downloadSvg.setAttribute('viewBox', '0 0 24 24');
        downloadSvg.setAttribute('width', '24');
        downloadSvg.setAttribute('height', '24');
        downloadSvg.style.marginRight = '6px';
        downloadSvg.style.fill = 'none';
        downloadSvg.style.stroke = 'currentColor';
        downloadSvg.style.strokeWidth = '1.5';
        downloadSvg.style.strokeLinecap = 'round';
        downloadSvg.style.strokeLinejoin = 'round';

        const path1 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        path1.setAttribute('d', 'M12 4v12');
        const path2 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        path2.setAttribute('d', 'M8 12l4 4 4-4');
        const path3 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        path3.setAttribute('d', 'M4 18h16');

        downloadSvg.appendChild(path1);
        downloadSvg.appendChild(path2);
        downloadSvg.appendChild(path3);

        // Create span element programmatically
        const downloadSpan = document.createElement('span');
        downloadSpan.textContent = 'Download';

        // Append elements
        downloadPart.appendChild(downloadSvg);
        downloadPart.appendChild(downloadSpan);

        downloadPart.addEventListener('click', startDownload);
        downloadPart.addEventListener('mouseenter', () => { if (!downloadPart.classList.contains('disabled')) downloadPart.style.backgroundColor = 'var(--btn-hover-bg)'; });
        downloadPart.addEventListener('mouseleave', () => { downloadPart.style.backgroundColor = 'transparent'; });


        const separator = document.createElement('div');
        // ... separator styles ...

        const dropdownPart = document.createElement('div');
        dropdownPart.id = DROPDOWN_ACTION_ID;
        dropdownPart.dataset.count = '0';
        dropdownPart.title = 'Show active downloads';
        dropdownPart.style.display = 'inline-flex';
        dropdownPart.style.alignItems = 'center';
        dropdownPart.style.justifyContent = 'center';
        dropdownPart.style.padding = '0 10px';
        dropdownPart.style.cursor = 'var(--btn-cursor)';
        dropdownPart.style.position = 'relative';
        dropdownPart.style.transition = 'background-color .2s ease';
        dropdownPart.style.borderRadius = '0 var(--btn-radius) var(--btn-radius) 0';
        const darkTheme = isDarkTheme();

        // Create SVG element programmatically
        const dropdownSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        dropdownSvg.setAttribute('width', '10');
        dropdownSvg.setAttribute('height', '7');
        dropdownSvg.setAttribute('viewBox', '0 0 10 7');
        dropdownSvg.style.fill = darkTheme ? '#fff' : '#000';

        const dropdownPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        dropdownPath.setAttribute('d', 'M0 0l5 7 5-7z');
        dropdownSvg.appendChild(dropdownPath);

        // Append SVG
        dropdownPart.appendChild(dropdownSvg);

        dropdownPart.addEventListener('click', toggleDownloadPopup);
        dropdownPart.addEventListener('mouseenter', () => { dropdownPart.style.backgroundColor = 'var(--btn-hover-bg)'; });
        dropdownPart.addEventListener('mouseleave', () => { dropdownPart.style.backgroundColor = 'transparent'; });

        combinedBtn.appendChild(downloadPart);
        combinedBtn.appendChild(separator);
        combinedBtn.appendChild(dropdownPart);
        wrapper.appendChild(combinedBtn);

        container.insertAdjacentElement('afterbegin', wrapper);

        const style = document.createElement('style');

        style.textContent = `
:root {
    --btn-bg: ${darkTheme ? "#272727" : "#f2f2f2"};
    --btn-hover-bg: ${darkTheme ? "#3f3f3f" : "#e5e5e5"};
    --btn-color: ${darkTheme ? "#fff" : "#000"};
    --btn-radius: 18px;
    --btn-padding: 0 12px;
    --btn-font: 500 14px/36px "Roboto", "Arial", sans-serif;
    --btn-cursor: pointer;
    --progress-bg: ${darkTheme ? "#3f3f3f" : "#e5e5e5"};
    --progress-fill-color: #2196F3;
    --progress-text-color: ${darkTheme ? "#fff" : "#000"};
    --popup-bg: ${darkTheme ? "#212121" : "#fff"};
    --popup-border: ${darkTheme ? "#444" : "#ccc"};
    --popup-text: ${darkTheme ? "#fff" : "#030303"};
    --badge-bg: #cc0000;
    --badge-text: #fff;
    --separator-color: ${darkTheme ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.1)'};
    --popup-radius: 6px;
    --popup-shadow: 0 4px 12px rgba(0,0,0,0.15);
}

/* Format Button Styling */
#${FORMAT_BUTTON_ID} {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    color: var(--btn-color);
    background-color: var(--btn-bg);
    border: none;
    border-radius: var(--btn-radius);
    padding: 0 12px;
    padding-right: 30px;
    white-space: nowrap;
    text-transform: none;
    font: var(--btn-font);
    cursor: var(--btn-cursor);
    transition: background-color .2s ease;
    height: 36px;
    width: 130px;
    box-sizing: border-box;
    position: relative;
    box-shadow: none;
}
#${FORMAT_BUTTON_ID}:disabled {
    cursor: not-allowed;
    opacity: 0.6;
}
#${FORMAT_BUTTON_ID}:hover:not(:disabled) {
    background-color: var(--btn-hover-bg);
}
/* Dropdown Arrow for Format Button */
#${FORMAT_BUTTON_ID}::after {
    content: '';
    position: absolute;
    right: 12px; /* Position arrow within the padding */
    top: 50%;
    transform: translateY(-50%);
    width: 0;
    height: 0;
    border-left: 5px solid transparent;
    border-right: 5px solid transparent;
    border-top: 7px solid var(--btn-color);
}

/* Combined Button Styling */
#${COMBINED_BUTTON_ID} {
    color: var(--btn-color);
    font: var(--btn-font);
    line-height: 36px;
}

#${DOWNLOAD_ACTION_ID}, #${DROPDOWN_ACTION_ID} {
    background-color: transparent;
}

#${DOWNLOAD_ACTION_ID} {
    color: inherit;
}
#${DOWNLOAD_ACTION_ID} svg {
    stroke: currentColor;
}
#${DOWNLOAD_ACTION_ID}.disabled {
    opacity: 0.6;
    cursor: not-allowed;
}
#${DOWNLOAD_ACTION_ID}.disabled:hover {
    background-color: transparent !important;
}

#${DROPDOWN_ACTION_ID} {
    color: inherit;
}
#${DROPDOWN_ACTION_ID} svg {
    fill: currentColor;
}

#${DROPDOWN_ACTION_ID}::after {
    content: attr(data-count);
    position: absolute;
    top: 2px;
    right: -8px;
    background-color: var(--badge-bg);
    color: var(--badge-text);
    border-radius: 50%;
    min-width: 16px;
    height: 16px;
    padding: 0 3px;
    font-size: 10px;
    line-height: 16px;
    text-align: center;
    font-weight: bold;
    display: none;
    font-family: "Roboto", "Arial", sans-serif;
    box-sizing: border-box;
    z-index: 2;
}
#${DROPDOWN_ACTION_ID}[data-count]:not([data-count="0"])::after {
    display: inline-block;
}

/* General Popup Styling */
#${FORMAT_POPUP_ID}, #${DOWNLOAD_POPUP_ID} {
    position: absolute;
    background: var(--popup-bg);
    color: var(--popup-text);
    border: 1px solid var(--popup-border);
    border-radius: var(--popup-radius);
    box-shadow: var(--popup-shadow);
    padding: 10px;
    z-index: 10000;
    max-height: 350px;
    overflow-y: auto;
}

/* Format Popup Specifics */
#${FORMAT_POPUP_ID} {
    width: 200px;
}
.format-group-label {
    font-weight: bold;
    font-size: 12px;
    color: ${darkTheme ? '#aaa' : '#555'};
    margin-top: 8px;
    margin-bottom: 4px;
    padding-left: 5px;
    text-transform: uppercase;
}
.format-group-label:first-child {
    margin-top: 0;
}
.format-item {
    display: block;
    width: 100%;
    padding: 6px 10px;
    font-size: 14px;
    cursor: pointer;
    border-radius: 4px;
    box-sizing: border-box;
    text-align: left;
    background: none;
    border: none;
    color: inherit;
}
.format-item:hover {
    background-color: var(--btn-hover-bg);
}
.format-item.selected {
    font-weight: bold;
    background-color: rgba(0, 100, 255, 0.1);
}


/* Download Popup Specifics */
#${DOWNLOAD_POPUP_ID} {
    width: 280px;
}
.download-item {
    margin-bottom: 12px;
    padding-bottom: 8px;
    border-bottom: 1px solid var(--popup-border);
}
.download-item:last-child {
    margin-bottom: 0;
    border-bottom: none;
}
.progress-bar {
    width: 100%;
    height: 18px;
    background-color: var(--progress-bg);
    border-radius: 9px;
    overflow: hidden;
    position: relative;
    margin-top: 4px;
}
.progress-fill {
    height: 100%;
    width: 0%;
    background-color: var(--progress-fill-color);
    transition: width 0.3s ease-in-out;
    display: flex;
    align-items: center;
    justify-content: center;
}
.progress-text {
    position: absolute; top: 0; left: 0; right: 0; bottom: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--progress-text-color);
    font: var(--btn-font);
    font-size: 11px;
    line-height: 18px;
    white-space: nowrap;
    z-index: 1;
}
.download-item-title {
    font-size: 13px;
    font-weight: 500;
    margin-bottom: 2px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    display: block;
}
.download-item-format {
    font-size: 11px;
    color: ${darkTheme ? '#aaa' : '#555'};
    display: block;
    margin-bottom: 4px;
}
.no-downloads-message {
    font-size: 15px;
    color: ${darkTheme ? '#aaa' : '#555'};
    text-align: center;
    padding: 10px 0;
}
        `;

        document.head.appendChild(style);

        resumeDownloads();
        updateDownloadCountBadge();
        updateUIState();
    }

    function removeUI() {
        const w = document.getElementById(UI_WRAPPER_ID);
        if (w) w.remove();
        // Remove both popups if they exist
        const formatPopup = document.getElementById(FORMAT_POPUP_ID);
        if (formatPopup) formatPopup.remove();
        const downloadPopup = document.getElementById(DOWNLOAD_POPUP_ID);
        if (downloadPopup) downloadPopup.remove();
    }

    function startDownload() {
        const downloadActionPart = document.getElementById(DOWNLOAD_ACTION_ID);
        if (downloadActionPart && downloadActionPart.classList.contains('disabled')) return;

        const formatButton = document.getElementById(FORMAT_BUTTON_ID);
        const fmt = formatButton.dataset.value;
        const formatText = formatButton.textContent;
        const videoUrl = encodeURIComponent(location.href);
        const initUrl = `https://p.oceansaver.in/ajax/download.php?format=${fmt}&url=${videoUrl}`;
        const id = Date.now().toString();
        const title = document.querySelector('h1.ytd-watch-metadata #video-title, h1.title.ytd-video-primary-info-renderer')?.textContent.trim() || 'YouTube Video';

        GM_xmlhttpRequest({
            method: 'GET', url: initUrl, responseType: 'json',
            onload(res) {
                const data = res.response;
                if (!data?.success) return alert('Failed to initialize');
                const downloads = GM_getValue(STORAGE_DOWNLOADS, []);
                downloads.push({ id, title, format: formatText, progress_url: data.progress_url, progress: 0, status: 'in_progress' });
                GM_setValue(STORAGE_DOWNLOADS, downloads);
                renderDownloadPopup(); // Update download popup if open
                updateDownloadCountBadge();
                pollProgress(id);
            },
            onerror() { alert('Network error'); }
        });
    }

    function pollProgress(id) {
        const downloads = GM_getValue(STORAGE_DOWNLOADS, []);
        const dl = downloads.find(d=>d.id===id);
        if (!dl || dl.status !== 'in_progress') return;

        if (POLL_INTERVALS[id]) clearInterval(POLL_INTERVALS[id]);

        const interval = setInterval(()=>{
            const currentDownloads = GM_getValue(STORAGE_DOWNLOADS, []);
            const currentDl = currentDownloads.find(d=>d.id===id);
            if (!currentDl || currentDl.status !== 'in_progress') {
                 console.log(`Stopping poll for ${id}, status changed.`);
                 clearInterval(interval);
                 delete POLL_INTERVALS[id];
                 renderDownloadPopup();
                 updateDownloadCountBadge();
                 return;
            }

            GM_xmlhttpRequest({ method:'GET', url: dl.progress_url, responseType:'json', onload(res){
                    const p = res.response;
                    const all = GM_getValue(STORAGE_DOWNLOADS, []);
                    const obj = all.find(x=>x.id===id);
                    if (!obj) {
                        clearInterval(interval); delete POLL_INTERVALS[id];
                        updateDownloadCountBadge();
                        return;
                    }
                    if (!p) {
                        console.warn(`Empty poll response for ${id}`);
                        return;
                    }
                    let statusChanged = false;
                    if (p.success) {
                        clearInterval(interval); delete POLL_INTERVALS[id];
                        obj.progress=100; obj.status='completed'; obj.download_url=p.download_url;
                        statusChanged = true;
                        GM_setValue(STORAGE_DOWNLOADS, all);
                        renderDownloadPopup();
                        triggerFileDownload(p.download_url);
                    } else if (p.error) {
                        clearInterval(interval); delete POLL_INTERVALS[id];
                        obj.status = 'error'; obj.errorMsg = p.error;
                        statusChanged = true;
                        GM_setValue(STORAGE_DOWNLOADS, all);
                        renderDownloadPopup();
                        console.error(`Download ${id} failed: ${p.error}`);
                    } else {
                        const percent = p.progress ? Math.min(Math.round(p.progress/10),100) : obj.progress;
                        if (obj.progress !== percent || obj.status !== 'in_progress') {
                            obj.progress = percent;
                            obj.status = 'in_progress';
                            GM_setValue(STORAGE_DOWNLOADS, all);
                            renderDownloadPopup();
                        }
                    }
                    if (statusChanged) {
                        updateDownloadCountBadge();
                    }
                },
                onerror(){
                    clearInterval(interval); delete POLL_INTERVALS[id];
                    const all = GM_getValue(STORAGE_DOWNLOADS, []);
                    const obj = all.find(x=>x.id===id);
                    if(obj) {
                        obj.status = 'error'; obj.errorMsg = 'Network error during polling';
                        GM_setValue(STORAGE_DOWNLOADS, all);
                        renderDownloadPopup();
                        updateDownloadCountBadge();
                    }
                    console.error(`Network error polling ${id}`);
                }
            });
        }, 2000);
        POLL_INTERVALS[id] = interval;
    }

    function triggerFileDownload(url) {
        const a = document.createElement('a'); a.href=url; a.download=''; document.body.appendChild(a);
        a.click(); a.remove();
    }

    // --- Popup Toggle Functions ---

    function toggleFormatPopup() {
        let popup = document.getElementById(FORMAT_POPUP_ID);
        if (popup) { popup.remove(); return; }

        // Close download popup if open
        const downloadPopup = document.getElementById(DOWNLOAD_POPUP_ID);
        if (downloadPopup) downloadPopup.remove();

        const wrapper = document.getElementById(UI_WRAPPER_ID);
        const formatButton = document.getElementById(FORMAT_BUTTON_ID);
        if (!wrapper || !formatButton) return;

        popup = document.createElement('div');
        popup.id = FORMAT_POPUP_ID;
        wrapper.appendChild(popup);
        renderFormatPopup();

        // Position popup below format button
        const buttonRect = formatButton.getBoundingClientRect();
        const wrapperRect = wrapper.getBoundingClientRect();
        popup.style.top = (buttonRect.bottom - wrapperRect.top + 5) + 'px';
        popup.style.left = (buttonRect.left - wrapperRect.left) + 'px';

        setTimeout(() => {
            document.addEventListener('click', handleClickOutsideFormatPopup, { capture: true, once: true });
        }, 0);
    }

    function toggleDownloadPopup() {
        let popup = document.getElementById(DOWNLOAD_POPUP_ID);
        if (popup) { popup.remove(); return; }

        // Close format popup if open
        const formatPopup = document.getElementById(FORMAT_POPUP_ID);
        if (formatPopup) formatPopup.remove();

        const wrapper = document.getElementById(UI_WRAPPER_ID);
        const combinedButton = document.getElementById(COMBINED_BUTTON_ID);
        if (!wrapper || !combinedButton) return;

        popup = document.createElement('div');
        popup.id = DOWNLOAD_POPUP_ID;
        wrapper.appendChild(popup);
        renderDownloadPopup();

        // Position popup below combined button, aligned right
        const buttonRect = combinedButton.getBoundingClientRect();
        const wrapperRect = wrapper.getBoundingClientRect();
        popup.style.top = (buttonRect.bottom - wrapperRect.top + 5) + 'px';
        popup.style.right = (wrapperRect.right - buttonRect.right) + 'px';

        setTimeout(() => {
            document.addEventListener('click', handleClickOutsideDownloadPopup, { capture: true, once: true });
        }, 0);
    }

    // --- Popup Click Outside Handlers ---

    function handleClickOutsideFormatPopup(event) {
        const popup = document.getElementById(FORMAT_POPUP_ID);
        const button = document.getElementById(FORMAT_BUTTON_ID);
        if (popup && !popup.contains(event.target) && !button.contains(event.target)) {
            popup.remove();
        } else if (popup) {
            // Re-attach listener if click was inside popup or on button
            document.addEventListener('click', handleClickOutsideFormatPopup, { capture: true, once: true });
        }
    }

    function handleClickOutsideDownloadPopup(event) {
        const popup = document.getElementById(DOWNLOAD_POPUP_ID);
        const button = document.getElementById(DROPDOWN_ACTION_ID); // Check against the dropdown part
        if (popup && !popup.contains(event.target) && !button.contains(event.target)) {
            popup.remove();
        } else if (popup) {
            document.addEventListener('click', handleClickOutsideDownloadPopup, { capture: true, once: true });
        }
    }

    // --- Popup Render Functions ---

    function renderFormatPopup() {
        const popup = document.getElementById(FORMAT_POPUP_ID);
        if (!popup) return;
        popup.textContent = '';
        const currentFormat = GM_getValue(STORAGE_FORMAT, DEFAULT_FORMAT);

        FORMAT_GROUPS.forEach(group => {
            const groupLabel = document.createElement('div');
            groupLabel.className = 'format-group-label';
            groupLabel.textContent = group.label;
            popup.appendChild(groupLabel);

            group.options.forEach(([value, text]) => {
                const item = document.createElement('button');
                item.className = 'format-item';
                item.textContent = text;
                item.dataset.value = value;
                if (value === currentFormat) {
                    item.classList.add('selected');
                }
                item.onclick = () => {
                    GM_setValue(STORAGE_FORMAT, value);
                    const formatButton = document.getElementById(FORMAT_BUTTON_ID);
                    if (formatButton) {
                        formatButton.textContent = text;
                        formatButton.dataset.value = value;
                    }
                    popup.remove();
                };
                popup.appendChild(item);
            });
        });
    }


    function renderDownloadPopup() {
        const popup = document.getElementById(DOWNLOAD_POPUP_ID);
        if (!popup) return;
        popup.textContent = '';

        const downloads = GM_getValue(STORAGE_DOWNLOADS, [])
                         .filter(d => d.status === 'in_progress' || d.status === 'error')
                         .sort((a, b) => (b.id - a.id));

        if (!downloads.length) {
            const noDownloadsMsg = document.createElement('div');
            noDownloadsMsg.className = 'no-downloads-message';
            noDownloadsMsg.textContent = 'No active or recent failed downloads.';
            popup.appendChild(noDownloadsMsg);
            return;
        }

        downloads.forEach(d => {
            const item = document.createElement('div');
            item.className = 'download-item';

            const titleDiv = document.createElement('div');
            titleDiv.className = 'download-item-title';
            titleDiv.textContent = d.title || `Download ${d.id}`;
            titleDiv.title = d.title || `Download ${d.id}`;
            item.appendChild(titleDiv);

            const formatDiv = document.createElement('div');
            formatDiv.className = 'download-item-format';
            formatDiv.textContent = d.format || 'Unknown Format';
            item.appendChild(formatDiv);

            if (d.status === 'in_progress') {
                const bar = document.createElement('div'); bar.className = 'progress-bar';
                const fill = document.createElement('div'); fill.className = 'progress-fill';
                fill.style.width = `${d.progress}%`;
                bar.appendChild(fill);
                const txt = document.createElement('div'); txt.className = 'progress-text';
                txt.textContent = `${d.progress}%`;
                bar.appendChild(txt);
                item.appendChild(bar);
            } else if (d.status === 'error') {
                const errorDiv = document.createElement('div');
                errorDiv.style.color = '#f44336'; errorDiv.style.fontSize = '12px';
                errorDiv.textContent = `Error: ${d.errorMsg || 'Unknown error'}`;
                item.appendChild(errorDiv);
            }
            popup.appendChild(item);
        });

         if (downloads.some(d => d.status === 'error')) {
            const clearButton = document.createElement('button');
            clearButton.textContent = 'Clear Errors';
            clearButton.style.marginTop = '10px';
            clearButton.style.fontSize = '12px';
            clearButton.style.padding = '4px 8px';
            clearButton.style.backgroundColor = 'var(--btn-bg)';
            clearButton.style.color = 'var(--btn-color)';
            clearButton.style.border = 'none';
            clearButton.style.borderRadius = '4px';
            clearButton.style.cursor = 'pointer';
            clearButton.onmouseover = () => clearButton.style.backgroundColor = 'var(--btn-hover-bg)';
            clearButton.onmouseout = () => clearButton.style.backgroundColor = 'var(--btn-bg)';

            clearButton.onclick = () => {
                const allDownloads = GM_getValue(STORAGE_DOWNLOADS, []);
                const keptDownloads = allDownloads.filter(dl => dl.status !== 'error');
                GM_setValue(STORAGE_DOWNLOADS, keptDownloads);
                renderDownloadPopup();
                updateDownloadCountBadge(); // Badge only shows 'in_progress', errors don't count
            };
            popup.appendChild(clearButton);
        }
    }

    function updateDownloadCountBadge() {
        const dropdownPart = document.getElementById(DROPDOWN_ACTION_ID);
        if (!dropdownPart) return;

        const downloads = GM_getValue(STORAGE_DOWNLOADS, []);
        const activeCount = downloads.filter(d => d.status === 'in_progress').length;

        dropdownPart.dataset.count = activeCount.toString();
    }

    function resumeDownloads() {
        const downloads = GM_getValue(STORAGE_DOWNLOADS, []).filter(d => d.status === 'in_progress');
        console.log(`Resuming ${downloads.length} downloads.`);
        downloads.forEach(d => {
            if (!POLL_INTERVALS[d.id]) {
                 pollProgress(d.id);
            }
        });
    }
})();