YoutubeDL

Download youtube videos at the comfort of your browser.

// ==UserScript==
// @name         YoutubeDL
// @namespace    https://www.youtube.com/
// @version      1.0.3
// @description  Download youtube videos at the comfort of your browser.
// @author       realcoloride
// @match        https://www.youtube.com/*
// @match        https://www.youtube.com/watch*
// @match        https://www.youtube.com/shorts*
// @match        https://www.youtube.com/embed*
// @connect      savetube.io
// @connect      googlevideo.com
// @connect      aadika.xyz
// @connect      dlsnap11.xyz
// @connect      githubusercontent.com
// @connect      *
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @license      MIT
// @grant        GM.xmlHttpRequest
// ==/UserScript==

(function() {
    'use strict';

    let pageInformation = {
        loaded : false,
        website : "https://savetube.io",
        searchEndpoint : null,
        convertEndpoint : null,
        checkingEndpoint : null,
        pageValues : {}
    }

    // Process:
    // Search -> Checking -> Convert by -> Convert using c_server

    const githubAssetEndpoint = "https://raw.githubusercontent.com/realcoloride/YoutubeDL/main/";

    let videoInformation;
    const fetchHeaders = {
        'Accept': '*/*',
        'Accept-Language': 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7',
        'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
        'Sec-Ch-Ua': '"Not.A/Brand";v="8", "Chromium";v="114", "Google Chrome";v="114"',
        'Sec-Ch-Ua-Mobile': '?0',
        'Sec-Ch-Ua-Platform': '"Windows"',
        'Sec-Fetch-Dest': 'empty',
        'Sec-Fetch-Mode': 'cors',
        'Sec-Fetch-Site': 'none',
    };
    const convertHeaders = {
        "accept": "*/*",
        "accept-language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
        "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
        "sec-ch-ua": "\"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"114\", \"Google Chrome\";v=\"114\"",
        "sec-ch-ua-mobile": "?0",
        "sec-ch-ua-platform": "\"Windows\"",
        "sec-fetch-dest": "empty",
        "sec-fetch-mode": "cors",
        "sec-fetch-site": "cross-site",
        "x-requested-key": "de0cfuirtgf67a"
    };
    const downloadHeaders = {
        "accept": "*/*",
        "accept-language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
        "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
        "sec-ch-ua": "\"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"114\", \"Google Chrome\";v=\"114\"",
        "sec-ch-ua-mobile": "?0",
        "sec-ch-ua-platform": "\"Windows\"",
        "sec-fetch-dest": "empty",
        "sec-fetch-mode": "cors",
        "sec-fetch-site": "same-origin",
        "x-requested-with": "XMLHttpRequest"
    };

    const popupHTML = `
        <div id="youtubeDL-popup">
                <span class="youtubeDL-text bigger float">
                    <img src="{asset}YoutubeDL.png" class="youtubeDL-logo float">
                    YoutubeDL - Download video
                    <button id="youtubeDL-close" class="youtubeDL-button youtubeDL-align right" aria-label="Cancel">
                        <span>Close</span>
                    </button>
                </span>

                <hr style="height:3px">

                <div id="youtubeDL-loading">
                    <span class="youtubeDL-text medium center float" style="display: flex;">
                        <img src="{asset}loading.svg" style="width:21px; padding-right: 6px;"> Loading...
                    </span>
                </div>

                <div id="youtubeDL-quality">
                    <span class="youtubeDL-text medium center float" >Select a quality and click on Download.</span><br>
                    <span class="youtubeDL-text medium center float" style="margin-bottom: 10px;">
                    ⚠️ CLICK 
                    <a href="{asset}allow.gif" target="_blank"><strong>"ALWAYS ALLOW ALL DOMAINS"</strong></a>
                    
                    WHEN DOWNLOADING FOR THE FIRST TIME.
                    
                    <span class="youtubeDL-text center float">Some providers may have a bigger file size than estimated.</span>
                    </span>
                    
                    <table id="youtubeDL-quality-table" style="width: 100%; border-spacing: 0;">
                        <thead class="youtubeDL-row">
                            <th class="youtubeDL-column youtubeDL-text">Format</th>
                            <th class="youtubeDL-column youtubeDL-text">Quality</th>
                            <th class="youtubeDL-column youtubeDL-text">Estimated Size</th>
                            <th class="youtubeDL-column youtubeDL-text">Download</th>
                        </thead>
                        <tbody id="youtubeDL-quality-container">
                            
                        </tbody>
                    </table>
                </div>

                <div class="youtubeDL-credits">
                    <span class="youtubeDL-text medium">YoutubeDL by (real)coloride - 2023</span>
                    <br>
                    <a class="youtubeDL-text medium" href="https://www.github.com/realcoloride/YoutubeDL">
                        <img src="{asset}github.png" width="21px">Github</a>
                    
                    <a class="youtubeDL-text medium" href="https://opensource.org/license/mit/">
                        <img src="{asset}mit.png" width="21px">MIT license
                    </a>
                </div>
            </div>
    `;
    
    // Element definitions
    
    const ytdAppContainer = document.querySelector("ytd-app");
    let popupElement;

    // Information gathering
    function getVideoInformation(url) {
        const regex = /(?:https?:\/\/(?:www\.)?youtube\.com\/(?:watch\?v=|shorts\/|embed\/)?)([\w-]+)/i;
        const match = regex.exec(url);
        const videoId = match ? match[1] : null;
        
        let type = null;
        if (url.includes("/shorts/"))       type = "shorts";
        else if (url.includes("/watch?v=")) type = "video";
        else if (url.includes("/embed/"))   type = "embed";
        
        return { type, videoId };
    };

    // Fetching
    function convertSizeToBytes(size) {
        const units = {
            B: 1,
            KB: 1024,
            MB: 1024 * 1024,
            GB: 1024 * 1024 * 1024,
        };
      
        const regex = /^(\d+(?:\.\d+)?)\s*([A-Z]+)$/i;
        const match = size.match(regex);
      
        if (!match) return 0;
      
        const value = parseFloat(match[1]);
        const unit = match[2].toUpperCase();
      
        if (!units.hasOwnProperty(unit)) return 0;
      
        return value * units[unit];
    }     
    function decipherVariables(variableString) {
        const variableDict = {};
      
        const variableAssignments = variableString.match(/var\s+(\w+)\s*=\s*(.+?);/g);
      
        variableAssignments.forEach((assignment) => {
            const [, variableName, variableValue] = assignment.match(/var\s+(\w+)\s*=\s*['"](.+?)['"];/);
        
            const trimmedValue = variableValue.trim().replace(/^['"]|['"]$/g, '');
        
            variableDict[variableName] = trimmedValue;
        });
      
        return variableDict;
    }
    function isTimestampExpired(timestamp) {
        const currentTimestamp = Math.floor(Date.now() / 1000);
        return currentTimestamp > timestamp;
    }
    async function fetchPageInformation() {
        // Scrapping internal values
        const pageRequest = await GM.xmlHttpRequest({
            url: `${pageInformation.website}`,
            method: "GET",
            headers: fetchHeaders,
        });

        const parser = new DOMParser();
        const pageDocument = parser.parseFromString(pageRequest.responseText, "text/html");

        let scrappedScriptElement;

        pageDocument.querySelectorAll("script").forEach((scriptElement) => {
            const scriptHTML = scriptElement.innerHTML;
            if (scriptHTML.includes("k_time") && scriptHTML.includes("k_page")) {
                scrappedScriptElement = scriptElement;
                return;
            }
        });

        const pageValues = decipherVariables(scrappedScriptElement.innerHTML);
        pageInformation.pageValues = pageValues;

        pageInformation.searchEndpoint = pageValues['k_url_search'];
        pageInformation.convertEndpoint = pageValues['k_url_convert'];
        pageInformation.checkingEndpoint = pageValues['k_url_check_task'];

        pageInformation.loaded = true;
    }
    async function startConversion(fileExtension, fileQuality, timeExpires, token, filename, button) {
        const videoType = videoInformation.type;
        const videoId = videoInformation.videoId;

        if (!videoType) return;

        const initialFormData = new FormData();
        initialFormData.append('v_id', videoId);
        initialFormData.append('ftype', fileExtension); 
        initialFormData.append('fquality', fileQuality);
        initialFormData.append('token', token);
        initialFormData.append('timeExpire', timeExpires);
        initialFormData.append('client', 'SaveTube.io');
        const initialRequestBody = new URLSearchParams(initialFormData).toString();

        let result = null;

        try {
            const payload = {
                url: pageInformation.convertEndpoint,
                method: "POST",
                headers: convertHeaders,
                data: initialRequestBody,
                responseType: 'text',
                referrerPolicy: "strict-origin-when-cross-origin",
                mode: "cors",
                credentials: "omit"
            };

            const initialRequest = await GM.xmlHttpRequest(payload);
            const initialResponse = JSON.parse(initialRequest.responseText);

            // Needs conversion is it links to a server
            const downloadLink = initialResponse.d_url;
            const needsConversation = (downloadLink == null);
            
            if (needsConversation) {
                updatePopupButton(button, 'Converting...');
                const conversionServerEndpoint = initialResponse.c_server;

                const convertFormData = new FormData();
                convertFormData.append('v_id', videoId);
                convertFormData.append('ftype', fileExtension); 
                convertFormData.append('fquality', fileQuality);
                convertFormData.append('fname', filename);
                convertFormData.append('token', token);
                convertFormData.append('timeExpire', timeExpires);
                const convertRequestBody = new URLSearchParams(convertFormData).toString();

                const convertRequest = await GM.xmlHttpRequest({
                    url: `${conversionServerEndpoint}/api/json/convert`,
                    method: "POST",
                    headers: convertHeaders,
                    data: convertRequestBody,
                    responseType: 'text', 
                });

                let convertResponse;

                let adaptedResponse = {};
                let result;

                try {
                    convertResponse = JSON.parse(convertRequest.responseText);
                    
                    result = convertResponse.result;
                    adaptedResponse = {
                        c_status : convertResponse.status,
                        d_url: result
                    }
                } catch (error) {
                    alert("[YoutubeDL] Converting failed.\nYou might have been downloading too fast and have been rate limited or your antivirus may be blocking the media.\n(💡 If so, refresh the page or check your antivirus's settings.)")

                    result = "error";
                    adaptedResponse = {
                        c_status : "error"
                    }
                    return adaptedResponse;
                }

                if (result == 'Converting') { // Not converted
                    const jobId = convertResponse.jobId;

                    console.log(`[YoutubeDL] Download needs to be checked on, jobId: ${jobId}, waiting...`);
                    updatePopupButton(button, 'Waiting for server...');

                    async function gatherResult() {
                        return new Promise(async(resolve, reject) => {
                            const parsedURL = new URL(conversionServerEndpoint);
                            const protocol = parsedURL.protocol === "https:" ? "wss:" : "ws:";
                            const websocketURL = `${protocol}//${parsedURL.host}/sub/${jobId}?fname=${pageInformation.pageValues.k_prefix_name}`;
                            
                            const socket = new WebSocket(websocketURL);

                            socket.onmessage = function(event) {
                                const message = JSON.parse(event.data);

                                switch (message.action) {
                                    case "success":
                                        socket.close();
                                        resolve(message.url);
                                    case "progress":
                                        updatePopupButton(button, `Converting... ${message.value}%`)
                                    case "error":
                                        socket.close();
                                        reject("WSCheck fail");
                                };
                            };
                        });
                    };

                    try {
                        const conversionUrl = await gatherResult();
                        adaptedResponse.d_url = conversionUrl;
                    } catch (error) {
                        console.error("[YoutubeDL] Error while checking for job converstion:", error);
                        adaptedResponse.c_status = 'error';

                        updatePopupButton(button, 'Converting Failed'); 
                        setTimeout(() => {
                            button.disabled = false;
                            updatePopupButton(button, 'Download');
                        }, 1000);
                    }
                }

                return adaptedResponse;
            } else {
                result = initialResponse;
            }
        } catch (error) {
            console.error(error);
            return null;
        }

        return result;
    }
    async function getMediaInformation() {
        const videoType = videoInformation.type;
        const videoId = videoInformation.videoId;

        if (!videoType) return;

        const formData = new FormData();
        formData.append('q', `https://www.youtube.com/watch?v=${videoId}`);
        formData.append('vt', 'home');
        const requestBody = new URLSearchParams(formData).toString();

        let result = null;

        try {
            const request = await GM.xmlHttpRequest({
                url: pageInformation.searchEndpoint,
                method: "POST",
                headers: fetchHeaders,
                data: requestBody,
                responseType: 'text',
            });
            
            result = JSON.parse(request.responseText);
        } catch (error) {
            return null;
        }

        return result;
    }

    // Light mode/Dark mode
    function isDarkMode() {
        if (videoInformation.type == 'embed') return true;
        
        const computedStyles = window.getComputedStyle(ytdAppContainer);

        const backgroundColor = computedStyles["background-color"];

        return backgroundColor.endsWith('15)');
    }
    function toggleLightClass(queryTarget) {
        const elements = document.querySelectorAll(queryTarget);
      
        elements.forEach((element) => {
            element.classList.toggle("light");
            toggleLightClassRecursive(element);
        });
    }
    function toggleLightClassRecursive(element) {
        const children = element.children;
    
        for (let i = 0; i < children.length; i++) {
            children[i].classList.toggle("light");
            toggleLightClassRecursive(children[i]);
        }
    }

    // Popup
    // Links
    // Downloading
    async function downloadFile(button, url, filename) {
        const baseText = `Download`;
        
        button.disabled = true;
        updatePopupButton(button, "Downloading...");
    
        console.log(`[YoutubeDL] Downloading media URL: ${url}`);
        
        function finish() {
            updatePopupButton(button, baseText);
            if (button.disabled) button.disabled = false
        }

        GM.xmlHttpRequest({
            method: 'GET',
            headers: downloadHeaders,
            url: url,
            responseType: 'blob',
            onload: async function(response) {
                if (response.status == 403) { 
                    alert("[YoutubeDL] Media expired or may be impossible to download, please retry or try with another format, sorry!"); 
                    await reloadMedia(); 
                    return; 
                }
                
                const blob = response.response;
                const link = document.createElement('a');

                link.href = URL.createObjectURL(blob);
                link.setAttribute('download', filename);
                link.click();

                URL.revokeObjectURL(link.href);
                updatePopupButton(button, 'Downloaded!');
                button.disabled = false;

                setTimeout(finish, 1000);
            },
            onerror: function(error) {
                console.error('[YoutubeDL] Download Error:', error);
                updatePopupButton(button, 'Download Failed');
                
                setTimeout(finish, 1000);
            }, 
            onprogress: function(progressEvent) {
                if (progressEvent.lengthComputable) {
                    const percentComplete = Math.round((progressEvent.loaded / progressEvent.total) * 100);
                    updatePopupButton(button, `Downloading: ${percentComplete}%`);
                } else
                    updatePopupButton(button, 'Downloading...');
            }
        });
    }
    function updatePopupButton(button, text) {
        button.innerHTML = `<strong>${text}</strong>`;
        if (!isDarkMode()) button.classList.add('light');
    }
    async function createMediaFile(params) {
        let { format, quality, size, extension, timeExpires, videoTitle, token } = params;

        const qualityContainer = getPopupElement("quality-container");

        const row = document.createElement("tr");
        row.classList.add("youtubeDL-row");

        function createRowElement() {
            const rowElement = document.createElement("td");
            rowElement.classList.add("youtubeDL-row-element");

            return rowElement;
        }
        function addRowElement(rowElement) {
            row.appendChild(rowElement);
        }

        function createSpanText(text, targetElement) {
            const spanText = document.createElement("span");
            spanText.classList.add("youtubeDL-text");

            spanText.innerHTML = `<strong>${text}</strong>`;
            if (!isDarkMode()) spanText.classList.add('light');

            targetElement.appendChild(spanText);
        }

        // Format
        const formatRowElement = createRowElement();
        createSpanText(format, formatRowElement);
        addRowElement(formatRowElement);

        // Quality
        const qualityRowElement = createRowElement();
        createSpanText(quality, qualityRowElement);
        addRowElement(qualityRowElement);
        
        // Size
        const sizeRowElement = createRowElement();
        createSpanText(size, sizeRowElement);
        addRowElement(sizeRowElement);

        const downloadRowElement = createRowElement();
        const downloadButton = document.createElement("button");
        downloadButton.classList.add("youtubeDL-button");
        downloadButton.ariaLabel = "Download";
        updatePopupButton(downloadButton, "Download");

        downloadButton.addEventListener("click", async(event) => {
            try {
                downloadButton.disabled = true;
                updatePopupButton(downloadButton, "Fetching info...");

                if (isTimestampExpired(pageInformation.pageValues.k_time)) {
                    await reloadMedia();
                    return;
                }

                extension = extension.replace(/ \(audio\)|kbps/g, '');
                quality = quality.replace(/ \(audio\)|kbps/g, '');
                let filename = `YoutubeDL_${videoTitle}_${quality}.${extension}`;
                if (extension == "mp3") filename = `YoutubeDL_${videoTitle}.${extension}`;
                
                const conversionRequest = await startConversion(extension, quality, timeExpires, token, filename, downloadButton);
                const conversionStatus = conversionRequest.c_status;

                async function fail() {
                    throw Error("Failed to download.");
                }

                if (!conversionStatus) { fail(); return; }
                if (conversionStatus != 'ok' && conversionStatus != 'success') { fail(); return; }

                const downloadLink = conversionRequest.d_url;
                await downloadFile(downloadButton, downloadLink, filename);
            } catch (error) {
                console.error(error);

                downloadButton.disabled = true;
                updatePopupButton(downloadButton, '');

                setTimeout(() => {
                    downloadButton.disabled = false;
                    updatePopupButton(downloadButton, 'Download');
                }, 2000);
            }
        });

        downloadRowElement.appendChild(downloadButton);
        addRowElement(downloadRowElement);

        qualityContainer.appendChild(row);
    }
    async function loadMediaFromLinks(response) {
        try {
            const links = response.links;
            const token = response.token;
            const timeExpires = response.timeExpires;
            const videoTitle = response.title;

            const audioLinks = links.mp3;
            let videoLinks = links.mp4;

            function addFormat(information) {
                const format = information.f;
                if (!format) return;

                const quality = information.q;
                let size = information.size;

                const regex = /\s[BKMGT]?B/;
                const unit = size.match(regex)[0];
                const sizeNoUnit = size.replace(regex, "");
                const roundedSize = Math.round(parseFloat(sizeNoUnit));
                
                size = `${roundedSize}${unit}`;

                createMediaFile({
                    extension: format, 
                    quality,
                    timeExpires,
                    videoTitle,

                    format: format.toUpperCase(),
                    size,
                    token
                });
            }

            // Audio will only have this one so it doesnt matter
            const defaultAudioFormat = audioLinks[Object.keys(audioLinks)[0]];
            defaultAudioFormat.f = "mp3 (audio)";

            addFormat(defaultAudioFormat);

            // Format sorting first
            // Remove auto quality
            videoLinks["auto"] = null;

            // Store 3gp quality if available
            const low3gpFormat = { ...videoLinks["3gp@144p"] };
            delete videoLinks["3gp@144p"];

            // Sort from highest to lowest quality
            const qualities = {};

            for (const [qualityId, information] of Object.entries(videoLinks)) {
                if (!information) continue;

                const qualityName = information.q;
                const strippedQualityName = qualityName.replace('p', '');
                const quality = parseInt(strippedQualityName);

                qualities[quality] = qualityId;
            }

            const newOrder = Object.keys(qualities).sort((a, b) => a - b);

            function swapKeys(object, victimKeys, targetKeys) {
                const swappedObj = {};

                victimKeys.forEach((key, index) => {
                    swappedObj[targetKeys[index]] = object[key];
                });

                return swappedObj;
            }
            videoLinks = swapKeys(videoLinks, Object.keys(videoLinks), newOrder);
             
            // Bubble swapping estimated qualities if incorrect (by provider) 
            function bubbleSwap() {
                const videoLinkIds = Object.keys(videoLinks);
                videoLinkIds.forEach((qualityId) => {
                    const currentQualityInformation = videoLinks[qualityId];
                    if (!currentQualityInformation) return;

                    const currentQualityIndex = videoLinkIds.findIndex((id) => id === qualityId);
                    if (currentQualityIndex - 1 < 0) return;

                    const previousQualityIndex = currentQualityIndex - 1;
                    const previousQualityId = videoLinkIds[previousQualityIndex];

                    if (!previousQualityId) return;

                    const previousQualityInformation = videoLinks[previousQualityId];

                    function getQualityOf(information) {
                        const qualityName = information.q;
                        const strippedQualityName = qualityName.replace('p', '');
                        const quality = parseInt(strippedQualityName);

                        return { qualityName, strippedQualityName, quality };
                    }

                    const previousQuality = getQualityOf(previousQualityInformation);
                    const currentQuality = getQualityOf(currentQualityInformation);

                    function swap() {
                        console.log(`[YoutubeDL] Swapping incorrect formats: [${previousQuality.qualityName}] ${previousQualityInformation.size} -> [${currentQuality.qualityName}] ${currentQualityInformation.size}`);

                        const previousClone = { ... previousQualityInformation};
                        const currentClone = { ... currentQualityInformation};

                        previousQualityInformation.size = currentClone.size;
                        currentQualityInformation.size = previousClone.size;
                    }

                    const previousSize = previousQualityInformation.size;
                    const previousSizeBytes = convertSizeToBytes(previousSize);

                    const currentSize = currentQualityInformation.size;
                    const currentSizeBytes = convertSizeToBytes(currentSize);

                    if (previousSizeBytes < currentSizeBytes) swap();
                });
            };

            for (let i = 0; i < Object.keys(videoLinks).length; i++) bubbleSwap();
            
            for (const [qualityId, information] of Object.entries(videoLinks)) {
                if (!information) continue;

                const qualityName = information.q;
                const strippedQualityName = qualityName.replace('p', '');
                const quality = parseInt(strippedQualityName);

                qualities[quality] = qualityId;
                addFormat(information);
            }

            if (low3gpFormat) addFormat(low3gpFormat);
        } catch (error) {
            console.error("[YoutubeDL] Failed loading media:", error);
            alert("[YoutubeDL] Failed fetching media.\n" +
            "This could be either because:\n" +
            "- An unhandled error\n" +
            "- Your tampermonkey settings\n" +
            "or an issue with the API.\n\n" +
            "Try to refresh the page, otherwise, reinstall the plugin.")

            togglePopup();
            popupElement.hidden = true;
        }
    }
    let isLoadingMedia = false;
    let hasLoadedMedia = false;
    function clearMedia() {
        const qualityContainer = getPopupElement("quality-container");
        qualityContainer.innerHTML = "";

        isLoadingMedia = false;
        hasLoadedMedia = false;
    }
    async function reloadMedia() {
        console.log("[YoutubeDL] Hot reloading...");

        const loadingBarSpan = getPopupElement("loading > span");
        loadingBarSpan.textContent = "Reloading...";

        togglePopupLoading(true);
        clearMedia();

        await fetchPageInformation();
        await loadMedia();

        loadingBarSpan.textContent = "Loading...";
    }
    async function loadMedia() {
        if (isLoadingMedia || hasLoadedMedia) return;
        isLoadingMedia = true;

        function fail() {
            isLoadingMedia = false;
            console.error("[YoutubeDL] Failed fetching media.");
        }

        if (!isLoadingMedia) {togglePopup(); return; };

        const request = await getMediaInformation();
        if (request.status != 'ok') { fail(); return; }

        try {
            await loadMediaFromLinks(request);

            hasLoadedMedia = true;
            togglePopupLoading(false);
        } catch (error) {
            console.error("[YoutubeDL] Failed fetching media content: ", error);
            hasLoadedMedia = false;
        }
    }
    // Getters
    function getPopupElement(element) {
        return document.querySelector(`#youtubeDL-${element}`);
    }
    // Loading and injection
    function togglePopupLoading(loading) {
        const loadingBar = getPopupElement("loading");
        const qualityContainer = getPopupElement("quality");

        loadingBar.hidden = !loading;
        qualityContainer.hidden = loading;
    }
    function injectPopup() {
        /*<div id="youtubeDL-popup-bg" class="shown">
            
        </div>*/
        popupElement = document.createElement("div");
        popupElement.id = "youtubeDL-popup-bg";

        const revisedHTML = popupHTML.replaceAll('{asset}', githubAssetEndpoint);
        popupElement.innerHTML = revisedHTML;
        
        document.body.appendChild(popupElement);

        togglePopupLoading(true);
        createButtonConnections();
        popupElement.hidden = true;
    }
    let hideTimeout;
    let waitingReload = false;
    function togglePopup() {
        popupElement.classList.toggle("shown");

        if (waitingReload) {reloadMedia(); waitingReload = false;}
        else loadMedia();

        // Avoid overlap
        if (popupElement.hidden) {
            clearTimeout(hideTimeout);

            hideTimeout = setTimeout(() => {
                popupElement.hidden = false;
            }, 200);
        };
    }
    // Button
    let injectedShorts = [];
    function injectDownloadButton() {
        let targets = [];
        let style;

        const onShorts = (videoInformation.type == 'shorts');
        
        if (onShorts) {
            // Button for shorts
            const playerControls = document.querySelectorAll('ytd-shorts-player-controls');

            targets = playerControls;
            style = "margin-bottom: 16px; transform: translateY(-15%); z-index: 999; pointer-events: auto;"
        } else {
            // Button for embed and normal player
            targets.push(document.querySelector(".ytp-left-controls"));
            style = "margin-top: 4px; transform: translateY(5%); padding-left: 4px;";
        }

        targets.forEach((target) => {
            if (injectedShorts.includes(target)) return;

            const downloadButton = document.createElement("button");
            downloadButton.classList.add("ytp-button");
            downloadButton.innerHTML = `<img src="${getAsset("YoutubeDL.png")}" style="${style}" width="36" height="36">`;
    
            downloadButton.id = 'youtubeDL-download'
            downloadButton.setAttribute('data-title-no-tooltip', 'YoutubeDL');
            downloadButton.setAttribute('aria-keyshortcuts', 'SHIFT+d');
            downloadButton.setAttribute('aria-label', 'Next keyboard shortcut SHIFT+d');
            downloadButton.setAttribute('data-duration', '');
            downloadButton.setAttribute('data-preview', '');
            downloadButton.setAttribute('data-tooltip-text', '');
            downloadButton.setAttribute('href', '');
            downloadButton.setAttribute('title', 'Download Video');
    
            downloadButton.addEventListener("click", (event) => {
                if (popupElement.hidden) {
                    popupElement.hidden = false;

                    togglePopup();
                }
            });
    
            const chapterContainer = target.querySelector('.ytp-chapter-container');

            if (onShorts) {
                target.insertBefore(downloadButton, target.children[1])
                injectedShorts.push(target);
            } else {
                if (chapterContainer) {
                    downloadButton.style = "overflow: visible; padding-right: 6px; padding-left: 1px;";
                    target.insertBefore(downloadButton, chapterContainer);
                }
                else target.appendChild(downloadButton);
            }
        });
    }

    // Styles
    async function loadCSS(url) {
        return new Promise((resolve, reject) => {
            GM.xmlHttpRequest({
                method: 'GET',
                url: url,
                onload: function(response) {
                    if (response.status === 200) {
                        const style = document.createElement('style');
                        style.innerHTML = response.responseText;
                        document.head.appendChild(style);
                        resolve();
                    } else {
                        reject(new Error('Failed to load CSS'));
                    }
                }
            });
        });
    }
    function getAsset(filename) {
        return `${githubAssetEndpoint}${filename}`;
    }
    let stylesInjected = false;
    async function injectStyles() {
        if (stylesInjected) return;
        stylesInjected = true;

        const asset = getAsset("youtubeDL.css");
        await loadCSS(asset);
    }

    // Buttons
    function createButtonConnections() {
        const closeButton = popupElement.querySelector("#youtubeDL-close");

        closeButton.addEventListener('click', (event) => {
            try {
                togglePopup();
                
                setTimeout(() => {
                    popupElement.hidden = true;
                }, 200);
            } catch (error) {console.error(error);}
        });
    }

    // Main page injection
    async function injectAll() {
        if (preinjected) return;
        preinjected = true;

        console.log("[YoutubeDL] Initializing downloader...");
        try {
            await fetchPageInformation();
        } catch (error) {
            isLoadingMedia = false;
            console.error("[YoutubeDL] Failed fetching page information: ", error);
        }

        console.log("[YoutubeDL] Loading custom styles...");
        await injectStyles();

        console.log("[YoutubeDL] Loading popup...");
        injectPopup();

        console.log("[YoutubeDL] Loading button...");
        injectDownloadButton();

        console.log("[YoutubeDL] Setting theme... DARK:", isDarkMode());
        if (!isDarkMode()) toggleLightClass("#youtubeDL-popup");
    }

    let preinjected = false;
    function shouldInject() {
        const targetElement = "#ytd-player";
        const videoPlayer = document.querySelector(targetElement);
        
        if (videoPlayer != null) {
            if (!preinjected) return true;

            const popupBackgroundElement = document.querySelector("#youtubeDL-popup-bg");
            return popupBackgroundElement != null;
        }
        
        return false;
    }

    function updateVideoInformation() {
        videoInformation = getVideoInformation(window.location.href);
    }
    function initialize() {
        updateVideoInformation();
        if (!videoInformation.type) return;
        
        console.log("[YoutubeDL] Loading... // (real)coloride - 2023");

        // Emebds: wait for user to press play
        const isEmbed = (videoInformation.type == 'embed');
        if (isEmbed) {
            const player = document.querySelector("#player");

            player.addEventListener("click", async(event) => {
                await injectAll();
            });
        } else {
            let injectionCheckInterval;
            injectionCheckInterval = setInterval(async() => {
                if (shouldInject())
                    try {
                        clearInterval(injectionCheckInterval);
                        await injectAll();
                    } catch (error) {
                        console.error("[YoutubeDL] ERROR: ", error);
                    }
            }, 600);
        }
    }
    
    initialize();

    // Hot reswap 
    let loadedUrl = window.location.href;
    async function checkUrlChange() {
        const currentUrl = window.location.href;
        
        if (currentUrl != loadedUrl) {
            console.log("[YoutubeDL] Detected URL Change");

            loadedUrl = currentUrl;

            updateVideoInformation();

            if (!videoInformation.type) return;

            waitingReload = true;
            await injectAll();

            if (videoInformation.type == 'shorts') injectDownloadButton();
        }
    }

    setInterval(checkUrlChange, 500);
    window.onhashchange = checkUrlChange;
})();