YouTube Helper API

YouTube Helper API.

Script này sẽ không được không được cài đặt trực tiếp. Nó là một thư viện cho các script khác để bao gồm các chỉ thị meta // @require https://update.greatest.deepsurf.us/scripts/549881/1670161/YouTube%20Helper%20API.js

// ==UserScript==
// @name                YouTube Helper API
// @author              ElectroKnight22
// @namespace           electroknight22_helper_api_namespace
// @version             0.5.7
// @license             MIT
// @description         A helper api for YouTube scripts that provides easy and consistent access for commonly needed functions, objects, and values.
// ==/UserScript==

/*jshint esversion: 11 */

window.youtubeHelperApi = (function () {
    'use strict';

    const privateEventTarget = new EventTarget();

    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',
    };

    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' },
    });

    const apiProxy = new Proxy(
        {},
        {
            get(target, property) {
                return (...args) => {
                    if (!player.api) return;
                    if (typeof player.api[property] === 'function') {
                        return player.api[property](...args);
                    } else {
                        console.warn(`Method "${property}" does not exist on the YouTube player API.`);
                    }
                };
            },
        },
    );

    const player = {
        playerObject: null,
        api: null,
        videoElement: null,
        isFullscreen: false,
        isTheater: false,
        isPlayingAds: false,
    };

    const video = {
        id: '',
        title: '',
        channel: '',
        channelId: '',
        rawDescription: '',
        rawUploadDate: '',
        rawPublishDate: '',
        uploadDate: null,
        publishDate: null,
        lengthSeconds: 0,
        viewCount: 0,
        likeCount: 0,
        isCurrentlyLive: false,
        isLiveOrVodContent: false,
        isFamilySafe: false,
        thumbnails: [],
        playingLanguage: null,
        originalLanguage: null,
        realCurrentProgress: 0, // YouTube can return the progress of the ad playing instead of the video content so we need implement our own progress tracking.
        isTimeSpecified: false,
        isInPlaylist: false,
        playlistId: '',
    };

    const chat = {
        container: null,
        iFrame: null,
        isCollapsed: false,
    };

    const page = {
        get manager() {
            return document.querySelector(SELECTORS.pageManager);
        },
        get watchFlexy() {
            return document.querySelector(SELECTORS.watchFlexy);
        },
        isIframe: window.top !== window.self,
        isMobile: window.location.hostname === 'm.youtube.com',
        type: 'unknown',
    };

    const localStorageApi = {
        get: (key, defaultValue) => {
            const value = localStorage.getItem(key);
            if (value === null) return defaultValue;
            try {
                return JSON.parse(value);
            } catch (error) {
                console.error(`Error parsing JSON for key "${key}":`, error);
                return value;
            }
        },
        set: (key, value) => {
            localStorage.setItem(key, JSON.stringify(value));
        },
    };

    const storageApi = (() => {
        const STORAGE_IMPLEMENTATIONS = {
            modern: {
                getValue: async (...args) => await GM.getValue(...args),
                setValue: async (...args) => await GM.setValue(...args),
                deleteValue: async (...args) => await GM.deleteValue(...args),
                listValues: async (...args) => await GM.listValues(...args),
            },
            old: {
                getValue: async (key, defaultValue) => GM_getValue(key, defaultValue),
                setValue: async (key, value) => GM_setValue(key, value),
                deleteValue: async (key) => GM_deleteValue(key),
                listValues: async () => GM_listValues(),
            },
            none: {
                getValue: async (key, defaultValue) => localStorageApi.get(key, defaultValue),
                setValue: async (key, value) => localStorageApi.set(key, value),
                deleteValue: async (key) => localStorage.removeItem(key),
                listValues: async () => Object.keys(localStorage),
            },
        };
        const gmType = (() => {
            if (typeof GM !== 'undefined') {
                return 'modern';
            }
            if (typeof GM_info !== 'undefined') {
                return 'old';
            }
            return 'none';
        })();
        return {
            ...STORAGE_IMPLEMENTATIONS[gmType],
            gmType,
        };
    })();

    async function _getSyncedStorageData(storageKey) {
        if (storageApi.gmType === 'none') return await storageApi.getValue(storageKey, null);
        const [gmData, localData] = await Promise.all([storageApi.getValue(storageKey, null), localStorageApi.get(storageKey, null)]);
        const gmTimestamp = gmData?.metadata?.timestamp ?? -1;
        const localTimestamp = localData?.metadata?.timestamp ?? -1;

        if (gmTimestamp > localTimestamp) {
            localStorageApi.set(storageKey, gmData);
            return gmData;
        } else if (localTimestamp > gmTimestamp) {
            await storageApi.setValue(storageKey, localData);
            return localData;
        }

        return gmData || localData;
    }

    async function saveToStorage(storageKey, data) {
        const dataToStore = {
            data: data,
            metadata: {
                timestamp: Date.now(),
            },
        };
        try {
            if (storageApi.gmType !== 'none') await storageApi.setValue(storageKey, dataToStore);
            localStorageApi.set(storageKey, dataToStore);
        } catch (error) {
            console.error(`Error saving data for key "${storageKey}":`, error);
        }
    }

    async function loadFromStorage(storageKey, defaultData) {
        try {
            const syncedWrapper = await _getSyncedStorageData(storageKey);
            const storedData = syncedWrapper && !syncedWrapper.metadata ? syncedWrapper : syncedWrapper?.data ?? {};
            return { ...defaultData, ...storedData };
        } catch (error) {
            console.error(`Error loading data for key "${storageKey}":`, error);
            return defaultData;
        }
    }

    async function loadAndCleanFromStorage(storageKey, defaultData) {
        try {
            const combinedData = await loadFromStorage(storageKey, defaultData);
            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;
        }
    }

    async function deleteFromStorage(storageKey) {
        try {
            if (storageApi.gmType !== 'none') await storageApi.deleteValue(storageKey);
            localStorage.removeItem(storageKey);
        } catch (error) {
            console.error(`Error deleting data for key "${storageKey}":`, error);
        }
    }

    async function listFromStorage() {
        try {
            const [greasemonkeyKeys, localStorageKeys] = await Promise.all([
                storageApi.gmType !== 'none' ? storageApi.listValues() : Promise.resolve([]),
                Promise.resolve(Object.keys(localStorage)),
            ]);
            const allUniqueKeys = new Set([...greasemonkeyKeys, ...localStorageKeys]);
            return Array.from(allUniqueKeys);
        } catch (error) {
            console.error('Error listing storage values:', error);
            return [];
        }
    }

    function fallbackGetPlayerApi() {
        if (window.location.hostname === 'm.youtube.com') return document.querySelector(SELECTORS.watchPlayer);
        if (window.location.pathname.startsWith('/shorts')) return document.querySelector(SELECTORS.shortsPlayer);
        if (window.location.pathname.startsWith('/watch')) return document.querySelector(SELECTORS.watchPlayer);
        return document.querySelector(SELECTORS.inlinePlayer);
    }

    function getOptimalResolution(targetResolutionString, usePremium = true) {
        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.paygatedQualityDetails) premiumCandidate = quality;
                    else normalCandidate = quality;
                }
            }
            return premiumCandidate || normalCandidate;
        } catch (error) {
            console.error('Error when resolving optimal quality:', error);
            return null;
        }
    }

    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);
        }
    }

    function _dispatchHelperApiReadyEvent() {
        if (!player.api) return;
        const event = new CustomEvent('yt-helper-api-ready', { detail: Object.freeze({ ...publicApi }) });
        privateEventTarget.dispatchEvent(event);
    }

    function _notifyAdDetected() {
        if (player.isPlayingAds)
            privateEventTarget.dispatchEvent(
                new CustomEvent('yt-helper-api-ad-detected', { detail: Object.freeze({ isPlayingAds: player.isPlayingAds }) }),
            );
    }

    function checkIsIframe() {
        if (page.isIframe) privateEventTarget.dispatchEvent(new Event('yt-helper-api-detected-iframe'));
    }

    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;
    }

    function updateVideoState() {
        if (!player.api) return;
        const playerResponseObject = apiProxy.getPlayerResponse();
        const searchParams = new URL(window.location.href).searchParams;
        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.isCurrentlyLive = apiProxy.getVideoData().isLive;
        video.isLiveOrVodContent = playerResponseObject?.videoDetails?.isLiveContent;
        video.isFamilySafe = playerResponseObject?.microformat?.playerMicroformatRenderer?.isFamilySafe;
        video.thumbnails = playerResponseObject?.microformat?.playerMicroformatRenderer?.thumbnail?.thumbnails;
        video.realCurrentProgress = apiProxy.getCurrentTime();
        video.isTimeSpecified = searchParams.has('t');
        video.playlistId = apiProxy.getPlaylistId();
    }

    function updatePlayerState(event) {
        player.api = event?.target?.player_ ?? fallbackGetPlayerApi();
        player.playerObject = event?.target?.playerContainer_?.children[0] ?? fallbackGetPlayerApi();
        player.videoElement = player.playerObject?.querySelector(SELECTORS.videoElement);
    }

    function updateFullscreenState() {
        player.isFullscreen = !!document.fullscreenElement;
    }

    function updateTheaterState(event) {
        player.isTheater = !!event?.detail?.enabled;
    }

    function updateChatStateUpdated(event) {
        chat.iFrame = event?.target ?? document.querySelector(SELECTORS.chatFrame);
        chat.container = chat.iFrame?.parentElement ?? document.querySelector(SELECTORS.chatContainer);
        chat.isCollapsed = event?.detail ?? true;
        privateEventTarget.dispatchEvent(new CustomEvent('yt-helper-api-chat-state-updated', { detail: Object.freeze({ ...chat }) }));
    }

    function updateAdState() {
        if (!player.playerObject) return;
        try {
            const shouldAvoid = player.playerObject.classList.contains('unstarted-mode'); // YouTube doesn't update ad state fully until player is marked as started.
            const isAdPresent =
                player.playerObject.classList.contains('ad-showing') || player.playerObject.classList.contains('ad-interrupting');
            const isPlayingAds = !shouldAvoid && isAdPresent;
            player.isPlayingAds = isPlayingAds;
            _notifyAdDetected();
        } catch (error) {
            console.error('Error in checkAdState:', error);
            return false;
        }
    }

    function fallbackUpdateAdState() {
        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 isPlayingAds = durationMismatch;
            player.isPlayingAds = isPlayingAds;
            _notifyAdDetected();
        } catch (error) {
            console.error('Error during ad check:', error);
            return false;
        }
    }

    function reloadVideo(targetTime) {
        if (!player.api) return;
        apiProxy.loadVideoById(video.id, targetTime);
    }

    function reloadToCurrentProgress() {
        reloadVideo(video.realCurrentProgress);
    }

    const timeUpdateTrackedElements = new WeakMap();
    function trackPlaybackProgress() {
        if (timeUpdateTrackedElements.has(player.videoElement)) return;
        if (!player.videoElement) return;
        const updateProgress = () => {
            if (!player.isPlayingAds && player.videoElement.currentTime > 0) {
                video.realCurrentProgress = player.videoElement.currentTime;
            }
        };
        player.videoElement.addEventListener('timeupdate', updateProgress);
        timeUpdateTrackedElements.set(player.videoElement, true);
    }

    const currentlyObservedContainers = new WeakMap();
    function trackAdState() {
        if (!player.playerObject) return;
        if (currentlyObservedContainers.has(player.playerObject)) return;
        const adStateObserver = new MutationObserver(updateAdState);
        adStateObserver.observe(player.playerObject, { attributes: true, attributeFilter: ['class'] });
        currentlyObservedContainers.set(player.playerObject, adStateObserver);
    }

    function _handlePlayerUpdate(event = null) {
        updatePlayerState(event);
        updateVideoState();
        updateVideoLanguage();
        updateAdState();
        trackAdState();
        trackPlaybackProgress();
        _dispatchHelperApiReadyEvent();
    }

    function _handlePageDataUpdate(event) {
        page.type = event.detail?.pageType;
    }

    function _handlePageTypeChange(event) {
        page.type = event.detail?.newPageSubtype;
    }

    function addPageStateListeners() {
        document.addEventListener('yt-page-data-updated', _handlePageDataUpdate);
        document.addEventListener('yt-page-type-changed', _handlePageTypeChange);
    }

    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);
    }

    function addChatStateListeners() {
        document.addEventListener('yt-chat-collapsed-changed', updateChatStateUpdated);
    }

    function initialize() {
        window.addEventListener('pageshow', _handlePlayerUpdate);
        checkIsIframe();
        if (!page.isIframe) {
            addPlayerStateListeners();
            addPageStateListeners();
            addChatStateListeners();
        }
    }

    initialize();

    const publicApi = {
        get player() {
            return { ...player };
        },
        get video() {
            return { ...video };
        },
        get chat() {
            return { ...chat };
        },
        get page() {
            return { ...page };
        },
        POSSIBLE_RESOLUTIONS,
        updateAdState,
        fallbackUpdateAdState,
        getOptimalResolution,
        setPlaybackResolution,
        saveToStorage,
        loadFromStorage,
        loadAndCleanFromStorage,
        deleteFromStorage,
        listFromStorage,
        reloadVideo,
        reloadToCurrentProgress,
        apiProxy,
        eventTarget: privateEventTarget,
    };

    return publicApi;
})();