Greasy Fork

Youtube HD premium fixed

Select a youtube resolution and resize the player. Enables premium when possible. Tested on the newest beta premium-only UI.

目前为 2024-06-17 提交的版本。查看 最新版本

// ==UserScript==
// @name          Youtube HD premium fixed
// @icon          data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAAAXNSR0IArs4c6QAAAqhJREFUaEPtmc9rE0EUxz+DjSh6UAQRxP4F9uhBRKjipef+FwqtoZdYEk3U4jGn0FJ6KrQnj6X0EKVKKIi9tAotPZSCYilFoq0/sK1Z92V329XGENiZSRZ2LtllZ9+8z/e9ncy8UcS8qZj7TwLQ7ggmEUgiEFGB/6aQAxeBq8Al4GxonDPAydD9+dB1qkFfefy9iZ9fgRrwC/jh96v6vz+Bj8B7BduNbBwDcOA6UABuAyciCqTr9d/ACxf0oYI3YaOHAA71KfWpq8QDF6BTP27H9/GRArk+ctSBZ0BGl2SG7YwoyB4COF66lDtY+X/1EPVvKXhVTxUHKsANw6rpNl9RcFM50A1sxEj9QAiJQrcA9LvT5XPd8liy1y8Ad4GSpQF1D3NPAO4DRd2WLdlL6wUYH4dKBSYnLfmPZoDZWejrg/l5GByE5WXTIIYAxO1aDaamYGgIthsuY3TAGQQI3KtWoVCAUgkODnQ4HbZhASAYbnUV0mmYm9MJYREgcHtmxvs+1td1gLQBQNze24OxMchmYXc3CkibAOQDl6k2k4GtrZgBLC56KbSwEMXx4F2LEdjchHweJia8KVZPswCwvw+jo5DLwc6OHrePrBgGKJdhYABWVnQ7bjiF1ta8OV+WFmab5ghMT8PSEhSL3lRpvmkGSKVAct5eqwPEfkMT+y3lZeBDbDf1kq6xLqv4AL3AyxhFQUoqvQpeh2ujI+46cdjeBBJppL9Li34UBCYP5Do4ErKIeiLV82PF3UAPB64Bj4E7biW4K5JO+l6WvajUbqW8/jZsttkBxwWgB7gCnPZfCg4z5P6UH6lzTfyUgxGp7ctBRdBkBxNsjiWXv4Seyd93+DDkG/AJeKfgc6NxOvUcoOXYJQAtS2WoYxIBQ8K2bDaJQMtSGer4B8aT1sve/dr7AAAAAElFTkSuQmCC
// @author        ElectroKnight22
// @namespace     electroknight22_youtube_hd_namespace
// @description   Select a youtube resolution and resize the player. Enables premium when possible. Tested on the newest beta premium-only UI.
// @version       2024.06.15
// @match         https://*.youtube.com/*
// @noframes
// @grant         GM.getValue
// @grant         GM.setValue
// @license       MIT

// ==/UserScript==

// The video will only resize when in theater mode on the main youtube website.
// By default only runs on youtube website, not players embedded on other websites, but there is experimental support for embeds.
// To enable experimental support for embedded players outside of YouTube website, do the following steps:
//   add  " @include * "  to the script metadata
//   remove  " @noframes "  from the script metadata

// 2024.01.17
// Fix issue with user script managers that don't define GM

// 2024.01.14
// Partially fix auto theater mode again after more youtube changes
// Note that if you want to turn theater mode back off after using auto theater you have to click the button a few times. This is a known issue that hasn't been fixed yet.

