YouTube Sizer

Resizes the YouTube player to different sizes

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name YouTube Sizer
// @author John Burt
// @namespace namespace_runio
// @version 2.04
// @description Resizes the YouTube player to different sizes
// @match https://www.youtube.com/*
// @match https://www.youtu.be/*
// @exclude https://www.youtube.com/tv*
// @exclude https://www.youtube.com/embed/*
// @exclude https://www.youtube.com/live_chat*
// @exclude https://www.youtube.com/shorts/*
// @run-at document-end
// @grant GM_setValue
// @grant GM_getValue
// @supportURL https://greasyfork.org/scripts/421396-youtube-sizer
// @icon https://i.imgur.com/9haPE5X.png
// @license GPL-3.0+
// @noframes
// ==/UserScript==
(function() {
    "use strict";

    if (window.frameElement) {
        throw new Error("Stopped JavaScript.");
    }
    //==================================================================
    // Storage helpers
    //==================================================================
    function setPref(preference, new_value) {
        GM_setValue(preference, new_value);
    }

    function getPref(preference) {
        return GM_getValue(preference);
    }

    function initPref(preference, new_value) {
        let value = getPref(preference);
        if (value === null || value === undefined) {
            setPref(preference, new_value);
            value = new_value;
        } else if (typeof value === "number" && isNaN(value)) {
            console.warn(`[YT Sizer] Stored preference "${preference}" was NaN — resetting to default.`);
            setPref(preference, new_value);
            value = new_value;
        }
        return value;
    }
    //==================================================================
    // Preferences
    //==================================================================
    initPref("yt-width", 1280);
    initPref("yt-resize", false);

    function getMaxWidth() {
        const stored = getPref("yt-width") ?? 854;
        return Math.max(854, Math.min(stored, window.innerWidth));
    }

    function setMaxWidth(value) {
        const clamped = Math.max(854, Math.min(value, window.innerWidth));
        setPref("yt-width", clamped);
    }
    //==================================================================
    // Global constants / state
    //==================================================================
    var shortcutKey = "R";
    var ytresizeCss = `ytd-watch-flexy[fullscreen] #ytp-resize-button { display:none !important; }`;
    let currentResizeObserver = null;
    let keyListenersAdded = false;
    let sizeObserverInstance = null;
    let resizeDebounceTimer = null;
    let checkURLDebounceTimer = null;
    let lastCheckedPath = "";
    //==================================================================
    // Boot
    //==================================================================
    window.addEventListener("load", () => {
        const observer = new MutationObserver(checkURL);
        const titleEl = document.querySelector("title");
        if (titleEl) {
            observer.observe(titleEl, {
                attributes: true,
                characterData: true,
                childList: true
            });
            checkURL();
        }
    }, {
        once: true
    });
    //==================================================================
    // checkURL
    //==================================================================
    function checkURL() {
        clearTimeout(checkURLDebounceTimer);
        checkURLDebounceTimer = setTimeout(() => {
            const path = window.location.pathname;
            if (!path.includes("watch")) return;
            if (path === lastCheckedPath) return;
            lastCheckedPath = path;
            waitElement("#player-container-outer").then((elm) => {
                if (!window.location.pathname.includes("watch")) return;
                if (!document.getElementById("yt-css")) {
                    startMethods();
                } else {
                    controlResize();
                    createResize();
                    viewObserver();
                }
            }).catch(() => {});
        }, 150);
    }
    //==================================================================
    // waitElement
    //==================================================================
    function waitElement(selector, timeoutMs = 5000) {
        return new Promise((resolve, reject) => {
            let element = document.querySelector(selector);
            if (element) return resolve(element);
            const observer = new MutationObserver(() => {
                element = document.querySelector(selector);
                if (element) {
                    clearTimeout(timer);
                    observer.disconnect();
                    resolve(element);
                }
            });
            const timer = setTimeout(() => {
                observer.disconnect();
                reject(new Error(`waitElement: "${selector}" not found within ${timeoutMs} ms`));
            }, timeoutMs);
            observer.observe(document.body, {
                childList: true,
                subtree: true
            });
        });
    }
    //==================================================================
    // startMethods
    //==================================================================
    function startMethods() {
        sizeObserver();
        if (getPref("yt-resize") === true) {
            addCss(`#primary.ytd-watch-flexy:not([theater]):not([fullscreen]) { max-width: ${getMaxWidth()}px !important; }`, "small-player");
            addCss(`ytd-watch-flexy[flexy]:not([full-bleed-player][full-bleed-no-max-width-columns]) #columns.ytd-watch-flexy {max-width: 100% !important;}`, "max-player");
        }
        addCss(ytresizeCss, "yt-css");
        controlResize();
        viewObserver();
    }
    //==================================================================
    // Helpers
    //==================================================================
    function isCentered(element1, element2) {
        const box1 = element1.getBoundingClientRect();
        const box2 = element2.getBoundingClientRect();
        const center1 = {
            x: box1.left + box1.width / 2,
            y: box1.top + box1.height / 2
        };
        const center2 = {
            x: box2.left + box2.width / 2,
            y: box2.top + box2.height / 2
        };
        return Math.abs(center1.x - center2.x) <= 1 &&
            Math.abs(center1.y - center2.y) <= 1;
    }

    function addCss(cssString, id) {
        const css = document.createElement("style");
        css.type = "text/css";
        css.id = id;
        css.textContent = cssString;
        document.head.appendChild(css);
    }
    //==================================================================
    // createResize
    //==================================================================
    function createResize() {
        const element = document.querySelector("ytd-app");
        if (!element) return;
        element.dispatchEvent(new CustomEvent("yt-action", {
            bubbles: true,
            cancelable: true,
            composed: true,
            detail: {
                actionName: "yt-window-resized",
                disableBroadcast: false,
                optionalAction: true,
                returnValue: []
            }
        }));
    }
    //==================================================================
    // viewObserver
    //==================================================================
    function viewObserver() {
        let movie_player = document.querySelector(".html5-video-player");
        let video = document.querySelector("video");
        if (!movie_player || !video) return;
        if (currentResizeObserver) currentResizeObserver.disconnect();
        const resizeObserver = new ResizeObserver((entries) => {
            window.requestAnimationFrame(() => {
                if (!Array.isArray(entries) || !entries.length) return;
                const currentlyCentered = isCentered(video, movie_player);
                if (!currentlyCentered) {
                    clearTimeout(resizeDebounceTimer);
                    resizeDebounceTimer = setTimeout(createResize, 100);
                }
            });
        });
        currentResizeObserver = resizeObserver;
        resizeObserver.observe(video);
    }
    //==================================================================
    // sizeObserver
    //==================================================================
    function sizeObserver() {
        if (sizeObserverInstance) return;
        const config = {
            attributes: true,
            childList: true,
            subtree: true,
            characterData: true
        };
        const callback = function(mutationsList) {
            for (let mutation of mutationsList) {
                const removedHasSmall = Array.from(mutation.removedNodes).some(
                    node => node.nodeType === Node.ELEMENT_NODE && node.id === "small-player"
                );
                const addedHasSmall = Array.from(mutation.addedNodes).some(
                    node => node.nodeType === Node.ELEMENT_NODE && node.id === "small-player"
                );
                if (removedHasSmall) {
                    setPref("yt-resize", false);
                    controlResize();
                    createResize();
                } else if (addedHasSmall) {
                    setPref("yt-resize", true);
                    controlResize();
                    createResize();
                }
                if (mutation.target && mutation.target.id === "small-player") {
                    setPref("yt-width", getMaxWidth());
                    createResize();
                } else if (mutation.target && mutation.target.parentNode &&
                    mutation.target.parentNode.id === "small-player") {
                    setPref("yt-width", getMaxWidth());
                    createResize();
                }
            }
        };
        sizeObserverInstance = new MutationObserver(callback);
        sizeObserverInstance.observe(document.head, config);
    }
    //==================================================================
    // Tooltip — R is now inside a real bordered box
    //==================================================================
    let resizeTooltipHideTimeout = null;

    function showResizeButtonTooltip(btn, show = true) {
        if (resizeTooltipHideTimeout) {
            clearTimeout(resizeTooltipHideTimeout);
            resizeTooltipHideTimeout = null;
        }

        const buttonRect = btn.getBoundingClientRect();
        const tooltipHorizontalCenter = buttonRect.left + buttonRect.width / 2;

        const tooltip = document.getElementById("ytd-resize-tt") || createTooltip();
        const tooltipText = tooltip.querySelector("#ytd-resize-tt-text");
        const tooltipKey = tooltip.querySelector("#ytd-resize-tt-key");

        if (show) {
            const label = btn.getAttribute("aria-label") || btn.getAttribute("title") || "Resize";
            tooltipText.textContent = label.replace(/\s*\[[^\]]+\]\s*$/, "");
            tooltipKey.textContent = shortcutKey;

            tooltip.style.removeProperty("display");
            tooltip.style.visibility = "hidden";
            tooltip.style.transition = "none";
            void tooltip.offsetHeight;

            const tooltipRect = tooltip.getBoundingClientRect();

            // --- FIX: anchor to stable YouTube UI baseline ---
            const controlsBar =
                  document.querySelector(".ytp-chrome-bottom") ||
                  document.querySelector(".ytp-progress-bar-container");
            let baseTop;
            if (controlsBar) {
                const controlsRect = controlsBar.getBoundingClientRect();
                baseTop = controlsRect.top;
            } else {
                baseTop = buttonRect.top;
            }

            const gap = 11.5;
            const tooltipTop = baseTop - tooltipRect.height - gap;

            tooltip.style.top = `${tooltipTop}px`;
            tooltip.style.left = `${tooltipHorizontalCenter - tooltipRect.width / 2}px`;
            tooltip.style.visibility = "visible";
            btn.removeAttribute("title");
        } else {
            resizeTooltipHideTimeout = setTimeout(() => {
                tooltip.style.display = "none";
                tooltip.style.visibility = "";
                tooltipText.textContent = "";
                tooltipKey.textContent = "";
                const currentLabel = btn.getAttribute("aria-label");
                if (currentLabel) {
                    btn.setAttribute("title", currentLabel);
                }
                resizeTooltipHideTimeout = null;
            }, 120);
        }

        function createTooltip() {
            const htmlPlayer = document.querySelector(".html5-video-player");
            if (!htmlPlayer) {
                const fallback = document.createElement("div");
                fallback.style.display = "none";
                return fallback;
            }

            const tooltip = document.createElement("div");
            tooltip.id = "ytd-resize-tt";
            tooltip.className = "ytp-tooltip ytp-bottom";
            tooltip.style.position = "fixed";
            tooltip.style.zIndex = "10000";

            const wrapper = document.createElement("div");
            wrapper.className = "ytp-tooltip-text-wrapper ytp-tooltip-bottom-text";

            const tooltipText = document.createElement("span");
            tooltipText.className = "ytp-tooltip-text";
            tooltipText.id = "ytd-resize-tt-text";

            const tooltipKey = document.createElement("span");
            tooltipKey.id = "ytd-resize-tt-key";

            wrapper.appendChild(tooltipText);
            wrapper.appendChild(tooltipKey);
            tooltip.appendChild(wrapper);

            if (!document.getElementById("yt-sizer-tooltip-style")) {
                const style = document.createElement("style");
                style.id = "yt-sizer-tooltip-style";
                style.textContent = `
                #ytd-resize-tt .ytp-tooltip-text-wrapper {
                    display: flex;
                    align-items: center;
                }
                #ytd-resize-tt-key {
                    display: inline-flex;
                    align-items: center;
                    justify-content: center;
                    min-width: 15px;
                    height: 15px;
                    margin-left: 4px;
                    padding: 0 4px;
                    border: 1px solid rgba(255,255,255,0.30);
                    border-radius: 4px;
                    font-size: 12px;
                    font-weight: 500;
                    line-height: 15px;
                    box-sizing: border-box;
                    vertical-align: middle;
                }
            `;
                document.head.appendChild(style);
            }

            htmlPlayer.appendChild(tooltip);
            return tooltip;
        }
    }
    //==================================================================
    // Button setup
    //==================================================================
    function setButton(btn, path) {
        let pathData = {};
        let ariaLabel, titleText;
        if (getPref("yt-resize") !== true) {
            pathData.d = `M 13 17 L 5 9 L 5 17 Z
                          M 23 19
                          L 23 4.98 C 23 3.88 22.1 3 21 3
                          L 3 3 C 1.9 3 1 3.88 1 4.98
                          L 1 19 C 1 20.1 1.9 21 3 21
                          L 21 21 C 22.1 21 23 20.1 23 19
                          L 23 19 Z
                          M 21 19.02 L 3 19.02 L 3 4.97
                          L 21 4.97 L 21 19.02 L 21 19.02 Z`;
            ariaLabel = `Resize mode [${shortcutKey}]`;
            titleText = `Resize mode [${shortcutKey}]`;
        } else {
            pathData.d = `M 19 15 L 19 7 L 11 7 Z M 23 19
                          L 23 4.98 C 23 3.88 22.1 3 21 3
                          L 3 3 C 1.9 3 1 3.88 1 4.98
                          L 1 19 C 1 20.1 1.9 21 3 21
                          L 21 21 C 22.1 21 23 20.1 23 19
                          L 23 19 Z M 21 19.02
                          L 3 19.02 L 3 4.97
                          L 21 4.97 L 21 19.02 L 21 19.02 Z`;
            ariaLabel = `Default view [${shortcutKey}]`;
            titleText = `Default view [${shortcutKey}]`;
        }
        path.setAttribute("d", pathData.d);
        btn.setAttribute("aria-label", ariaLabel);
        btn.setAttribute("title", titleText);
    }

    function createButton(container) {
        const btn = document.createElement("button");
        const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
        const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
        svg.setAttribute("height", "24");
        svg.setAttribute("viewBox", "0 0 24 24");
        svg.setAttribute("width", "24");
        setButton(btn, path);
        path.setAttribute("fill", "white");
        svg.appendChild(path);
        btn.appendChild(svg);
        btn.classList.add("ytp-resize-button", "ytp-button");
        btn.setAttribute("id", "ytp-resize-button");
        btn.setAttribute("data-tooltip-target-id", "ytp-resize-button");
        container.insertBefore(btn, container.lastChild.previousSibling || container.lastChild);
        const showTooltip = (event) => {
            showResizeButtonTooltip(btn, ["mouseover", "focus"].includes(event.type));
        };
        btn.addEventListener("click", (e) => {
            e.stopPropagation();
            e.preventDefault();
            buttonScript();
        }, false);
        btn.addEventListener("mouseover", showTooltip);
        btn.addEventListener("mouseout", showTooltip);
        btn.addEventListener("focus", showTooltip);
        btn.addEventListener("blur", showTooltip);
    }

    function toggleStyle(id, cssTemplate) {
        const styleElement = document.getElementById(id);
        if (styleElement && document.head.contains(styleElement)) {
            document.head.removeChild(styleElement);
        } else {
            addCss(cssTemplate, id);
        }
    }

    function buttonScript() {
        toggleStyle(
            "max-player",
            `ytd-watch-flexy[flexy]:not([full-bleed-player][full-bleed-no-max-width-columns])
        #columns.ytd-watch-flexy { max-width: 100% !important; }`
        );
        toggleStyle(
            "small-player",
            `#primary.ytd-watch-flexy:not([theater]):not([fullscreen]) {
        max-width: ${getMaxWidth()}px !important; }`
        );
    }

    function shortScript() {
        const css = `#primary.ytd-watch-flexy:not([theater]):not([fullscreen]) { max-width: ${getMaxWidth()}px !important; }`;
        let splayer = document.getElementById("small-player");
        if (splayer && document.head.contains(splayer)) {
            splayer.textContent = css;
        } else {
            addCss(css, "small-player");
        }
    }
    //==================================================================
    // Keyboard / wheel handlers
    //==================================================================
    function handleKeydown(e) {
        if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
        if (/^(?:input|textarea|select|button)$/i.test(e.target.tagName)) return;
        if (/(?:contenteditable-root)/i.test(e.target.id)) return;
        const splayer = document.getElementById("small-player");
        if (e.key === shortcutKey.toLowerCase() || e.key === shortcutKey.toUpperCase()) {
            e.stopPropagation();
            e.preventDefault();
            buttonScript();
            return;
        }
        if (document.head.contains(splayer)) {
            if (e.key === "z") {
                e.stopPropagation();
                e.preventDefault();
                setMaxWidth(getMaxWidth() - 20);
                shortScript();
            } else if (e.key === "x") {
                e.stopPropagation();
                e.preventDefault();
                setMaxWidth(getMaxWidth() + 20);
                shortScript();
            }
        }
    }

    function handleWheel(e) {
        const splayer = document.getElementById("small-player");
        if (!document.head.contains(splayer)) return;
        if (e.altKey || e.ctrlKey || e.metaKey) return;
        if (/^(?:input|textarea|select|button)$/i.test(e.target.tagName)) return;
        if (/(?:contenteditable-root)/i.test(e.target.id)) return;
        if (!e.shiftKey) return;
        e.stopPropagation();
        e.preventDefault();
        if (e.deltaY < 0) {
            setMaxWidth(getMaxWidth() + 20);
        } else if (e.deltaY > 0) {
            setMaxWidth(getMaxWidth() - 20);
        }
        shortScript();
    }
    //==================================================================
    // controlResize
    //==================================================================
    function addListenersOnce() {
        if (keyListenersAdded) return;
        document.addEventListener("keydown", handleKeydown, false);
        document.addEventListener("wheel", handleWheel, {
            passive: false
        });
        keyListenersAdded = true;
    }

    function controlResize() {
        const buttonExists = document.getElementById("ytp-resize-button");
        if (!buttonExists) {
            const container = document.querySelector(".ytp-right-controls-right") ||
                document.querySelector(".ytp-right-controls");
            if (container) {
                createButton(container);
                addListenersOnce();
            } else {
                waitElement(".ytp-right-controls-right, .ytp-right-controls").then((container) => {
                    if (!document.getElementById("ytp-resize-button")) {
                        createButton(container);
                    }
                    addListenersOnce();
                }).catch(() => {});
            }
        } else {
            setButton(buttonExists, buttonExists.querySelector("path"));
        }
    }
    //==================================================================
})();