您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Youtube Pro is a script which allows you to return dislikes ,download youtube videos and block anoying ads powered by Return YouTube Dislike,local youtube video downloader and Youtube Pro
// ==UserScript== // @name Youtube Pro | v4 // @version v4 // @description Youtube Pro is a script which allows you to return dislikes ,download youtube videos and block anoying ads powered by Return YouTube Dislike,local youtube video downloader and Youtube Pro // @author devsoniexpert72,maple3142 and Anarios & JRWR // @match https://*.youtube.com/* // @require https://unpkg.com/[email protected]/dist/vue.js // @require https://unpkg.com/[email protected]/xfetch.min.js // @require https://unpkg.com/@ffmpeg/[email protected]/dist/ffmpeg.min.js // @require https://bundle.run/[email protected] // @grant GM_xmlhttpRequest // @grant GM_info // @grant GM_setValue // @grant GM_addStyle // @grant GM.xmlHttpRequest // @grant unsafeWindow // @run-at document-end // @connect googlevideo.com // @license MIT // @exclude *://music.youtube.com/* // @exclude *://*.music.youtube.com/* // @compatible chrome // @compatible firefox // @compatible opera // @compatible safari // @compatible edge // @run-at document-end // @namespace https://greasyfork.org/users/1235871 // ==/UserScript== ;(function () { 'use strict' if ( window.top === window.self && GM_info.scriptHandler === 'Tampermonkey' && GM_info.version === '4.18.0' && GM_getValue('tampermonkey_breaks_should_alert', true) ) { alert( `Tampermonkey recently release a breaking change / bug in version 4.18.0 that breaks this script, which is fixed in newer version of Tampermonkey right now. You should update it or switch to Violentmonkey instead.` ) GM_setValue('tampermonkey_breaks_should_alert', false) } const DEBUG = true const createLogger = (console, tag) => Object.keys(console) .map(k => [k, (...args) => (DEBUG ? console[k](tag + ': ' + args[0], ...args.slice(1)) : void 0)]) .reduce((acc, [k, fn]) => ((acc[k] = fn), acc), {}) const logger = createLogger(console, 'YTDL') const sleep = ms => new Promise(res => setTimeout(res, ms)) const LANG_FALLBACK = 'en' const LOCALE = { en: { togglelinks: 'Show/Hide Links', stream: 'Stream', adaptive: ' ', videoid: 'Video ID: ', inbrowser_adaptive_merger: ' ', dlmp4: ' ', get_video_failed: 'Failed to get video infomation for unknown reason, refresh the page may work.', live_stream_disabled_message: 'Local YouTube Downloader is not available for live stream' }, 'zh-tw': { togglelinks: '顯示 / 隱藏連結', stream: '串流 Stream', adaptive: '自適應 Adaptive (沒有聲音)', videoid: '影片 ID: ', inbrowser_adaptive_merger: '線上自適應影片及音訊合成工具 (FFmpeg)', dlmp4: '一鍵下載高畫質 mp4', get_video_failed: '無法取得影片資訊,重新整理頁面可能會有效果。', live_stream_disabled_message: '因為是直播的緣故,本地 YouTube 下載器的功能是停用的。' }, 'zh-hk': { togglelinks: '顯示/隱藏連結', stream: '串流 Stream', adaptive: '自動適應 Adaptive (沒有聲音)', videoid: '影片 ID: ', inbrowser_adaptive_merger: '網上自動適應影片及音訊合成工具 (FFmpeg)', dlmp4: '一 click 下載高畫質 mp4', get_video_failed: '無法取得影片資訊,重新整理頁面可能會有效果。', live_stream_disabled_message: '本地 YouTube 下載器無法用於直播。' }, zh: { togglelinks: '显示/隐藏链接', stream: '串流 Stream', adaptive: '自适应 Adaptive (没有声音)', videoid: '视频 ID: ', inbrowser_adaptive_merger: '线上自适应视频及音频合成工具 (FFmpeg)', dlmp4: '一键下载高画质 mp4', get_video_failed: '无法取得影片资讯,重新整理页面可能会有效果。', live_stream_disabled_message: '因为是直播,本地 YouTube 下载器的功能已被禁用。' }, ja: { togglelinks: 'リンク表示・非表示', stream: 'ストリーミング', adaptive: 'アダプティブ(音無し)', videoid: 'ビデオ ID: ', inbrowser_adaptive_merger: 'ビデオとオーディオを合併するオンラインツール (FFmpeg)', dlmp4: 'ワンクリックで高解像度の mp4 をダウンロード', live_stream_disabled_message: 'ライブ配信のため、ローカル YouTube ダウンローダーは無効になっています。' }, kr: { togglelinks: '링크 보이기 · 숨기기', stream: '스트리밍', adaptive: '적응 (어댑티브)', videoid: '비디오 ID: ', inbrowser_adaptive_merger: '비디오와 오디오를 합병하는 온라인 도구 (FFmpeg)', dlmp4: '한 번의 클릭으로 고해상도 mp4 다운로드' }, es: { togglelinks: 'Mostrar/Ocultar Links', stream: 'Stream', adaptive: 'Adaptable', videoid: 'Id del Video: ', inbrowser_adaptive_merger: 'Acoplar Audio a Video (FFmpeg)' }, he: { togglelinks: 'הצג/הסתר קישורים', stream: 'סטרים', adaptive: 'אדפטיבי', videoid: 'מזהה סרטון: ' }, fr: { togglelinks: 'Afficher/Masquer les liens', stream: 'Stream', adaptive: 'Adaptative', videoid: 'ID vidéo: ', inbrowser_adaptive_merger: 'Fusionner vidéos et audios adaptatifs dans le navigateur (FFmpeg)', dlmp4: 'Téléchargez la plus haute résolution mp4 en un clic' }, pl: { togglelinks: 'Pokaż/Ukryj Linki', stream: 'Stream', adaptive: 'Adaptywne', videoid: 'ID filmu: ', inbrowser_adaptive_merger: 'Połącz audio i wideo adaptywne w przeglądarce (FFmpeg)', dlmp4: 'Pobierz .mp4 w najwyższej jakości' }, hi: { togglelinks: 'लिंक टॉगल करें', stream: 'स्ट्रीमिंग (Stream)', adaptive: 'अनुकूली (Adaptive)', videoid: 'वीडियो आईडी: {{id}}' }, ru: { togglelinks: 'Показать/Cкрыть ссылки', stream: 'Поток', adaptive: 'Адаптивный', videoid: 'Идентификатор видео: ', inbrowser_adaptive_merger: 'Адаптивное слияние видео и аудио онлайн (FFmpeg)', dlmp4: 'Скачать mp4 в высоком разрешении в один клик', get_video_failed: 'Не удалось получить информацию о видео по неизвестной причине, попробуйте обновить страницу.', live_stream_disabled_message: 'Локальный загрузчик YouTube недоступен для прямой трансляции' }, ua: { togglelinks: 'Показати/Приховати посилання', stream: 'Потік', adaptive: 'Адаптивний', videoid: 'Ідентифікатор відео: ', inbrowser_adaptive_merger: 'Адаптивне злиття відео і аудіо онлайн (FFmpeg)', dlmp4: 'Завантажити mp4 у високій роздільній здатності в один клік', get_video_failed: 'Не вдалося отримати інформацію про відео з невідомої причини, спробуйте оновити сторінку.', live_stream_disabled_message: 'Локальний завантажувач YouTube недоступний для прямої трансляції' }, cs: { togglelinks: 'Zobrazit/Skrýt odkazy', stream: 'Stream', adaptive: 'Adaptivní', videoid: 'ID videa: ', inbrowser_adaptive_merger: 'Online nástroj pro sloučení videa a audia (FFmpeg)', dlmp4: 'Stáhnout video mp4 jedním kliknutím ve vysokém rozlišení', get_video_failed: 'Nepodařilo se nahrát informace o videu. Zkuste obnovit stránku (F5).', live_stream_disabled_message: 'Local YouTube Downloader není dostupný pro živé vysílání' } } for (const [lang, data] of Object.entries(LOCALE)) { if (lang === LANG_FALLBACK) continue for (const key of Object.keys(LOCALE[LANG_FALLBACK])) { if (!(key in data)) { data[key] = LOCALE[LANG_FALLBACK][key] } } } const findLang = l => { l = l.replace('-Hant', '') // special case for zh-Hant-TW // language resolution logic: zh-tw --(if not exists)--> zh --(if not exists)--> LANG_FALLBACK(en) l = l.toLowerCase().replace('_', '-') if (l in LOCALE) return l else if (l.length > 2) return findLang(l.split('-')[0]) else return LANG_FALLBACK } const getLangCode = () => { const html = document.querySelector('html') if (html) { return html.lang } else { return navigator.language } } const $ = (s, x = document) => x.querySelector(s) const $el = (tag, opts) => { const el = document.createElement(tag) Object.assign(el, opts) return el } const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const parseDecsig = data => { try { if (data.startsWith('var script')) { // they inject the script via script tag const obj = {} const document = { createElement: () => obj, head: { appendChild: () => {} } } eval(data) data = obj.innerHTML } const fnnameresult = /=([a-zA-Z0-9\$_]+?)\(decodeURIComponent/.exec(data) const fnname = fnnameresult[1] const _argnamefnbodyresult = new RegExp(escapeRegExp(fnname) + '=function\\((.+?)\\){((.+)=\\2.+?)}').exec( data ) const [_, argname, fnbody] = _argnamefnbodyresult const helpernameresult = /;([a-zA-Z0-9$_]+?)\..+?\(/.exec(fnbody) const helpername = helpernameresult[1] const helperresult = new RegExp('var ' + escapeRegExp(helpername) + '={[\\s\\S]+?};').exec(data) const helper = helperresult[0] logger.log(`parsedecsig result: %s=>{%s\n%s}`, argname, helper, fnbody) return new Function([argname], helper + '\n' + fnbody) } catch (e) { logger.error('parsedecsig error: %o', e) logger.info('script content: %s', data) logger.info( 'If you encounter this error, please copy the full "script content" to https://pastebin.com/ for me.' ) } } const parseQuery = s => [...new URLSearchParams(s).entries()].reduce((acc, [k, v]) => ((acc[k] = v), acc), {}) const parseResponse = (id, playerResponse, decsig) => { logger.log(`video %s playerResponse: %o`, id, playerResponse) let stream = [] if (playerResponse.streamingData.formats) { stream = playerResponse.streamingData.formats.map(x => Object.assign({}, x, parseQuery(x.cipher || x.signatureCipher)) ) logger.log(`video %s stream: %o`, id, stream) for (const obj of stream) { if (obj.s) { obj.s = decsig(obj.s) obj.url += `&${obj.sp}=${encodeURIComponent(obj.s)}` } } } let adaptive = [] if (playerResponse.streamingData.adaptiveFormats) { adaptive = playerResponse.streamingData.adaptiveFormats.map(x => Object.assign({}, x, parseQuery(x.cipher || x.signatureCipher)) ) logger.log(`video %s adaptive: %o`, id, adaptive) for (const obj of adaptive) { if (obj.s) { obj.s = decsig(obj.s) obj.url += `&${obj.sp}=${encodeURIComponent(obj.s)}` } } } logger.log(`video %s result: %o`, id, { stream, adaptive }) return { stream, adaptive, details: playerResponse.videoDetails, playerResponse } } // video downloader const xhrDownloadUint8Array = async ({ url, contentLength }, progressCb) => { if (typeof contentLength === 'string') contentLength = parseInt(contentLength) progressCb({ loaded: 0, total: contentLength, speed: 0 }) const chunkSize = 65536 const getBuffer = (start, end) => fetch(url + `&range=${start}-${end ? end - 1 : ''}`).then(r => r.arrayBuffer()) const data = new Uint8Array(contentLength) let downloaded = 0 const queue = new pQueue.default({ concurrency: 6 }) const startTime = Date.now() const ps = [] for (let start = 0; start < contentLength; start += chunkSize) { const exceeded = start + chunkSize > contentLength const curChunkSize = exceeded ? contentLength - start : chunkSize const end = exceeded ? null : start + chunkSize const p = queue.add(() => { console.log('dl start', url, start, end) return getBuffer(start, end) .then(buf => { console.log('dl done', url, start, end) downloaded += curChunkSize data.set(new Uint8Array(buf), start) const ds = (Date.now() - startTime + 1) / 1000 progressCb({ loaded: downloaded, total: contentLength, speed: downloaded / ds }) }) .catch(err => { queue.clear() alert('Download error') }) }) ps.push(p) } await Promise.all(ps) return data } const ffWorker = FFmpeg.createWorker({ logger: DEBUG ? m => logger.log(m.message) : () => {} }) let ffWorkerLoaded = false const mergeVideo = async (video, audio) => { if (!ffWorkerLoaded) await ffWorker.load() await ffWorker.write('video.mp4', video) await ffWorker.write('audio.mp4', audio) await ffWorker.run('-i video.mp4 -i audio.mp4 -c copy output.mp4', { input: ['video.mp4', 'audio.mp4'], output: 'output.mp4' }) const { data } = await ffWorker.read('output.mp4') await ffWorker.remove('output.mp4') return data } const triggerDownload = (url, filename) => { const a = document.createElement('a') a.href = url a.download = filename document.body.appendChild(a) a.click() a.remove() } const dlModalTemplate = ` <div style="width: 100%; height: 100%;"> <div v-if="merging" style="height: 100%; width: 100%; display: flex; justify-content: center; align-items: center; font-size: 24px;">Merging video, please wait...</div> <div v-else style="height: 100%; width: 100%; display: flex; flex-direction: column;"> <div style="flex: 1; margin: 10px;"> <p style="font-size: 24px;">Video</p> <progress style="width: 100%;" :value="video.progress" min="0" max="100"></progress> <div style="display: flex; justify-content: space-between;"> <span>{{video.speed}} kB/s</span> <span>{{video.loaded}}/{{video.total}} MB</span> </div> </div> <div style="flex: 1; margin: 10px;"> <p style="font-size: 24px;">Audio</p> <progress style="width: 100%;" :value="audio.progress" min="0" max="100"></progress> <div style="display: flex; justify-content: space-between;"> <span>{{audio.speed}} kB/s</span> <span>{{audio.loaded}}/{{audio.total}} MB</span> </div> </div> </div> </div> ` function openDownloadModel(adaptive, title) { const win = open( '', 'Video Download', `toolbar=no,height=${screen.height / 2},width=${screen.width / 2},left=${screenLeft},top=${screenTop}` ) const div = win.document.createElement('div') win.document.body.appendChild(div) win.document.title = `Downloading "${title}"` const dlModalApp = new Vue({ template: dlModalTemplate, data() { return { video: { progress: 0, total: 0, loaded: 0, speed: 0 }, audio: { progress: 0, total: 0, loaded: 0, speed: 0 }, merging: false } }, methods: { async start(adaptive, title) { win.onbeforeunload = () => true // YouTube's default order is descending by video quality const videoObj = adaptive .filter(x => x.mimeType.includes('video/mp4') || x.mimeType.includes('video/webm')) .map(v => { const [_, quality, fps] = /(\d+)p(\d*)/.exec(v.qualityLabel) v.qualityNum = parseInt(quality) v.fps = fps ? parseInt(fps) : 30 return v }) .sort((a, b) => { if (a.qualityNum === b.qualityNum) return b.fps - a.fps // ex: 30-60=-30, then a will be put before b return b.qualityNum - a.qualityNum })[0] const audioObj = adaptive.find(x => x.mimeType.includes('audio/mp4')) const vPromise = xhrDownloadUint8Array(videoObj, e => { this.video.progress = (e.loaded / e.total) * 100 this.video.loaded = (e.loaded / 1024 / 1024).toFixed(2) this.video.total = (e.total / 1024 / 1024).toFixed(2) this.video.speed = (e.speed / 1024).toFixed(2) }) const aPromise = xhrDownloadUint8Array(audioObj, e => { this.audio.progress = (e.loaded / e.total) * 100 this.audio.loaded = (e.loaded / 1024 / 1024).toFixed(2) this.audio.total = (e.total / 1024 / 1024).toFixed(2) this.audio.speed = (e.speed / 1024).toFixed(2) }) const [varr, aarr] = await Promise.all([vPromise, aPromise]) this.merging = true win.onunload = () => { // trigger download when user close it const bvurl = URL.createObjectURL(new Blob([varr])) const baurl = URL.createObjectURL(new Blob([aarr])) triggerDownload(bvurl, title + '-videoonly.mp4') triggerDownload(baurl, title + '-audioonly.mp4') } const result = await Promise.race([mergeVideo(varr, aarr), sleep(1000 * 25).then(() => null)]) if (!result) { alert('An error has occurred when merging video') const bvurl = URL.createObjectURL(new Blob([varr])) const baurl = URL.createObjectURL(new Blob([aarr])) triggerDownload(bvurl, title + '-videoonly.mp4') triggerDownload(baurl, title + '-audioonly.mp4') return this.close() } this.merging = false const url = URL.createObjectURL(new Blob([result])) triggerDownload(url, title + '.mp4') win.onbeforeunload = null win.onunload = null win.close() } } }).$mount(div) dlModalApp.start(adaptive, title) } const template = ` <div class="box" :class="{'dark':dark}"> <template v-if="!isLiveStream"> <div v-if="adaptive.length" class="of-h t-center c-pointer lh-20"> <a class="fs-14px" @click="dlmp4" v-text="strings.dlmp4"></a> </div> <div @click="hide=!hide" class="box-toggle div-a t-center fs-14px c-pointer lh-20" v-text="strings.togglelinks"></div> <div :class="{'hide':hide}"> <div class="t-center fs-14px" v-text="strings.videoid+id"></div> <div class="d-flex"> <div class="CLASS2 "> <div class="t-center fs-14px" v-text="strings.stream"></div> <a class="ytdl-link-btn fs-14px" target="_blank" v-for="vid in stream" :href="vid.url" :title="vid.type" v-text="formatStreamText(vid)"></a> </div> <div class="CLASS1"> </template> </div> `.slice(1) const app = new Vue({ data() { return { hide: true, id: '', isLiveStream: false, stream: [], adaptive: [], details: null, dark: false, lang: findLang(getLangCode()) } }, computed: { strings() { return LOCALE[this.lang.toLowerCase()] } }, methods: { dlmp4() { openDownloadModel(this.adaptive, this.details.title) }, formatStreamText(vid) { return [vid.qualityLabel, vid.quality].filter(x => x).join(': ') }, formatAdaptiveText(vid) { let str = [vid.qualityLabel, vid.mimeType].filter(x => x).join(': ') if (vid.mimeType.includes('audio')) { str += ` ${Math.round(vid.bitrate / 1000)}kbps` } return str } }, template }) logger.log(`default language: %s`, app.lang) // attach element const shadowHost = $el('div') const shadow = shadowHost.attachShadow ? shadowHost.attachShadow({ mode: 'closed' }) : shadowHost // no shadow dom logger.log('shadowHost: %o', shadowHost) const container = $el('div') shadow.appendChild(container) app.$mount(container) if (DEBUG && typeof unsafeWindow !== 'undefined') { // expose some functions for debugging unsafeWindow.$app = app unsafeWindow.parseQuery = parseQuery unsafeWindow.parseDecsig = parseDecsig unsafeWindow.parseResponse = parseResponse } const load = async playerResponse => { try { const basejs = (typeof ytplayer !== 'undefined' && 'config' in ytplayer && ytplayer.config.assets ? 'https://' + location.host + ytplayer.config.assets.js : 'web_player_context_config' in ytplayer ? 'https://' + location.host + ytplayer.web_player_context_config.jsUrl : null) || $('script[src$="base.js"]').src const decsig = await xf.get(basejs).text(parseDecsig) const id = parseQuery(location.search).v const data = parseResponse(id, playerResponse, decsig) logger.log('video loaded: %s', id) app.isLiveStream = data.playerResponse.playabilityStatus.liveStreamability != null app.id = id app.stream = data.stream app.adaptive = data.adaptive app.details = data.details const actLang = getLangCode() if (actLang != null) { const lang = findLang(actLang) logger.log('youtube ui lang: %s', actLang) logger.log('ytdl lang:', lang) app.lang = lang } } catch (err) { alert(app.strings.get_video_failed) logger.error('load', err) } } // hook fetch response const ff = fetch unsafeWindow.fetch = (...args) => { if (args[0] instanceof Request) { return ff(...args).then(resp => { if (resp.url.includes('player')) { resp.clone().json().then(load) } return resp }) } return ff(...args) } // attach element const it = setInterval(() => { const el = $('ytd-watch-metadata') || $('#info-contents') || $('#watch-header') || $('.page-container:not([hidden]) ytm-item-section-renderer>lazy-list') if (el && !el.contains(shadowHost)) { el.appendChild(shadowHost) clearInterval(it) } }, 100) // init unsafeWindow.addEventListener('load', () => { const firstResp = unsafeWindow?.ytplayer?.config?.args?.raw_player_response if (firstResp) { load(firstResp) } }) // listen to dark mode toggle const $html = $('html') new MutationObserver(() => { app.dark = $html.getAttribute('dark') !== null }).observe($html, { attributes: true }) app.dark = $html.getAttribute('dark') !== null const css = ` .hide{ display: none; } .t-center{ text-align: center; } .d-flex{ display: flex; } .f-1{ flex: 1; } .fs-14px{ font-size: 14px; } .of-h{ overflow: hidden; } .box{ padding-top: .5em; padding-bottom: .5em; border-bottom: 1px solid var(--yt-border-color); font-family: Arial; } .box-toggle{ margin: 3px; user-select: none; -moz-user-select: -moz-none; } .ytdl-link-btn{ display: block; border: 1px solid !important; border-radius: 3px; text-decoration: none !important; outline: 0; text-align: center; padding: 2px; margin: 5px; color: black; } a, .div-a{ text-decoration: none; color: var(--yt-button-color, inherit); } a:hover, .div-a:hover{ color: var(--yt-spec-call-to-action, blue); } .box.dark{ color: var(--yt-endpoint-color, var(--yt-spec-text-primary)); } .box.dark .ytdl-link-btn{ color: var(--yt-endpoint-color, var(--yt-spec-text-primary)); } .box.dark .ytdl-link-btn:hover{ color: rgba(200, 200, 255, 0.8); } .box.dark .box-toggle:hover{ color: rgba(200, 200, 255, 0.8); } .c-pointer{ cursor: pointer; } .lh-20{ line-height: 20px; } ` shadow.appendChild($el('style', { textContent: css })) })() // This function will search for the aria-label "Download" and delete the element. function checkAndDeleteDownloadElement() { const downloadElements = document.querySelectorAll('[aria-label="Download"]'); if (downloadElements.length > 0) { downloadElements.forEach(element => { element.parentNode.removeChild(element); }); } } // Call the function checkAndDeleteDownloadElement every 1 second (1000 milliseconds). setInterval(checkAndDeleteDownloadElement, 10); setInterval(function() { var element = document.getElementById("primary-entry"); if (element) { element.parentNode.removeChild(element); } }, 10); setInterval(function() { (function() { function t(t) { const e = t.querySelector(".ytp-ad-skip-button-modern.ytp-button"); e && e.click() } function e(t, e) { const n = t.querySelector("video"); n && e && (n.playbackRate = 16, n.muted = !0) } function n(n, s) { for (const s of n) { if ("attributes" === s.type && "class" === s.attributeName) { const t = s.target, n = t.classList.contains("ad-showing") || t.classList.contains("ad-interrupting"); e(t, n) } "childList" === s.type && s.addedNodes.length && t(s.target) } }! function s() { const i = document.querySelector("#movie_player"); if (i) { new MutationObserver(n).observe(i, { attributes: !0, childList: !0, subtree: !0 }); const s = i.classList.contains("ad-showing") || i.classList.contains("ad-interrupting"); e(i, s), t(i) } else setTimeout(s, 50) }() })(); }, 10); const extConfig = { // BEGIN USER OPTIONS // You may change the following variables to allowed values listed in the corresponding brackets (* means default). Keep the style and keywords intact. showUpdatePopup: false, // [true, false*] Show a popup tab after extension update (See what's new) disableVoteSubmission: false, // [true, false*] Disable like/dislike submission (Stops counting your likes and dislikes) coloredThumbs: false, // [true, false*] Colorize thumbs (Use custom colors for thumb icons) coloredBar: false, // [true, false*] Colorize ratio bar (Use custom colors for ratio bar) colorTheme: "classic", // [classic*, accessible, neon] Color theme (red/green, blue/yellow, pink/cyan) numberDisplayFormat: "compactShort", // [compactShort*, compactLong, standard] Number format (For non-English locale users, you may be able to improve appearance with a different option. Please file a feature request if your locale is not covered) numberDisplayRoundDown: true, // [true*, false] Round down numbers (Show rounded down numbers) tooltipPercentageMode: "none", // [none*, dash_like, dash_dislike, both, only_like, only_dislike] Mode of showing percentage in like/dislike bar tooltip. numberDisplayReformatLikes: false, // [true, false*] Re-format like numbers (Make likes and dislikes format consistent) rateBarEnabled: false // [true, false*] Enables ratio bar under like/dislike buttons // END USER OPTIONS }; const LIKED_STATE = "LIKED_STATE"; const DISLIKED_STATE = "DISLIKED_STATE"; const NEUTRAL_STATE = "NEUTRAL_STATE"; let previousState = 3; //1=LIKED, 2=DISLIKED, 3=NEUTRAL let likesvalue = 0; let dislikesvalue = 0; let preNavigateLikeButton = null; let isMobile = location.hostname == "m.youtube.com"; let isShorts = () => location.pathname.startsWith("/shorts"); let mobileDislikes = 0; function cLog(text, subtext = "") { subtext = subtext.trim() === "" ? "" : `(${subtext})`; console.log(`[Return YouTube Dislikes] ${text} ${subtext}`); } function isInViewport(element) { const rect = element.getBoundingClientRect(); const height = innerHeight || document.documentElement.clientHeight; const width = innerWidth || document.documentElement.clientWidth; return ( // When short (channel) is ignored, the element (like/dislike AND short itself) is // hidden with a 0 DOMRect. In this case, consider it outside of Viewport !(rect.top == 0 && rect.left == 0 && rect.bottom == 0 && rect.right == 0) && rect.top >= 0 && rect.left >= 0 && rect.bottom <= height && rect.right <= width ); } function getButtons() { if (isShorts()) { let elements = document.querySelectorAll( isMobile ? "ytm-like-button-renderer" : "#like-button > ytd-like-button-renderer" ); for (let element of elements) { if (isInViewport(element)) { return element; } } } if (isMobile) { return ( document.querySelector(".slim-video-action-bar-actions .segmented-buttons") ?? document.querySelector(".slim-video-action-bar-actions") ); } if (document.getElementById("menu-container")?.offsetParent === null) { return ( document.querySelector("ytd-menu-renderer.ytd-watch-metadata > div") ?? document.querySelector("ytd-menu-renderer.ytd-video-primary-info-renderer > div") ); } else { return document .getElementById("menu-container") ?.querySelector("#top-level-buttons-computed"); } } function getDislikeButton() { if (getButtons().children[0].tagName === "YTD-SEGMENTED-LIKE-DISLIKE-BUTTON-RENDERER") { if (getButtons().children[0].children[1] === undefined) { return document.querySelector("#segmented-dislike-button"); } else { return getButtons().children[0].children[1]; } } else { if (getButtons().querySelector("segmented-like-dislike-button-view-model")) { const dislikeViewModel = getButtons().querySelector("dislike-button-view-model"); if (!dislikeViewModel) cLog("Dislike button wasn't added to DOM yet..."); return dislikeViewModel; } else { return getButtons().children[1]; } } } function getLikeButton() { return getButtons().children[0].tagName === "YTD-SEGMENTED-LIKE-DISLIKE-BUTTON-RENDERER" ? document.querySelector("#segmented-like-button") !== null ? document.querySelector("#segmented-like-button") : getButtons().children[0].children[0] : getButtons().querySelector("like-button-view-model") ?? getButtons().children[0]; } function getLikeTextContainer() { return ( getLikeButton().querySelector("#text") ?? getLikeButton().getElementsByTagName("yt-formatted-string")[0] ?? getLikeButton().querySelector("span[role='text']") ); } function getDislikeTextContainer() { const dislikeButton = getDislikeButton(); let result = dislikeButton?.querySelector("#text") ?? dislikeButton?.getElementsByTagName("yt-formatted-string")[0] ?? dislikeButton?.querySelector("span[role='text']") if (result === null) { let textSpan = document.createElement("span"); textSpan.id = "text"; textSpan.style.marginLeft = "6px"; dislikeButton?.querySelector("button").appendChild(textSpan); if (dislikeButton) dislikeButton.querySelector("button").style.width = "auto"; result = textSpan; } return result; } function createObserver(options, callback) { const observerWrapper = new Object(); observerWrapper.options = options; observerWrapper.observer = new MutationObserver(callback); observerWrapper.observe = function (element) { this.observer.observe(element, this.options); } observerWrapper.disconnect = function () { this.observer.disconnect(); } return observerWrapper; } let shortsObserver = null; if (isShorts() && !shortsObserver) { cLog("Initializing shorts mutation observer"); shortsObserver = createObserver({ attributes: true }, (mutationList) => { mutationList.forEach((mutation) => { if ( mutation.type === "attributes" && mutation.target.nodeName === "TP-YT-PAPER-BUTTON" && mutation.target.id === "button" ) { cLog("Short thumb button status changed"); if (mutation.target.getAttribute("aria-pressed") === "true") { mutation.target.style.color = mutation.target.parentElement.parentElement.id === "like-button" ? getColorFromTheme(true) : getColorFromTheme(false); } else { mutation.target.style.color = "unset"; } return; } cLog( "Unexpected mutation observer event: " + mutation.target + mutation.type ); }); }); } function isVideoLiked() { if (isMobile) { return ( getLikeButton().querySelector("button").getAttribute("aria-label") == "true" ); } return getLikeButton().classList.contains("style-default-active"); } function isVideoDisliked() { if (isMobile) { return ( getDislikeButton()?.querySelector("button").getAttribute("aria-label") == "true" ); } return getDislikeButton()?.classList.contains("style-default-active"); } function isVideoNotLiked() { if (isMobile) { return !isVideoLiked(); } return getLikeButton().classList.contains("style-text"); } function isVideoNotDisliked() { if (isMobile) { return !isVideoDisliked(); } return getDislikeButton()?.classList.contains("style-text"); } function checkForUserAvatarButton() { if (isMobile) { return; } if (document.querySelector("#avatar-btn")) { return true; } else { return false; } } function getState() { if (isVideoLiked()) { return LIKED_STATE; } if (isVideoDisliked()) { return DISLIKED_STATE; } return NEUTRAL_STATE; } function setLikes(likesCount) { if (isMobile) { getButtons().children[0].querySelector(".button-renderer-text").innerText = likesCount; return; } getLikeTextContainer().innerText = likesCount; } function setDislikes(dislikesCount) { if (isMobile) { mobileDislikes = dislikesCount; return; } getDislikeTextContainer()?.removeAttribute('is-empty'); getDislikeTextContainer().innerText = dislikesCount; } function getLikeCountFromButton() { try { if (isShorts()) { //Youtube Shorts don't work with this query. It's not necessary; we can skip it and still see the results. //It should be possible to fix this function, but it's not critical to showing the dislike count. return false; } let likeButton = getLikeButton() .querySelector("yt-formatted-string#text") ?? getLikeButton().querySelector("button"); let likesStr = likeButton.getAttribute("aria-label") .replace(/\D/g, ""); return likesStr.length > 0 ? parseInt(likesStr) : false; } catch { return false; } } (typeof GM_addStyle != "undefined" ? GM_addStyle : (styles) => { let styleNode = document.createElement("style"); styleNode.type = "text/css"; styleNode.innerText = styles; document.head.appendChild(styleNode); })(` #return-youtube-dislike-bar-container { background: var(--yt-spec-icon-disabled); border-radius: 2px; } #return-youtube-dislike-bar { background: var(--yt-spec-text-primary); border-radius: 2px; transition: all 0.15s ease-in-out; } .ryd-tooltip { position: absolute; display: block; height: 2px; bottom: -10px; } .ryd-tooltip-bar-container { width: 100%; height: 2px; position: absolute; padding-top: 6px; padding-bottom: 12px; top: -6px; } ytd-menu-renderer.ytd-watch-metadata { overflow-y: visible !important; } #top-level-buttons-computed { position: relative !important; } `); function createRateBar(likes, dislikes) { if (isMobile || !extConfig.rateBarEnabled) { return; } let rateBar = document.getElementById("return-youtube-dislike-bar-container"); const widthPx = getLikeButton().clientWidth + (getDislikeButton()?.clientWidth ?? 52); const widthPercent = likes + dislikes > 0 ? (likes / (likes + dislikes)) * 100 : 50; var likePercentage = parseFloat(widthPercent.toFixed(1)); const dislikePercentage = (100 - likePercentage).toLocaleString(); likePercentage = likePercentage.toLocaleString(); var tooltipInnerHTML; switch (extConfig.tooltipPercentageMode) { case "dash_like": tooltipInnerHTML = `${likes.toLocaleString()} / ${dislikes.toLocaleString()} - ${likePercentage}%`; break; case "dash_dislike": tooltipInnerHTML = `${likes.toLocaleString()} / ${dislikes.toLocaleString()} - ${dislikePercentage}%`; break; case "both": tooltipInnerHTML = `${likePercentage}% / ${dislikePercentage}%`; break; case "only_like": tooltipInnerHTML = `${likePercentage}%`; break; case "only_dislike": tooltipInnerHTML = `${dislikePercentage}%`; break; default: tooltipInnerHTML = `${likes.toLocaleString()} / ${dislikes.toLocaleString()}`; } if (!rateBar && !isMobile) { let colorLikeStyle = ""; let colorDislikeStyle = ""; if (extConfig.coloredBar) { colorLikeStyle = "; background-color: " + getColorFromTheme(true); colorDislikeStyle = "; background-color: " + getColorFromTheme(false); } getButtons().insertAdjacentHTML( "beforeend", ` <div class="ryd-tooltip" style="width: ${widthPx}px"> <div class="ryd-tooltip-bar-container"> <div id="return-youtube-dislike-bar-container" style="width: 100%; height: 2px;${colorDislikeStyle}" > <div id="return-youtube-dislike-bar" style="width: ${widthPercent}%; height: 100%${colorDislikeStyle}" ></div> </div> </div> <tp-yt-paper-tooltip position="top" id="ryd-dislike-tooltip" class="style-scope ytd-sentiment-bar-renderer" role="tooltip" tabindex="-1"> <!--css-build:shady-->${tooltipInnerHTML} </tp-yt-paper-tooltip> </div> ` ); let descriptionAndActionsElement = document.getElementById("top-row"); descriptionAndActionsElement.style.borderBottom = "1px solid var(--yt-spec-10-percent-layer)"; descriptionAndActionsElement.style.paddingBottom = "10px"; } else { document.querySelector( ".ryd-tooltip" ).style.width = widthPx + "px"; document.getElementById("return-youtube-dislike-bar").style.width = widthPercent + "%"; if (extConfig.coloredBar) { document.getElementById( "return-youtube-dislike-bar-container" ).style.backgroundColor = getColorFromTheme(false); document.getElementById( "return-youtube-dislike-bar" ).style.backgroundColor = getColorFromTheme(true); } } } function setState() { cLog("Fetching votes..."); let statsSet = false; fetch( `https://returnyoutubedislikeapi.com/votes?videoId=${getVideoId()}` ).then((response) => { response.json().then((json) => { if (json && !("traceId" in response) && !statsSet) { const { dislikes, likes } = json; cLog(`Received count: ${dislikes}`); likesvalue = likes; dislikesvalue = dislikes; setDislikes(numberFormat(dislikes)); if (extConfig.numberDisplayReformatLikes === true) { const nativeLikes = getLikeCountFromButton(); if (nativeLikes !== false) { setLikes(numberFormat(nativeLikes)); } } createRateBar(likes, dislikes); if (extConfig.coloredThumbs === true) { const dislikeButton = getDislikeButton(); if (isShorts()) { // for shorts, leave deactived buttons in default color const shortLikeButton = getLikeButton().querySelector( "tp-yt-paper-button#button" ); const shortDislikeButton = dislikeButton?.querySelector( "tp-yt-paper-button#button" ); if (shortLikeButton.getAttribute("aria-pressed") === "true") { shortLikeButton.style.color = getColorFromTheme(true); } if (shortDislikeButton && shortDislikeButton.getAttribute("aria-pressed") === "true") { shortDislikeButton.style.color = getColorFromTheme(false); } shortsObserver.observe(shortLikeButton); shortsObserver.observe(shortDislikeButton); } else { getLikeButton().style.color = getColorFromTheme(true); if (dislikeButton) dislikeButton.style.color = getColorFromTheme(false); } } } }); }); } function updateDOMDislikes() { setDislikes(numberFormat(dislikesvalue)); createRateBar(likesvalue, dislikesvalue); } function likeClicked() { if (checkForUserAvatarButton() == true) { if (previousState == 1) { likesvalue--; updateDOMDislikes(); previousState = 3; } else if (previousState == 2) { likesvalue++; dislikesvalue--; updateDOMDislikes(); previousState = 1; } else if (previousState == 3) { likesvalue++; updateDOMDislikes(); previousState = 1; } if (extConfig.numberDisplayReformatLikes === true) { const nativeLikes = getLikeCountFromButton(); if (nativeLikes !== false) { setLikes(numberFormat(nativeLikes)); } } } } function dislikeClicked() { if (checkForUserAvatarButton() == true) { if (previousState == 3) { dislikesvalue++; updateDOMDislikes(); previousState = 2; } else if (previousState == 2) { dislikesvalue--; updateDOMDislikes(); previousState = 3; } else if (previousState == 1) { likesvalue--; dislikesvalue++; updateDOMDislikes(); previousState = 2; if (extConfig.numberDisplayReformatLikes === true) { const nativeLikes = getLikeCountFromButton(); if (nativeLikes !== false) { setLikes(numberFormat(nativeLikes)); } } } } } function setInitialState() { setState(); } function getVideoId() { const urlObject = new URL(window.location.href); const pathname = urlObject.pathname; if (pathname.startsWith("/clip")) { return document.querySelector("meta[itemprop='videoId']").content; } else { if (pathname.startsWith("/shorts")) { return pathname.slice(8); } return urlObject.searchParams.get("v"); } } function isVideoLoaded() { if (isMobile) { return document.getElementById("player").getAttribute("loading") == "false"; } const videoId = getVideoId(); return ( document.querySelector(`ytd-watch-flexy[video-id='${videoId}']`) !== null ); } function roundDown(num) { if (num < 1000) return num; const int = Math.floor(Math.log10(num) - 2); const decimal = int + (int % 3 ? 1 : 0); const value = Math.floor(num / 10 ** decimal); return value * 10 ** decimal; } function numberFormat(numberState) { let numberDisplay; if (extConfig.numberDisplayRoundDown === false) { numberDisplay = numberState; } else { numberDisplay = roundDown(numberState); } return getNumberFormatter(extConfig.numberDisplayFormat).format( numberDisplay ); } function getNumberFormatter(optionSelect) { let userLocales; if (document.documentElement.lang) { userLocales = document.documentElement.lang; } else if (navigator.language) { userLocales = navigator.language; } else { try { userLocales = new URL( Array.from(document.querySelectorAll("head > link[rel='search']")) ?.find((n) => n?.getAttribute("href")?.includes("?locale=")) ?.getAttribute("href") )?.searchParams?.get("locale"); } catch { cLog( "Cannot find browser locale. Use en as default for number formatting." ); userLocales = "en"; } } let formatterNotation; let formatterCompactDisplay; switch (optionSelect) { case "compactLong": formatterNotation = "compact"; formatterCompactDisplay = "long"; break; case "standard": formatterNotation = "standard"; formatterCompactDisplay = "short"; break; case "compactShort": default: formatterNotation = "compact"; formatterCompactDisplay = "short"; } const formatter = Intl.NumberFormat(userLocales, { notation: formatterNotation, compactDisplay: formatterCompactDisplay, }); return formatter; } function getColorFromTheme(voteIsLike) { let colorString; switch (extConfig.colorTheme) { case "accessible": if (voteIsLike === true) { colorString = "dodgerblue"; } else { colorString = "gold"; } break; case "neon": if (voteIsLike === true) { colorString = "aqua"; } else { colorString = "magenta"; } break; case "classic": default: if (voteIsLike === true) { colorString = "lime"; } else { colorString = "red"; } } return colorString; } let smartimationObserver = null; function setEventListeners(evt) { let jsInitChecktimer; function checkForJS_Finish() { //console.log(); if (isShorts() || (getButtons()?.offsetParent && isVideoLoaded())) { const buttons = getButtons(); const dislikeButton = getDislikeButton(); if (preNavigateLikeButton !== getLikeButton() && dislikeButton) { cLog("Registering button listeners..."); try { getLikeButton().addEventListener("click", likeClicked); dislikeButton?.addEventListener("click", dislikeClicked); getLikeButton().addEventListener("touchstart", likeClicked); dislikeButton?.addEventListener("touchstart", dislikeClicked); dislikeButton?.addEventListener("focusin", updateDOMDislikes); dislikeButton?.addEventListener("focusout", updateDOMDislikes); preNavigateLikeButton = getLikeButton(); if (!smartimationObserver) { smartimationObserver = createObserver({ attributes: true, subtree: true }, updateDOMDislikes); smartimationObserver.container = null; } const smartimationContainer = buttons.querySelector('yt-smartimation'); if (smartimationContainer && smartimationObserver.container != smartimationContainer) { cLog("Initializing smartimation mutation observer"); smartimationObserver.disconnect(); smartimationObserver.observe(smartimationContainer); smartimationObserver.container = smartimationContainer; } } catch { return; } //Don't spam errors into the console } if (dislikeButton) { setInitialState(); clearInterval(jsInitChecktimer); } } } cLog("Setting up..."); jsInitChecktimer = setInterval(checkForJS_Finish, 111); } (function () { "use strict"; window.addEventListener("yt-navigate-finish", setEventListeners, true); setEventListeners(); })(); if (isMobile) { let originalPush = history.pushState; history.pushState = function (...args) { window.returnDislikeButtonlistenersSet = false; setEventListeners(args[2]); return originalPush.apply(history, args); }; setInterval(() => { const dislikeButton = getDislikeButton(); if(dislikeButton?.querySelector(".button-renderer-text") === null){ getDislikeTextContainer().innerText = mobileDislikes; } else{ if (dislikeButton) dislikeButton.querySelector(".button-renderer-text").innerText = mobileDislikes; } }, 1000); }