// ==UserScript== // @name Youtube HD Premium // @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 // @version 2024.10.22 // 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/* // @exclude *://www.youtube.com/live_chat* // @grant GM.getValue // @grant GM.setValue // @grant GM.deleteValue // @grant GM.listValues // @grant GM.registerMenuCommand // @grant GM.unregisterMenuCommand // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @license MIT // @description Automcatically 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/en/scripts/498145-youtube-hd-premium // @downloadURL none // ==/UserScript== /*jshint esversion: 11 */ (function() { "use strict"; const DEFAULT_SETTINGS = { targetResolution: "hd2160", expandMenu: false, debug: false }; const QUALITIES = { highres: 4320, hd2880: 2880, hd2160: 2160, hd1440: 1440, hd1080: 1080, hd720: 720, large: 480, medium: 360, small: 240, tiny: 144, }; const PREMIUM_INDICATOR_LABEL = "Premium"; let userSettings = { ...DEFAULT_SETTINGS }; let useCompatilibtyMode = false; let isBrokenOrMissingGMAPI = false; let menuItems = []; let videoId = ''; let resolvedTarget = ''; let targetLabel = ''; let ytPlayer = document.getElementById("movie_player") || document.getElementsByClassName("html5-video-player")[0]; let video = document.querySelector('video'); // --- CLASS DEFINITIONS ----------- class AllowedExceptionError extends Error { constructor(message) { super(message); this.name = "Allowed Exception"; } } // --- GM FUNCTION OVERRIDES ------ const GMCustomRegisterMenuCommand = useCompatilibtyMode ? GM_registerMenuCommand : GM.registerMenuCommand; const GMCustomUnregisterMenuCommand = useCompatilibtyMode ? GM_unregisterMenuCommand : GM.unregisterMenuCommand; const GMCustomGetValue = useCompatilibtyMode ? GM_getValue : GM.getValue; const GMCustomSetValue = useCompatilibtyMode ? GM_setValue : GM.setValue; const GMCustomListValues = useCompatilibtyMode ? GM_listValues : GM.listValues; const GMCustomDeleteValue = useCompatilibtyMode ? GM_deleteValue : GM.deleteValue; // --- FUNCTIONS ------ function debugLog(message) { if (!userSettings.debug) return; const stack = new Error().stack; const stackLines = stack.split("\n"); const callerLine = stackLines[2] ? stackLines[2].trim() : "Line not found"; message += ""; if (!message.endsWith(".")) { message += "."; } console.log(`[YTHD DEBUG] ${message} Function called ${callerLine}`); } // Attempt to set the video resolution to target quality or the next best quality function setResolution(force = false) { try { checkVideoValid(force); resolvedTarget = findNextAvailableQuality(userSettings.targetResolution, ytPlayer.getAvailableQualityLevels()); const premiumData = ytPlayer.getAvailableQualityData().find(q => q.quality === resolvedTarget && q.qualityLabel.trim().endsWith(PREMIUM_INDICATOR_LABEL) && q.isPlayable); ytPlayer.setPlaybackQualityRange(resolvedTarget, resolvedTarget, premiumData?.formatId); targetLabel = QUALITIES[resolvedTarget] + "p" + (premiumData ? " " + PREMIUM_INDICATOR_LABEL : ""); videoId = ytPlayer.getVideoData().video_id; debugLog(`Setting quality to: ${targetLabel}`); } catch (error) { debugLog("Did not set resolution. " + error); } } function checkVideoValid(force) { try { if (/^https?:\/\/(www\.)?youtube\.com\/shorts\//.test(window.location.href)) { throw new AllowedExceptionError("Skipping Youtube Shorts."); } if (!ytPlayer?.getAvailableQualityLabels().length) { throw "Video data missing."; } if (videoId == ytPlayer.getVideoData().video_id && !force) { throw new AllowedExceptionError("Duplicate Load. Skipping."); } } catch (error) { throw error; } } function findNextAvailableQuality(target, availableQualities) { const targetValue = QUALITIES[target]; return availableQualities .map(q => ({ quality: q, value: QUALITIES[q] })) .find(q => q.value <= targetValue)?.quality; } function compareQualityLabelButIgnoreFrameRate(label1, label2) { const normalize = label => label.replace(new RegExp(`(\\d+p)\\d*( ${PREMIUM_INDICATOR_LABEL})?$`), '$1').trim(); return normalize(label1) === normalize(label2); } // Tries to validate setting changes. If this fails to detect a successful update it will call setResolution to attempt to force the playback quality to update again. function verifyChange(retries = 10) { const retryDelay = 500; try { checkVideoValid(true); let success = ytPlayer.getPreferredQuality() !== "auto"; let currentQualityLabel = ytPlayer.getPlaybackQualityLabel(); let isManualChange = !compareQualityLabelButIgnoreFrameRate(currentQualityLabel, targetLabel); if (success) { debugLog(`Quality is ${currentQualityLabel}. ${isManualChange ? "Manual change detected!" : "Change verified!"}`); } else { throw "Unable to verify change"; } } catch (error) { if (error instanceof AllowedExceptionError) { debugLog(error); } else if (retries) { debugLog(`Error when verifying quality change. [${error}] Attempting quality change and reverifying after ${retryDelay}ms...`); setResolution(true); setTimeout(() => verifyChange(retries - 1), retryDelay); } else { debugLog(`Cannot verify quality change after retrying.`); } } } function processNewVideo() { try { video?.removeEventListener('playing', verifyChange, true); videoId = ''; ytPlayer = document.getElementById("movie_player") || document.getElementsByClassName("html5-video-player")[0] || ytPlayer; video = ytPlayer?.querySelector('video') || document.querySelector('video'); if (!video) throw "Video element not found."; video.addEventListener('playing', verifyChange, true); setResolution(); } catch (error) { debugLog("Load error: " + error); } } // ---------------------------------------- // Functions for the quality selection menu function removeMenuOptions() { while (menuItems.length) { GMCustomUnregisterMenuCommand(menuItems.pop()); } } function showMenuOptions() { removeMenuOptions(); const menuOptions = { expandMenu: { alwaysShow: true, label: () => `Quality Settings [${userSettings.expandMenu ? "HIDE" : "SHOW"}]`, menuId: "menuExpandBtn", handleClick: function() { userSettings.expandMenu = !userSettings.expandMenu; GMCustomSetValue('expandMenu', userSettings.expandMenu); showMenuOptions(); } }, qualities: { items: Object.entries(QUALITIES).map(([label, resolution]) => ({ label: () => `${resolution}p${label === userSettings.targetResolution ? " ✅" : ""}`, menuId: label, handleClick: function() { if (userSettings.targetResolution == label) return; userSettings.targetResolution = label; GMCustomSetValue('targetResolution', label); setResolution(true); showMenuOptions(); } })) }, debug: { label: () => `DEBUG [${userSettings.debug ? "ON" : "OFF"}]`, menuId: "debugBtn", handleClick: function() { userSettings.debug = !userSettings.debug; GMCustomSetValue('debug', userSettings.debug); showMenuOptions(); } } } function registerItem(item) { GMCustomRegisterMenuCommand(item.label(), () => { item.handleClick(); }, { id: item.menuId, autoClose: false }); menuItems.push(item.menuId); } Object.values(menuOptions).forEach(option => { if (!option.alwaysShow && !userSettings.expandMenu) return; if (option.items){ option.items.forEach(item => { registerItem(item); }); return; } registerItem(option); }); } // ----------------------------------------------- // Verify Grease Monkey API exists and is working. function checkGMAPI() { if (typeof GM != 'undefined') return; if (typeof GM_info != 'undefined') { useCompatilibtyMode = true; debugLog("Running in compatiblity mode."); return; } isBrokenOrMissingGMAPI = true; } // ----------------------------------------------- // User setting handling async function loadUserSettings() { // Get all keys from GM const storedValues = await GMCustomListValues(); // Write any missing key-value pairs from DEFAULT_SETTINGS to GM for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) { if (!storedValues.includes(key)) { await GMCustomSetValue(key, value); } } // Delete any extra keys in GM that are not in DEFAULT_SETTINGS for (const key of storedValues) { if (!(key in DEFAULT_SETTINGS)) { await GMCustomDeleteValue(key); } } // Retrieve and update user settings from GM const keyValuePairs = await Promise.all(storedValues.map(async key => [key, await GMCustomGetValue(key)])); keyValuePairs.forEach(([newKey, newValue]) => { userSettings[newKey] = newValue; }); debugLog(`Loaded user settings: [${Object.entries(userSettings).map(([key, value]) => `${key}: ${value}`).join(", ")}].`); } // ---------------- // Main function async function initialize() { checkGMAPI(); try { if (isBrokenOrMissingGMAPI) { throw "Did not detect valid Grease Monkey API"; } await loadUserSettings(); } catch (error) { debugLog(`Error loading user settings: ${error}. Loading with default settings.`); } window.addEventListener('popstate', () => { videoId = ''; }); if (window.self == window.top) { // handle youtube website window.addEventListener("yt-navigate-finish", processNewVideo, true ); showMenuOptions(); } else { // handle iframes window.addEventListener('load', processNewVideo, true ); } } // Entry Point initialize(); })();