Youtube HD Premium

自動切換到你預先設定的畫質。會優先使用Premium位元率。

// ==UserScript==
// @name                Youtube HD Premium
// @name:zh-TW          Youtube HD Premium
// @name:zh-CN          Youtube HD Premium
// @name:ja             Youtube HD Premium
// @icon                https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @author              ElectroKnight22
// @namespace           electroknight22_youtube_hd_namespace
// @version             2025.09.05.3
// @note                I would prefer semantic versioning but it's a bit too late to change it at this point. Calendar versioning was originally chosen to maintain similarity to the adisib's code.
// @match               *://www.youtube.com/*
// @match               *://m.youtube.com/*
// @match               *://www.youtube-nocookie.com/*
// @exclude             *://www.youtube.com/live_chat*
// @grant               none
// @run-at              document-idle
// @license             MIT
// @description         Automatically switches to your pre-selected resolution. Enables premium when possible.
// @description:zh-TW   自動切換到你預先設定的畫質。會優先使用Premium位元率。
// @description:zh-CN   自动切换到你预先设定的画質。会优先使用Premium比特率。
// @description:ja      自動的に設定した画質に替わります。Premiumのビットレートを優先的に選択します。
// @homepage            https://greasyfork.org/scripts/498145-youtube-hd-premium
// ==/UserScript==

/*jshint esversion: 11 */

