YouTube Helper API

YouTube Helper API.

Este script não deve ser instalado diretamente. Este script é uma biblioteca de outros scripts para incluir com o diretório meta // @require https://update.greasyfork.org/scripts/549881/1664757/YouTube%20Helper%20API.js

// ==UserScript==
// @name          YouTube Helper API
// @author        ElectroKnight22
// @namespace     electroknight22_helper_api_namespace
// @version       0.2.1
// @license       MIT
// @description   YouTube Helper API.
// ==/UserScript==

/*jshint esversion: 11 */

/**
 * @namespace youtubeHelperApi
 * @description A comprehensive helper API for interacting with the YouTube player and page.
 * It provides a stable interface for accessing player state, video data, and controlling playback.
 */
window.youtubeHelperApi = (function () {
    ('use strict');

    /**
     * @property {Object} SELECTORS - A centralized object for all CSS selectors used in the script.
     * @private
     */
    const SELECTORS = {
        pageManager: 'ytd-page-manager',
        shortsPlayer: '#shorts-player',
        watchPlayer: '#movie_player',
        inlinePlayer: '.inline-preview-player',
        videoElement: 'video',
        watchFlexy: 'ytd-watch-flexy',
        chatFrame: 'ytd-live-chat-frame#chat',
        chatContainer: '#chat-container',
    };

    /**
     * @property {Object} POSSIBLE_RESOLUTIONS - A manually updated map of all the possible YouTube's resolution options.
     * @public
     */
    const POSSIBLE_RESOLUTIONS = Object.freeze({
        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' },
    });

    /**
     * @property {Proxy} apiProxy - A safe proxy to interact with YouTube's internal player API.
     * Prevents errors when the API or its methods are not available.
     * @private
     */
    const apiProxy = new Proxy(
        {},
        {
            get(target, property) {
                return (...args) => {
                    if (player.api && typeof player.api[property] === 'function') {
                        return player.api[property](...args);
                    }
                };
            },
        },
    );

    /**
     * @property {Object} player - Internal state object for the YouTube player.
     * @property {Object|null} player.playerObject - The root HTML element of the player.
     * @property {Object|null} player.api - The internal YouTube player API object.
     * @property {HTMLVideoElement|null} player.videoElement - The <video> element.
     * @property {boolean} player.isFullscreen - Whether the player is in fullscreen mode.
     * @property {boolean} player.isTheater - Whether the player is in theater mode.
     * @private
     */
    const player = {
        playerObject: null,
        api: null,
        videoElement: null,
        isFullscreen: false,
        isTheater: false,
    };

    /**
     * @property {Object} video - Internal state object for the current video's data.
     * @private
     */
    const video = {
        id: '',
        title: '',
        channel: '',
        channelId: '',
        rawDescription: '',
        rawUploadDate: '',
        rawPublishDate: '',
        uploadDate: null,
        publishDate: null,
        lengthSeconds: 0,
        viewCount: 0,
        likeCount: 0,
        isLive: false,
        isFamilySafe: false,
        thumbnails: [],
        playingLanguage: null,
        originalLanguage: null,
    };

    /**
     * @property {Object} chat - Internal state object for the live chat component.
     * @private
     */
    const chat = {
        container: null,
        iFrame: null,
        isCollapsed: false,
    };

    /**
     * @property {Object} page - Internal state object for the current YouTube page.
     * @private
     */
    const page = {
        manager: document.querySelector(SELECTORS.pageManager),
        watchFlexy: document.querySelector(SELECTORS.watchFlexy),
        isIframe: window.top !== window.self,
        isMobile: window.location.hostname === 'm.youtube.com',
        type: 'unknown',
    };
    let detectedAds = false;

    /**
     * @description Finds the current YouTube player element on the page as a fallback.
     * @returns {HTMLElement|null} The player element or null if not found.
     * @private
     */
    function fallbackGetPlayerApi() {
        const pathname = window.location.pathname;
        if (pathname.startsWith('/shorts')) return document.querySelector(SELECTORS.shortsPlayer);
        if (pathname.startsWith('/watch')) return document.querySelector(SELECTORS.watchPlayer);
        return document.querySelector(SELECTORS.inlinePlayer);
    }

    /**
     * @description Saves data to localStorage.
     * @param {string} storageKey - The key to save the data under.
     * @param {*} data - The data to be saved (will be JSON stringified).
     */
    function saveToStorage(storageKey, data) {
        try {
            localStorage.setItem(storageKey, JSON.stringify(data));
        } catch (error) {
            console.error(`Error saving data for key "${storageKey}":`, error);
        }
    }

    /**
     * @description Loads data from localStorage, merging with default data.
     * @param {string} storageKey - The key to load data from.
     * @param {Object} defaultData - Default data to return if loading fails or key doesn't exist.
     * @returns {Object} The loaded and merged data.
     */
    function loadFromStorage(storageKey, defaultData) {
        try {
            const storedData = JSON.parse(localStorage.getItem(storageKey));
            return { ...defaultData, ...storedData };
        } catch (error) {
            console.error(`Error loading data for key "${storageKey}":`, error);
            return defaultData;
        }
    }

    /**
     * @description Loads data from localStorage and cleans it, ensuring only keys present in defaultData are kept.
     * @param {string} storageKey - The key to load data from.
     * @param {Object} defaultData - The object defining the desired shape of the loaded data.
     * @returns {Object} The loaded and cleaned data.
     */
    function loadAndCleanFromStorage(storageKey, defaultData) {
        try {
            const storedData = JSON.parse(localStorage.getItem(storageKey)) || {};
            const combinedData = { ...defaultData, ...storedData };
            const cleanedData = Object.keys(defaultData).reduce((accumulator, currentKey) => {
                accumulator[currentKey] = combinedData[currentKey];
                return accumulator;
            }, {});

            return cleanedData;
        } catch (error) {
            console.error(`Error loading and cleaning data for key "${storageKey}":`, error);
            return defaultData;
        }
    }

    /**
     * @description Calculates the best playable quality based on a target resolution string and the actual available resolutions.
     * @param {string} targetResolutionString - The desired quality (e.g., 'hd1080', 'large').
     * @param {boolean} [usePremium=true] - Whether to use premium bitrate versions if available.
     * @returns {Object|null} The optimal quality data object from the player API, or null.
     */
    function getOptimalResolution(targetResolutionString, usePremium = true) {
        const PREMIUM_INDICATOR = 'Premium';
        try {
            if (!targetResolutionString || !POSSIBLE_RESOLUTIONS[targetResolutionString])
                throw new Error(`Invalid target resolution: ${targetResolutionString}`);
            const videoQualityData = apiProxy.getAvailableQualityData();
            const availableQualities = [...new Set(videoQualityData.map((q) => q.quality))];
            const targetValue = POSSIBLE_RESOLUTIONS[targetResolutionString].p;
            const bestQualityString = availableQualities
                .filter((q) => POSSIBLE_RESOLUTIONS[q] && POSSIBLE_RESOLUTIONS[q].p <= targetValue)
                .sort((a, b) => POSSIBLE_RESOLUTIONS[b].p - POSSIBLE_RESOLUTIONS[a].p)[0];
            if (!bestQualityString) return null;
            let normalCandidate = null;
            let premiumCandidate = null;
            for (const quality of videoQualityData) {
                if (quality.quality === bestQualityString && quality.isPlayable) {
                    if (usePremium && quality.qualityLabel?.trim().endsWith(PREMIUM_INDICATOR)) premiumCandidate = quality;
                    else normalCandidate = quality;
                }
            }
            return premiumCandidate || normalCandidate;
        } catch (error) {
            console.error('Error when resolving optimal quality:', error);
            return null;
        }
    }

    /**
     * @description Sets the video playback quality.
     * @param {string} targetResolution - The desired quality string (e.g., 'hd1080').
     * @param {boolean} [ignoreAvailable=false] - If true, forces the quality without checking availability.
     * @param {boolean} [usePremium=true] - Whether to use premium bitrate versions if available.
     */
    function setPlaybackResolution(targetResolution, ignoreAvailable = false, usePremium = true) {
        try {
            if (!player.api?.getAvailableQualityData) return;
            if (!usePremium && ignoreAvailable) {
                apiProxy.setPlaybackQualityRange(targetResolution);
            } else {
                const optimalQuality = getOptimalResolution(targetResolution, usePremium);
                if (optimalQuality)
                    apiProxy.setPlaybackQualityRange(
                        optimalQuality.quality,
                        optimalQuality.quality,
                        usePremium ? optimalQuality.formatId : null,
                    );
            }
        } catch (error) {
            console.error('Error when setting resolution:', error);
        }
    }

    /**
     * @description Dispatches a custom event to signal that the API is ready and has updated player data.
     * @private
     */
    function dispatchHelperApiReadyEvent() {
        if (!player.api) return;
        const event = new CustomEvent('yt-helper-api-ready', { detail: Object.freeze({ ...publicApi }) });
        document.dispatchEvent(event);
    }

    /**
     * @description Updates the video language states based on available and playing audio tracks.
     * @private
     */
    function updateVideoLanguage() {
        if (!player.api) return;
        const getAudioTrackId = (track) => Object.values(track ?? {}).find((p) => p?.id)?.id ?? null;
        const availableTracks = apiProxy.getAvailableAudioTracks();
        if (availableTracks?.length === 0) return; // Either no alternative languages exist or YouTube's API failed.
        const renderer = apiProxy.getPlayerResponse()?.captions?.playerCaptionsTracklistRenderer;
        const originalAudioId = renderer?.audioTracks?.[renderer?.defaultAudioTrackIndex]?.audioTrackId;
        const playingAudioTrack = apiProxy.getAudioTrack();
        const originalAudioTrack = availableTracks.find((track) => getAudioTrackId(track) === originalAudioId);
        video.playingLanguage = playingAudioTrack;
        video.originalLanguage = originalAudioTrack;
    }

    /**
     * @description Updates the internal video state object with the latest data from the player response.
     * @private
     */
    function updateVideoState() {
        if (!player.api) return;
        const playerResponseObject = apiProxy.getPlayerResponse();
        video.id = playerResponseObject?.videoDetails?.videoId;
        video.title = playerResponseObject?.videoDetails?.title;
        video.channel = playerResponseObject?.videoDetails?.author;
        video.channelId = playerResponseObject?.videoDetails?.channelId;
        video.rawDescription = playerResponseObject?.videoDetails?.shortDescription;
        video.rawUploadDate = playerResponseObject?.microformat?.playerMicroformatRenderer?.uploadDate;
        video.rawPublishDate = playerResponseObject?.microformat?.playerMicroformatRenderer?.publishDate;
        video.uploadDate = video.rawUploadDate ? new Date(video.rawUploadDate) : null;
        video.publishDate = video.rawPublishDate ? new Date(video.rawPublishDate) : null;
        video.lengthSeconds = parseInt(playerResponseObject?.videoDetails?.lengthSeconds ?? '0', 10);
        video.viewCount = parseInt(playerResponseObject?.videoDetails?.viewCount ?? '0', 10);
        video.likeCount = parseInt(playerResponseObject?.microformat?.playerMicroformatRenderer?.likeCount ?? '0', 10);
        video.isLive = playerResponseObject?.videoDetails?.isLiveContent;
        video.isFamilySafe = playerResponseObject?.microformat?.playerMicroformatRenderer?.isFamilySafe;
        video.thumbnails = playerResponseObject?.microformat?.playerMicroformatRenderer?.thumbnail?.thumbnails;
    }

    /**
     * @description Updates the internal player state object.
     * @param {Event|null} event - The event that triggered the update (e.g., 'yt-player-updated').
     * @private
     */
    function updatePlayerState(event) {
        player.api = event?.target?.player_ ?? fallbackGetPlayerApi();
        player.playerObject = event?.target?.playerContainer_?.children[0] ?? fallbackGetPlayerApi();
        player.videoElement = player.playerObject?.querySelector(SELECTORS.videoElement);
    }

    /**
     * @description Updates fullscreen state based on the document's fullscreen element.
     * @private
     */
    function updateFullscreenState() {
        player.isFullscreen = !!document.fullscreenElement;
    }

    /**
     * @description Updates theater mode state from a custom YouTube event.
     * @param {CustomEvent} event - The 'yt-set-theater-mode-enabled' event.
     * @private
     */
    function updateTheaterState(event) {
        player.isTheater = !!event?.detail?.enabled;
    }

    /**
     * @description Updates the chat state from a custom YouTube event.
     * @param {CustomEvent} event - The 'yt-chat-collapsed-changed' event.
     * @private
     */
    function updateChatState(event) {
        chat.iFrame = event?.target ?? document.querySelector(SELECTORS.chatFrame);
        chat.container = chat.iFrame?.parentElement ?? document.querySelector(SELECTORS.chatContainer);
        chat.isCollapsed = event?.detail ?? true;
        document.dispatchEvent(new CustomEvent('yt-helper-api-chat-state-updated', { detail: Object.freeze({ ...chat }) }));
    }

    /**
     * @description Checks if the script is running inside an iframe and dispatches an event if so.
     * @private
     */
    function checkIsIframe() {
        if (page.isIframe) document.dispatchEvent(new Event('yt-helper-api-detected-iframe'));
    }

    /**
     * @description Checks for ad presence by inspecting CSS classes on the player container.
     * @returns {boolean|undefined} True if ad is present, otherwise false.
     */
    function checkAdPresense() {
        try {
            const shouldAvoid = player.container.classList.contains('unstarted-mode'); // YouTube doesn't update ad state fully until player is marked as started.
            const isAdPresent = player.container.classList.contains('ad-showing') || player.container.classList.contains('ad-interrupting');
            return !shouldAvoid && isAdPresent;
        } catch (error) {
            console.error('Error in checkAdState:', error);
            return false;
        }
    }

    /**
     * @description A fallback method to check for ads by comparing video duration from different API sources.
     * @returns {boolean} True if an ad is detected.
     */
    function fallbackCheckAdPresense() {
        if (!player.api) return;
        try {
            const progressState = apiProxy.getProgressState();
            const reportedContentDuration = progressState.duration;
            const realContentDuration = apiProxy.getDuration() ?? -1;
            const durationMismatch = Math.trunc(realContentDuration) !== Math.trunc(reportedContentDuration);
            const hasAds = durationMismatch;
            if (hasAds) document.dispatchEvent(new CustomEvent('yt-helper-api-ad-detected'));
            if (hasAds !== detectedAds)
                document.dispatchEvent(new CustomEvent('yt-helper-api-ad-state-changed', { detail: Object.freeze({ adState: hasAds }) }));
            detectedAds = hasAds;
            return detectedAds;
        } catch (error) {
            console.error('Error during ad check:', error);
            return false;
        }
    }

    /**
     * @description Main handler for player updates. Triggers all necessary state update functions.
     * @param {Event|null} [event=null] - The event that triggered the update.
     * @private
     */
    function handlePlayerUpdate(event = null) {
        updatePlayerState(event);
        updateVideoState();
        updateVideoLanguage();
        fallbackCheckAdPresense();
        dispatchHelperApiReadyEvent();
    }

    /**
     * @description Handler for the 'yt-navigate-finish' event. Updates page state.
     * @param {CustomEvent} event - The navigation event.
     * @private
     */
    function handleNavigationFinish(event) {
        page.type = event?.detail?.pageType;
        page.manager = document.querySelector(SELECTORS.pageManager);
        page.watchFlexy = document.querySelector(SELECTORS.watchFlexy);
    }

    /**
     * @description Adds event listeners related to the player state.
     * @private
     */
    function addPlayerStateListeners() {
        const PLAYER_UPDATE_EVENT = page.isMobile ? 'state-navigateend' : 'yt-player-updated';
        document.addEventListener(PLAYER_UPDATE_EVENT, handlePlayerUpdate);
        document.addEventListener('fullscreenchange', updateFullscreenState);
        document.addEventListener('yt-set-theater-mode-enabled', updateTheaterState);
    }

    /**
     * @description Adds event listeners related to the chat state.
     * @private
     */
    function addChatStateListeners() {
        document.addEventListener('yt-chat-collapsed-changed', updateChatState);
    }

    /**
     * @description Adds event listeners related to page navigation.
     * @private
     */
    function addNavigationListeners() {
        document.addEventListener('yt-navigate-finish', handleNavigationFinish);
    }

    /**
     * @description Initializes the script by setting up all event listeners.
     * @private
     */
    function initialize() {
        window.addEventListener('pageshow', handlePlayerUpdate);
        checkIsIframe();
        addNavigationListeners();
        addPlayerStateListeners();
        addChatStateListeners();
    }

    initialize();

    const publicApi = {
        /** @type {Object} A read-only copy of the player state object. */
        get player() {
            return { ...player };
        },
        /** @type {Object} A read-only copy of the video state object. */
        get video() {
            return { ...video };
        },
        /** @type {Object} A read-only copy of the chat state object. */
        get chat() {
            return { ...chat };
        },
        /** @type {Object} A read-only copy of the page state object. */
        get page() {
            return { ...page };
        },
        /** @type {boolean} A read-only flag indicating if an ad has been detected. */
        get isAdPlaying() {
            return detectedAds;
        },
        POSSIBLE_RESOLUTIONS,
        checkAdPresense,
        fallbackCheckAdPresense,
        getOptimalResolution,
        setPlaybackResolution,
        saveToStorage,
        loadFromStorage,
        loadAndCleanFromStorage,
        apiProxy,
    };

    return publicApi;
})();