您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
YouTube Helper API.
此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.cn-greasyfork.org/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; })();