(function () {
    'use strict';

    const STORAGE_KEY = 'YTHD_settings';
    const DEFAULT_SETTINGS = {
        targetResolution: 'hd2160',
    };

    const QUALITIES = {
        highres: { p: 4320, label: '8K' },
        hd2160: { p: 2160, label: '4K' },
        hd1440: { p: 1440, label: '1440p' },
        hd1080: { p: 1080, label: '1080p' },
        hd720: { p: 720, label: '720p' },
        large: { p: 480, label: '480p' },
        medium: { p: 360, label: '360p' },
        small: { p: 240, label: '240p' },
        tiny: { p: 144, label: '144p' },
    };

    const PREMIUM_INDICATOR = 'Premium';
    const SVG_NS = 'http://www.w3.org/2000/svg';

    const ICONS = {
        createPinIcon: () => {
            const svg = document.createElementNS(SVG_NS, 'svg');
            svg.setAttribute('viewBox', '0 0 24 24');
            svg.setAttribute('height', '24');
            svg.setAttribute('width', '24');
            const path = document.createElementNS(SVG_NS, 'path');
            path.setAttribute('d', 'M16,12V4H17V2H7V4H8V12L6,14V16H11.5V22H12.5V16H18V14L16,12Z');
            path.setAttribute('fill', 'currentColor');
            svg.appendChild(path);
            return svg;
        },
    };

    const state = {
        userSettings: { ...DEFAULT_SETTINGS },
        moviePlayer: null,
    };

    // --- Core Logic ---
    function resolveOptimalQuality(videoQualityData, targetResolutionString) {
        const availableQualities = [...new Set(videoQualityData.map((q) => q.quality))];
        const targetValue = QUALITIES[targetResolutionString].p;
        const bestQualityString = availableQualities
            .filter((q) => QUALITIES[q] && QUALITIES[q].p <= targetValue)
            .sort((a, b) => QUALITIES[b].p - QUALITIES[a].p)[0];
        if (!bestQualityString) return null;
        let normalCandidate = null,
            premiumCandidate = null;
        for (const quality of videoQualityData) {
            if (quality.quality === bestQualityString && quality.isPlayable) {
                if (quality.qualityLabel?.trim().endsWith(PREMIUM_INDICATOR)) premiumCandidate = quality;
                else normalCandidate = quality;
            }
        }
        return premiumCandidate || normalCandidate;
    }
    function setResolution() {

        try {
            if (!state.moviePlayer || typeof state.moviePlayer.getAvailableQualityData !== 'function') throw new Error('No valid video player found.');
            const videoQualityData = state.moviePlayer.getAvailableQualityData();
            if (!videoQualityData || !videoQualityData.length) throw new Error('Cannot determine available video qualities.');
            const optimalQuality = resolveOptimalQuality(videoQualityData, state.userSettings.targetResolution);
            if (optimalQuality)
                state.moviePlayer.setPlaybackQualityRange(optimalQuality.quality, optimalQuality.quality, optimalQuality.formatId);
        } catch (error) {
            console.error('Error when setting resolution:', error);
        }
    }
    function fallbackGetPlayer() {
        if (window.location.pathname === '/watch') {
            return document.querySelector('#movie_player');
        } else if (window.location.pathname.startsWith('/shorts')) {
            return document.querySelector('#shorts-player');
        }
    }
    function handlePlayerStateChange(playerState) {
        const playerElement = state.moviePlayer ?? fallbackGetPlayer();
        if (!playerElement) return;
        if (playerState === 1 && !playerElement.hasAttribute('YTHD-resolution-set')) {
            playerElement.setAttribute('YTHD-resolution-set', 'true');
            setResolution();
        } else if (playerState === -1 && playerElement.hasAttribute('YTHD-resolution-set')) {
            playerElement.removeAttribute('YTHD-resolution-set');
        }
    }
    function processVideoLoad(event = null) {
        state.moviePlayer = event?.target?.player_ ?? fallbackGetPlayer();
        setResolution();
        const playerElement = document.getElementById('movie_player');
        if (playerElement && !playerElement.hasAttribute('YTHD-listener-added')) {
            playerElement.addEventListener('onStateChange', handlePlayerStateChange);
            playerElement.setAttribute('YTHD-listener-added', 'true');
        }
    }

    // --- UI Logic ---
    function createYTHDHeaderTrigger(titleText) {
        const header = document.createElement('div');
        header.id = 'ythd-header-trigger';
        header.className = 'ytp-panel-header';
        header.style.cursor = 'pointer';
        const title = document.createElement('div');
        title.className = 'ytp-panel-title';
        title.style.display = 'flex';
        title.style.justifyContent = 'space-between';
        title.style.width = '100%';
        title.style.alignItems = 'center';
        title.style.padding = '16px';
        const leftGroup = document.createElement('span');
        leftGroup.style.display = 'flex';
        leftGroup.style.alignItems = 'center';
        leftGroup.style.gap = '8px';
        leftGroup.append(ICONS.createPinIcon(), titleText);
        const rightGroup = document.createElement('span');
        rightGroup.id = 'ythd-header-label';
        rightGroup.textContent = `${QUALITIES[state.userSettings.targetResolution].label} >`;
        title.append(leftGroup, rightGroup);
        header.appendChild(title);
        return header;
    }

    function setupQualityMenuNavigation(qualityPanel) {
        if (qualityPanel.querySelector('#ythd-animation-wrapper')) return;
        const nativeHeader = qualityPanel.querySelector('.ytp-panel-header');
        const settingsPopup = qualityPanel.closest('.ytp-popup.ytp-settings-menu');
        const nativeTitleText = nativeHeader?.querySelector('.ytp-panel-title')?.textContent.trim() || 'Quality';
        if (!nativeHeader || !settingsPopup) return;
        const ythdHeaderTrigger = createYTHDHeaderTrigger(nativeTitleText);
        nativeHeader.after(ythdHeaderTrigger);
        const animationWrapper = document.createElement('div');
        animationWrapper.id = 'ythd-animation-wrapper';
        animationWrapper.style.position = 'relative';
        animationWrapper.style.overflow = 'hidden';
        const originalWrapperChildren = [...qualityPanel.children];
        animationWrapper.append(...originalWrapperChildren);
        qualityPanel.replaceChildren(animationWrapper);
        const animateAndSwap = (contentSetupCallback, isForward) => {
            const animationDuration = 250;
            const oldContent = document.createElement('div');
            oldContent.style.position = 'absolute';
            oldContent.style.width = '100%';
            oldContent.append(...animationWrapper.childNodes);
            const newContent = document.createElement('div');
            newContent.style.position = 'absolute';
            newContent.style.width = '100%';
            contentSetupCallback(newContent);
            const oldFinalX = isForward ? '-100%' : '100%';
            const newInitialX = isForward ? '100%' : '-100%';
            oldContent.style.transform = 'translateX(0)';
            newContent.style.transform = `translateX(${newInitialX})`;
            const oldHeight = animationWrapper.offsetHeight;
            animationWrapper.replaceChildren(oldContent, newContent);
            const newHeight = newContent.offsetHeight;
            animationWrapper.style.height = `${oldHeight}px`;
            requestAnimationFrame(() => {
                animationWrapper.style.transition = `height ${animationDuration}ms linear`;
                oldContent.style.transition = `transform ${animationDuration}ms ease-in-out`;
                newContent.style.transition = `transform ${animationDuration}ms ease-in-out`;
                animationWrapper.style.height = `${newHeight}px`;
                oldContent.style.transform = `translateX(${oldFinalX})`;
                newContent.style.transform = 'translateX(0)';
            });
            setTimeout(() => {
                animationWrapper.replaceChildren(...newContent.childNodes);
                animationWrapper.style.cssText = 'position: relative;';
            }, animationDuration);
        };
        const switchToNativeMenu = () => {
            animateAndSwap((newWrapper) => {
                newWrapper.append(...originalWrapperChildren);
                const restoredTriggerLabel = newWrapper.querySelector('#ythd-header-label');
                if (restoredTriggerLabel) {
                    restoredTriggerLabel.textContent = `${QUALITIES[state.userSettings.targetResolution].label} >`;
                }
            }, false);
        };
        const switchToYTHDMenu = () => {
            animateAndSwap((newWrapper) => {
                const backButton = document.createElement('button');
                backButton.className = 'ytp-panel-back-button ytp-button';
                const title = document.createElement('div');
                title.className = 'ytp-panel-title';
                title.style.display = 'flex';
                title.style.alignItems = 'center';
                title.style.gap = '8px';
                title.append(ICONS.createPinIcon(), nativeTitleText);
                const ythdHeader = document.createElement('div');
                ythdHeader.className = 'ytp-panel-header';
                ythdHeader.append(backButton, title);
                const ythdMenu = document.createElement('div');
                ythdMenu.className = 'ytp-panel-menu';
                Object.entries(QUALITIES).forEach(([key, value]) => {
                    const menuItem = document.createElement('div');
                    menuItem.className = 'ytp-menuitem';
                    menuItem.setAttribute('role', 'menuitemradio');
                    menuItem.setAttribute('aria-checked', (state.userSettings.targetResolution === key).toString());
                    menuItem.dataset.resolutionKey = key;
                    const labelDiv = document.createElement('div');
                    labelDiv.className = 'ytp-menuitem-label';
                    labelDiv.textContent = `${value.p}p ${value.label.includes('K') ? `(${value.label})` : ''}`.trim();
                    menuItem.append(labelDiv);
                    ythdMenu.appendChild(menuItem);
                });
                newWrapper.append(ythdHeader, ythdMenu);
                backButton.addEventListener('click', (event) => {
                    event.stopPropagation();
                    switchToNativeMenu();
                });
                ythdMenu.querySelectorAll('.ytp-menuitem').forEach((item) => {
                    item.addEventListener('click', (event) => {
                        event.stopPropagation();
                        const newResolution = item.dataset.resolutionKey;
                        if (state.userSettings.targetResolution === newResolution) return;

                        ythdMenu.querySelector('[aria-checked="true"]')?.setAttribute('aria-checked', 'false');
                        item.setAttribute('aria-checked', 'true');

                        state.userSettings.targetResolution = newResolution;
                        saveUserSettings();
                        setResolution();
                        document.getElementById('ythd-header-label').textContent = `${QUALITIES[newResolution].label} >`;
                        switchToNativeMenu();
                    });
                });
            }, true);
        };
        animationWrapper.querySelector('#ythd-header-trigger').addEventListener('click', (event) => {
            event.stopPropagation();
            switchToYTHDMenu();
        });
        if (!settingsPopup.dataset.ythdResetObserverAdded) {
            settingsPopup.dataset.ythdResetObserverAdded = 'true';
            const resetObserver = new MutationObserver(() => {
                if (settingsPopup.style.display === 'none' && animationWrapper.querySelector('#ythd-header-trigger') === null) {
                    animationWrapper.replaceChildren(...originalWrapperChildren);
                    const restoredTriggerLabel = animationWrapper.querySelector('#ythd-header-label');
                    if (restoredTriggerLabel) {
                        restoredTriggerLabel.textContent = `${QUALITIES[state.userSettings.targetResolution].label} >`;
                    }
                }
            });
            resetObserver.observe(settingsPopup, { attributes: true, attributeFilter: ['style'] });
        }
    }

    // --- Settings Persistence ---
    function saveUserSettings() {
        try {
            localStorage.setItem(STORAGE_KEY, JSON.stringify(state.userSettings));
        } catch (error) {
            console.error('Error saving settings:', error);
        }
    }
    function loadUserSettings() {
        try {
            const storedSettings = JSON.parse(localStorage.getItem(STORAGE_KEY));
            if (storedSettings) state.userSettings = { ...DEFAULT_SETTINGS, ...storedSettings };
            if (!QUALITIES[state.userSettings.targetResolution]) {
                state.userSettings.targetResolution = DEFAULT_SETTINGS.targetResolution;
            }
            saveUserSettings();
        } catch (error) {
            console.error('Error loading settings:', error);
        }
    }
    function startObserveSettingsPanel() {
        const observer = new MutationObserver((mutations) => {
            for (const mutation of mutations) {
                for (const node of mutation.addedNodes) {
                    if (node.nodeType === 1 && node.classList.contains('ytp-panel')) {
                        if (node.querySelector('.ytp-menuitem[role="menuitemradio"]') && node.classList.contains('ytp-quality-menu')) {
                            setupQualityMenuNavigation(node);
                        }
                    }
                }
            }
        });
        observer.observe(document.body, { childList: true, subtree: true });
    }
    function addEventListeners() {
        const playerUpdateEvent = window.location.hostname === 'm.youtube.com' ? 'state-navigateend' : 'yt-player-updated';
        window.addEventListener(playerUpdateEvent, processVideoLoad, true);
        window.addEventListener('pageshow', processVideoLoad, true);
    }

    // --- Initialization ---
    function initialize() {
        loadUserSettings();
        addEventListeners();
        startObserveSettingsPanel();
    }
    initialize();
})();