(function() {
    "use strict";

    // --- SETTINGS -------

    // PLEASE NOTE:
    // Settings will be saved the first time the script is loaded so that your changes aren't undone by an update.
    // If you want to make adjustments, please set "overwriteStoredSettings" to true.
    // Otherwise, your settings changes will NOT have an effect because it will use the saved settings.
    // After the script has next been run by loading a video with "overwriteStoredSettings" as true, your settings will be updated.
    // Then after that, you can set it to false again to prevent your settings from being changed by an update.

    let settings = {
        // Target Resolution to always set to. If not available, the next best resolution will be used.
        changeResolution: true,
        preferPremium: true,
        targetRes: "hd2160",
        // Choices for targetRes are currently:
        //   "highres" >= ( 8K / 4320p / QUHD  )
        //   "hd2880"   = ( 5K / 2880p /  UHD+ )
        //   "hd2160"   = ( 4K / 2160p /  UHD  )
        //   "hd1440"   = (      1440p /  QHD  )
        //   "hd1080"   = (      1080p /  FHD  )
        //   "hd720"    = (       720p /   HD  )
        //   "large"    = (       480p         )
        //   "medium"   = (       360p         )
        //   "small"    = (       240p         )
        //   "tiny"     = (       144p         )

        // Target Resolution for high frame rate (60 fps) videos
        // If null, it is the same as targetRes
        highFramerateTargetRes: null,

        // If changePlayerSize is true, then the video's size will be changed on the page
        //   instead of using YouTube's default (if theater mode is enabled).
        // If useCustomSize is false, then the player will be resized to try to match the target resolution.
        //   If true, then it will use the customHeight variables (theater mode is always full page width).
        changePlayerSize: false,
        useCustomSize: false,
        customHeight: 600,

        // If autoTheater is true, each video page opened will default to theater mode.
        // This means the video will always be resized immediately if you are changing the size.
        // NOTE: YouTube will not always allow theater mode immediately, the page must be fully loaded before the theater can be set.
        autoTheater: false,

        // If flushBuffer is false, then the first second or so of the video may not always be the desired resolution.
        //   If true, then the entire video will be guaranteed to be the target resolution, but there may be
        //   a very small additional delay before the video starts if the buffer needs to be flushed.
        flushBuffer: true,

        // Setting cookies can allow some operations to perform faster or without a delay (e.g. theater mode)
        // Some people don't like setting cookies, so this is false by default (which is the same as old behavior)
        allowCookies: false,

        // Tries to set the resolution as early as possible.
        // This might cause issues on YouTube polymer layout, so disable if videos fail to load.
        // If videos load fine, leave as true or resolution may fail to set.
        setResolutionEarly: true,

        // Enables a temporary workaround for an issue where users can get the wrong YouTube error screen
        // (YouTube has two of them for some reason and changing to theater mode moves the wrong one to the front)
        // Try disabling if you can't interact with the video or you think you are missing an error message.
        enableErrorScreenWorkaround: true,

        // Use the iframe API to set the resolution if possible. Otherwise uses simulated mouse clicks.
        useAPI: true,

        // Overwrite stored settings with the settings coded into the script, to apply changes.
        // Set and keep as true to have settings behave like before, where you can just edit the settings here to change them.
        overwriteStoredSettings: true
    };

    // --------------------

    // --- GLOBALS --------

    const DEBUG = false;

    // Possible resolution choices (in decreasing order, i.e. highres is the best):
    const resolutions = ['highres', 'hd2880', 'hd2160', 'hd1440', 'hd1080', 'hd720', 'large', 'medium', 'small', 'tiny'];
    // YouTube has to be at least 480x270 for the player UI
    const heights = [4320, 2880, 2160, 1440, 1080, 720, 480, 360, 240, 144];

    let doc = document, win = window;

    // ID of the most recently played video
    let recentVideo = "";

    let foundHFR = false;

    let setHeight = 0;

    // --------------------

    function debugLog(message) {
        if (DEBUG) {
            console.log("YTHD | " + message);
        }
    }

    // --------------------

    // Used only for compatibility with web extensions version of Greasemonkey
    function unwrapElement(el) {
        if (el && el.wrappedJSObject) {
            return el.wrappedJSObject;
        }
        return el;
    }

    // --------------------

    // Get video ID from the currently loaded video (which might be different than currently loaded page)
    function getVideoIDFromURL(ytPlayer) {
        const idMatch = /(?:v=)([\w\-]+)/;
        let id = "ERROR: idMatch failed; YouTube changed something";
        let matches = idMatch.exec(ytPlayer.getVideoUrl());
        if (matches) {
            id = matches[1];
        }
        return id;
    }

    // --------------------

    // Attempt to set the video resolution to desired quality or the next best quality
    function setResolution(ytPlayer, resolutionList) {
        const currentQuality = ytPlayer.getPlaybackQuality();
        if (currentQuality === "unknown") {
            setTimeout(() => setResolution(ytPlayer, resolutionList), 100);
            return;
        }

        let res = settings.targetRes;

        if (settings.highFramerateTargetRes && foundHFR) {
            res = settings.highFramerateTargetRes;
        }

        // YouTube doesn't return "auto" for auto, so set to make sure that auto is not set by setting
        //   even when already at target res or above, but do so without removing the buffer for this quality
        if (resolutionList.indexOf(res) < resolutionList.indexOf(currentQuality)) {
            const end = resolutionList.length - 1;
            let nextBestIndex = Math.max(resolutionList.indexOf(res), 0);
            let ytResolutions = ytPlayer.getAvailableQualityLevels();
            debugLog("Available Resolutions: " + ytResolutions.join(", "));

            while ((ytResolutions.indexOf(resolutionList[nextBestIndex]) === -1) && nextBestIndex < end) {
                ++nextBestIndex;
            }

            if (settings.flushBuffer && currentQuality !== resolutionList[nextBestIndex]) {
                let id = getVideoIDFromURL(ytPlayer);
                if (id.indexOf("ERROR") === -1) {
                    let pos = ytPlayer.getCurrentTime();
                    ytPlayer.loadVideoById(id, pos, resolutionList[nextBestIndex]);
                }

                debugLog("ID: " + id);
            }
            res = resolutionList[nextBestIndex];
        }

        let shouldPremium = settings.preferPremium && ytPlayer.getAvailableQualityData().some(q => q.quality == res && q.qualityLabel.includes("Premium") && q.isPlayable);
        var useButtons = !settings.useAPI || shouldPremium;

        if (useButtons) {
            let resLabel = heights[resolutionList.indexOf(res)];
            if (shouldPremium) {
                resLabel = ytPlayer.getAvailableQualityData().find(q => q.quality == res && q.qualityLabel.includes("Premium")).qualityLabel;
            }

            let settingsButton = doc.querySelector(".ytp-settings-button:not(#ScaleBtn)");
            unwrapElement(settingsButton).click();

            let qualityMenuButton = document.evaluate('.//*[contains(text(),"Quality")]/ancestor-or-self::*[@class="ytp-menuitem-label"]', ytPlayer, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
            unwrapElement(qualityMenuButton).click();

            let qualityButton = document.evaluate('.//*[contains(text(),"' + resLabel + '") and not(@class)]/ancestor::*[@class="ytp-menuitem"]', ytPlayer, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
            unwrapElement(qualityButton).click();

            debugLog("(Buttons) Resolution Set To: " + res);
            return;
        }

        if (settings.useAPI) {
            if (ytPlayer.setPlaybackQualityRange !== undefined) {
                ytPlayer.setPlaybackQualityRange(res);
            }
            ytPlayer.setPlaybackQuality(res);
            debugLog("(API) Resolution Set To: " + res);
        }
    }

    // --------------------

    // Set resolution, but only when API is ready (it should normally already be ready)
    function setResOnReady(ytPlayer, resolutionList) {
        if (settings.useAPI && ytPlayer.getPlaybackQuality === undefined) {
            win.setTimeout(setResOnReady, 100, ytPlayer, resolutionList);
        } else {
            let framerateUpdate = false;
            if (settings.highFramerateTargetRes) {
                let features = ytPlayer.getVideoData().video_quality_features;
                if (features) {
                    let isHFR = features.includes("hfr");
                    framerateUpdate = isHFR && !foundHFR;
                    foundHFR = isHFR;
                }
            }

            let curVid = getVideoIDFromURL(ytPlayer);
            if ((curVid !== recentVideo) || framerateUpdate) {
                recentVideo = curVid;
                setResolution(ytPlayer, resolutionList);

                let storedQuality = localStorage.getItem("yt-player-quality");
                if (!storedQuality || storedQuality.indexOf(settings.targetRes) === -1) {
                    let tc = Date.now(), te = tc + 2592000000;
                    localStorage.setItem("yt-player-quality", "{\"data\":\"" + settings.targetRes + "\",\"expiration\":" + te + ",\"creation\":" + tc + "}");
                }
            }
        }
    }

    // --------------------

    function setTheaterMode(ytPlayer) {
        debugLog("Setting Theater Mode");

        if (win.location.href.indexOf("/watch") !== -1) {
            let pageManager = unwrapElement(doc.getElementsByTagName("ytd-watch-flexy")[0]);

            if (pageManager && !pageManager.hasAttribute("theater")) {
                if (settings.enableErrorScreenWorkaround) {
                    const styleContent = "#error-screen { z-index: 42 !important } .ytp-error { display: none !important }";

                    let errorStyle = doc.getElementById("ythdErrorWorkaroundStyleSheet");
                    if (!errorStyle) {
                        errorStyle = doc.createElement("style");
                        errorStyle.type = "text/css";
                        errorStyle.id = "ythdStyleSheet";
                        errorStyle.innerHTML = styleContent;
                        doc.head.appendChild(errorStyle);
                    } else {
                        errorStyle.innerHTML = styleContent;
                    }
                }

                try {
                    pageManager.setTheaterModeRequested(true);
                    pageManager.updateTheaterModeState_(true);
                    pageManager.onTheaterReduxValueUpdate(true);
                    pageManager.setPlayerTheaterMode_();
                    pageManager.dispatchEvent(new CustomEvent("yt-set-theater-mode-enabled", { detail: { enabled: true }, bubbles: true, cancelable: false }));
                } catch { }

                let theaterButton;
                for (let i = 0; i < 3 && !pageManager.theaterValue; ++i) {
                    debugLog("Clicking theater button to attempt to notify redux state");
                    let theaterButton = theaterButton || unwrapElement(doc.getElementsByClassName("ytp-size-button")[0]);
                    theaterButton.click();
                }
            }
        }
    }

    // --------------------

    function computeAndSetPlayerSize() {
        let height = settings.customHeight;
        if (!settings.useCustomSize) {
            // don't include YouTube search bar as part of the space the video can try to fit in
            let heightOffsetEl = doc.getElementById("masthead");
            let mastheadContainerEl = doc.getElementById("masthead-container");
            let mastheadHeight = 50, mastheadPadding = 16;
            if (heightOffsetEl && mastheadContainerEl) {
                mastheadHeight = parseInt(win.getComputedStyle(heightOffsetEl).height, 10);
                mastheadPadding = parseInt(win.getComputedStyle(mastheadContainerEl).paddingBottom, 10) * 2;
            }

            let i = Math.max(resolutions.indexOf(settings.targetRes), 0);
            height = Math.min(heights[i], win.innerHeight - (mastheadHeight + mastheadPadding));
            height = Math.max(height, 270);
        }

        resizePlayer(height);
    }

    // --------------------

    // resize the player
    function resizePlayer(height) {
        debugLog("Setting video player size");

        if (setHeight === height) {
            debugLog("Player size already set");
            return;
        }

        let styleContent = "\
ytd-watch-flexy[theater]:not([fullscreen]) #player-theater-container.style-scope, \
ytd-watch-flexy[theater]:not([fullscreen]) #player-wide-container.style-scope, \
ytd-watch-flexy[theater]:not([fullscreen]) #full-bleed-container.style-scope { \
min-height: " + height + "px !important; max-height: none !important; height: " + height + "px !important }";

        let ythdStyle = doc.getElementById("ythdStyleSheet");
        if (!ythdStyle) {
            ythdStyle = doc.createElement("style");
            ythdStyle.type = "text/css";
            ythdStyle.id = "ythdStyleSheet";
            ythdStyle.innerHTML = styleContent;
            doc.head.appendChild(ythdStyle);
        } else {
            ythdStyle.innerHTML = styleContent;
        }

        setHeight = height;

        win.dispatchEvent(new Event("resize"));
    }

    // --- MAIN -----------

    function main() {
        let ytPlayer = doc.getElementById("movie_player") || doc.getElementsByClassName("html5-video-player")[0];
        let ytPlayerUnwrapped = unwrapElement(ytPlayer);

        if (settings.autoTheater && ytPlayerUnwrapped) {
            if (settings.allowCookies && doc.cookie.indexOf("wide=1") === -1) {
                doc.cookie = "wide=1; domain=.youtube.com";
            }

            setTheaterMode(ytPlayerUnwrapped);
        }

        if (settings.changePlayerSize && win.location.host.indexOf("youtube.com") !== -1 && win.location.host.indexOf("gaming.") === -1) {
            computeAndSetPlayerSize();
            window.addEventListener("resize", computeAndSetPlayerSize, true);
        }

        if (settings.changeResolution && settings.setResolutionEarly && ytPlayerUnwrapped) {
            setResOnReady(ytPlayerUnwrapped, resolutions);
        }

        if (settings.changeResolution || settings.autoTheater) {
            win.addEventListener("loadstart", function(e) {
                if (!(e.target instanceof win.HTMLMediaElement)) {
                    return;
                }

                ytPlayer = doc.getElementById("movie_player") || doc.getElementsByClassName("html5-video-player")[0];
                ytPlayerUnwrapped = unwrapElement(ytPlayer);
                if (ytPlayerUnwrapped) {
                    debugLog("Loaded new video");
                    if (settings.changeResolution) {
                        setResOnReady(ytPlayerUnwrapped, resolutions);
                    }
                    if (settings.autoTheater) {
                        setTheaterMode(ytPlayerUnwrapped);
                    }
                }
            }, true);
        }

        // This will eventually be changed to use the "once" option, but I want to keep a large range of browser support.
        win.removeEventListener("yt-navigate-finish", main, true);
    }

    async function applySettings() {
        if (typeof GM != 'undefined' && GM.getValue && GM.setValue) {
            let settingsSaved = await GM.getValue("SettingsSaved");

            if (settings.overwriteStoredSettings || !settingsSaved) {
                Object.entries(settings).forEach(([k, v]) => GM.setValue(k, v));

                await GM.setValue("SettingsSaved", true);
            } else {
                await Promise.all(
                    Object.keys(settings).map(k => { let newval = GM.getValue(k); return newval.then(v => [k, v]); })
                ).then((c) => c.forEach(([nk, nv]) => {
                    if (settings[nk] !== null && nk !== "overwriteStoredSettings") {
                        settings[nk] = nv;
                    }
                }));
            }

            debugLog(Object.entries(settings).map(([k, v]) => k + " | " + v).join(", "));
        }
    }

    applySettings().then(() => {
        main();
        // YouTube doesn't load the page immediately in the new version so you can watch before waiting for page load
        // But we can only set the resolution until the page finishes loading
        win.addEventListener("yt-navigate-finish", main, true);
    });
})();