Return YouTube Dislike (PC Only)

Desktop-only fork of Return YouTube Dislike. Adds Shorts SPA support, visual like/dislike ratio bar, and a class-based architecture.

// ==UserScript==
// @name         Return YouTube Dislike (PC Only)
// @name:ja      Return YouTube Dislike(PC専用改良版)
// @namespace    https://github.com/koyasi777/return-youtube-dislike-pc-only
// @homepage     https://github.com/koyasi777/return-youtube-dislike-pc-only
// @version      4.1.0
// @encoding     utf-8
// @description  Desktop-only fork of Return YouTube Dislike. Adds Shorts SPA support, visual like/dislike ratio bar, and a class-based architecture.
// @description:ja  デスクトップ専用に改良した Return YouTube Dislike。Shorts対応、評価バー表示、クラスベース設計を追加。
// @icon         https://github.com/Anarios/return-youtube-dislike/raw/main/Icons/Return%20Youtube%20Dislike%20-%20Transparent.png
// @author       Anarios & JRWR (original), koyasi777 (mod)
// @license      AGPL-3.0-only
// @licenseURL   https://www.gnu.org/licenses/agpl-3.0.html
// @match        *://*.youtube.com/*
// @exclude      *://music.youtube.com/*
// @exclude      *://*.music.youtube.com/*
// @compatible   chrome
// @compatible   firefox
// @compatible   opera
// @compatible   safari
// @compatible   edge
// @grant        GM.xmlHttpRequest
// @grant        GM_addStyle
// @connect      youtube.com
// @run-at       document-end
// ==/UserScript==

(function () {
    'use strict';

    class ReturnYouTubeDislike {
        // --- Static Properties ---
        static config = { disableLogging: true, coloredThumbs: false, coloredBar: false, colorTheme: "classic", numberDisplayFormat: "compactShort", numberDisplayRoundDown: true, tooltipPercentageMode: "none", numberDisplayReformatLikes: false, rateBarEnabled: true };
        static API_URL = 'https://returnyoutubedislikeapi.com/votes?videoId=';
        static VOTE_STATE = { LIKED: 1, DISLIKED: 2, NEUTRAL: 3 };
        static STYLES = `
            #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: 1.5px; bottom: -10px; }
            #ryd-dislike-tooltip { visibility: hidden; opacity: 0; transition: opacity 0.15s ease-in-out; background-color: #616161; color: #fff; padding: 8px; border-radius: 2px; font-size: 12px; text-align: center; white-space: nowrap; }
            .ryd-tooltip:hover #ryd-dislike-tooltip { visibility: visible; opacity: 1; }
            .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; }
            #ryd-shorts-dislike-text { color: var(--yt-spec-text-primary); font-family: "Roboto", "Arial", sans-serif; font-size: 12px; font-weight: 500; text-align: center; margin-top: 4px; }
        `;

        // --- Instance Properties ---
        videoId = null;
        likes = 0;
        dislikes = 0;
        previousState = ReturnYouTubeDislike.VOTE_STATE.NEUTRAL;
        domCache = {};
        initInterval = null;
        rateBarObserver = null;
        debouncedUpdateRateBar = null;

        constructor() {
            this.log('Instance created.');
            this.injectStyles();
            this.debouncedUpdateRateBar = this.debounce(() => this.createOrUpdateRateBar(), 150);
            window.addEventListener('yt-navigate-finish', () => this.run(), true);
            this.run();
        }

        run() {
            if (this.initInterval) clearInterval(this.initInterval);
            this.initInterval = setInterval(() => this.checkForDOMReady(), 100);
        }

        checkForDOMReady() {
            const currentVideoId = this.getVideoId();
            if (!currentVideoId || (currentVideoId === this.videoId && this.domCache.buttons)) return;
            if (!currentVideoId && this.videoId) { this.cleanup(); return; }
            const buttons = this.getButtons();
            if (buttons?.offsetParent && (this.isShorts() || this.isRegularVideoLoaded())) {
                this.log('DOM is ready. Initializing main logic.');
                clearInterval(this.initInterval);
                this.initInterval = null;
                this.cleanup();
                this.main(currentVideoId, buttons);
            }
        }

        async main(videoId, buttons) {
            this.videoId = videoId;
            this.log(`Processing video ID: ${this.videoId}`);
            if (!this.cacheDomElements(buttons)) { this.logError('Required DOM elements not found. Aborting.'); return; }
            try {
                const data = await this.fetchVotes();
                this.dislikes = data.dislikes;
                this.likes = this.getNativeLikeCount() ?? data.likes;
                this.log(`Using like count: ${this.likes}`);
                this.previousState = this.getInitialVoteState();
                this.updateUI();
                this.setupEventListeners();
                this.setupRateBarObserver();
            } catch (error) {
                this.logError('Failed to fetch or process votes.', error);
            }
        }

        cleanup() {
            this.log('Cleaning up previous instance.');
            if (this.initInterval) clearInterval(this.initInterval);
            if (this.rateBarObserver) this.rateBarObserver.disconnect();
            this.initInterval = null;
            this.rateBarObserver = null;
            this.domCache = {};
            this.videoId = null;
        }

        setupRateBarObserver() {
            if (!ReturnYouTubeDislike.config.rateBarEnabled || this.isShorts() || !this.domCache.buttons) return;
            this.rateBarObserver = new MutationObserver(() => {
                this.log('Button container changed. Debouncing rate bar update.');
                this.debouncedUpdateRateBar();
            });
            this.rateBarObserver.observe(this.domCache.buttons, { childList: true, subtree: true, attributes: true });
        }

        /**
         * 🚀 [修正] 永続オブザーバーと自己再試行ポーリングを組み合わせたハイブリッド方式
         */
        createOrUpdateRateBar(retryCount = 0) {
            const { buttons, likeButton, dislikeButton } = this.domCache;
            if (!buttons || this.isShorts() || !likeButton || !dislikeButton) return;

            const likeButtonWidth = likeButton.clientWidth;
            const dislikeButtonWidth = dislikeButton.clientWidth;
            const totalWidth = likeButtonWidth + dislikeButtonWidth;

            // 起動時の描画タイミング問題に対応するため、自己再試行ロジックを復活させる
            if (totalWidth === 0) {
                const maxRetries = 10;
                if (retryCount < maxRetries) {
                    this.log(`Rate bar calculation skipped: button width is zero. Retrying... (${retryCount + 1}/${maxRetries})`);
                    setTimeout(() => this.createOrUpdateRateBar(retryCount + 1), 250);
                } else {
                    this.logError('Failed to get button width for rate bar after multiple retries.');
                }
                return;
            }

            let rateBarContainer = document.getElementById('return-youtube-dislike-bar-container');
            const totalVotes = this.likes + this.dislikes;
            const likePercentage = totalVotes > 0 ? (this.likes / totalVotes) * 100 : 50;

            if (!rateBarContainer) {
                this.log(`Creating rate bar. Width: ${totalWidth}px`);
                const tooltipHtml = this.getTooltipHtml(likePercentage);
                const colorLikeStyle = ReturnYouTubeDislike.config.coloredBar ? `background-color: ${this.getColor(true)};` : '';
                const colorDislikeStyle = ReturnYouTubeDislike.config.coloredBar ? `background-color: ${this.getColor(false)};` : '';
                const barHtml = `<div class="ryd-tooltip" style="width: ${totalWidth}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: ${likePercentage}%; height: 100%; ${colorLikeStyle}"></div></div></div><tp-yt-paper-tooltip position="top" id="ryd-dislike-tooltip" role="tooltip" tabindex="-1">${tooltipHtml}</tp-yt-paper-tooltip></div>`;
                buttons.insertAdjacentHTML('beforeend', barHtml);
            } else {
                this.log(`Updating rate bar. Width: ${totalWidth}px`);
                document.querySelector('.ryd-tooltip').style.width = `${totalWidth}px`;
                document.getElementById('return-youtube-dislike-bar').style.width = `${likePercentage}%`;
                document.getElementById('ryd-dislike-tooltip').innerHTML = this.getTooltipHtml(likePercentage);
            }
        }

        // --- Unchanged Helper Methods ---
        cacheDomElements(buttonsContainer){this.log("Caching DOM elements..."),this.domCache.buttons=buttonsContainer;const e=this.isShorts()?"#like-button":"ytd-segmented-like-dislike-button-renderer #like-button, ytd-toggle-button-renderer:first-of-type, like-button-view-model",t=this.isShorts()?"#dislike-button":"ytd-segmented-like-dislike-button-renderer #dislike-button, ytd-toggle-button-renderer:nth-of-type(2), dislike-button-view-model";if(this.domCache.likeButton=buttonsContainer.querySelector(e),this.domCache.dislikeButton=buttonsContainer.querySelector(t),!this.domCache.likeButton||!this.domCache.dislikeButton)return!1;if(this.isShorts()){let o=document.getElementById("ryd-shorts-dislike-text");o&&!o.isConnected&&(o=null),o||(o=document.createElement("span"),o.id="ryd-shorts-dislike-text",this.domCache.dislikeButton.insertAdjacentElement("afterend",o)),this.domCache.dislikeText=o}else{if(this.domCache.likeText=this.domCache.likeButton.querySelector("#text, .yt-spec-button-shape-next__button-text-content"),this.domCache.dislikeText=this.domCache.dislikeButton.querySelector("#text, .yt-spec-button-shape-next__button-text-content"),!this.domCache.dislikeText){let i=this.domCache.dislikeButton.querySelector("button")||this.domCache.dislikeButton;if(i.querySelector("#text"))return!0;let s=document.createElement("span");s.id="text",s.className="yt-spec-button-shape-next__button-text-content",s.style.marginLeft="6px",i.style.width="auto",i.appendChild(s),this.domCache.dislikeText=s}}return!0}
        isRegularVideoLoaded(){return document.querySelector(`ytd-watch-grid[video-id='${this.getVideoId()}']`)||document.querySelector(`ytd-watch-flexy[video-id='${this.getVideoId()}']`)}
        getButtons(){if(this.isShorts()){for(const e of document.querySelectorAll("#actions.ytd-reel-player-overlay-renderer")){const t=e.getBoundingClientRect();if(t.top>0&&t.bottom<window.innerHeight&&t.width>0&&t.height>0)return e}return null}return(document.getElementById("menu-container")?.offsetParent===null?document.querySelector("ytd-menu-renderer.ytd-watch-metadata > div")||document.querySelector("ytd-menu-renderer.ytd-video-primary-info-renderer > div"):document.getElementById("menu-container")?.querySelector("#top-level-buttons-computed"))}
        fetchVotes(){return new Promise((e,t)=>{GM.xmlHttpRequest({method:"GET",url:`${ReturnYouTubeDislike.API_URL}${this.videoId}`,onload:o=>{if(o.status>=200&&o.status<300)try{const n=JSON.parse(o.responseText);n&&typeof n.dislikes!="undefined"?(this.log(`Received counts: ${n.dislikes} dislikes, ${n.likes} likes.`),e(n)):t(new Error("Invalid API response format."))}catch(n){t(new Error("Failed to parse API response."))}else t(new Error(`API request failed with status: ${o.status}`))},onerror:e=>t(e),ontimeout:()=>t(new Error("API request timed out.")),timeout:15e3})})}
        getInitialVoteState(){const{likeButton:e,dislikeButton:t}=this.domCache;return e?.getAttribute("aria-pressed")==="true"||e?.classList.contains("style-default-active")?ReturnYouTubeDislike.VOTE_STATE.LIKED:t?.getAttribute("aria-pressed")==="true"||t?.classList.contains("style-default-active")?ReturnYouTubeDislike.VOTE_STATE.DISLIKED:ReturnYouTubeDislike.VOTE_STATE.NEUTRAL}
        getNativeLikeCount(){if(!this.domCache.likeButton)return null;try{const e=this.domCache.likeButton.querySelector("button")||this.domCache.likeButton,t=(e.getAttribute("aria-label")||"").replace(/\D/g,"");return t?parseInt(t,10):null}catch(e){return this.logError("Could not parse native like count from aria-label.",e),null}}
        updateUI(){this.updateDislikeCount(),ReturnYouTubeDislike.config.numberDisplayReformatLikes&&this.updateLikeCount(),ReturnYouTubeDislike.config.rateBarEnabled&&this.createOrUpdateRateBar(),ReturnYouTubeDislike.config.coloredThumbs&&this.applyThumbColors()}
        updateDislikeCount(){this.domCache.dislikeText&&(this.domCache.dislikeText.textContent=this.formatNumber(this.dislikes))}
        updateLikeCount(){this.domCache.likeText&&(this.domCache.likeText.textContent=this.formatNumber(this.likes))}
        applyThumbColors(){}
        setupEventListeners(){this.log("Registering button listeners..."),this.domCache.likeButton?.addEventListener("click",this.handleVoteClick.bind(this)),this.domCache.dislikeButton?.addEventListener("click",this.handleVoteClick.bind(this))}
        handleVoteClick(){if(this.isUserLoggedIn())setTimeout(()=>{const e=this.getInitialVoteState();if(e!==this.previousState)e===ReturnYouTubeDislike.VOTE_STATE.LIKED?(this.likes++,this.previousState===ReturnYouTubeDislike.VOTE_STATE.DISLIKED&&this.dislikes--):e===ReturnYouTubeDislike.VOTE_STATE.DISLIKED?(this.dislikes++,this.previousState===ReturnYouTubeDislike.VOTE_STATE.LIKED&&this.likes--):e===ReturnYouTubeDislike.VOTE_STATE.NEUTRAL&&(this.previousState===ReturnYouTubeDislike.VOTE_STATE.LIKED&&this.likes--,this.previousState===ReturnYouTubeDislike.VOTE_STATE.DISLIKED&&this.dislikes--),this.previousState=e,this.updateUI()},200)}
        debounce(e,t){let o=null;return(...n)=>{window.clearTimeout(o),o=window.setTimeout(()=>{e.apply(null,n)},t)}}
        log(e,t=""){ReturnYouTubeDislike.config.disableLogging||console.log(`[RYD] ${e}`,t)}
        logError(e,t){console.error(`[RYD] ERROR: ${e}`,t||"")}
        injectStyles(){GM_addStyle(ReturnYouTubeDislike.STYLES)}
        getVideoId(){const e=new URL(window.location.href);return e.pathname.startsWith("/shorts/")?e.pathname.split("/")[2]:e.pathname.startsWith("/clip/")?document.querySelector("meta[itemprop='videoId']")?.content||null:e.searchParams.get("v")}
        isShorts(){return window.location.pathname.startsWith("/shorts/")}
        isUserLoggedIn(){return document.querySelector("#avatar-btn")!==null}
        formatNumber(e){const t=ReturnYouTubeDislike.config.numberDisplayRoundDown?this.roundDown(e):e;return this.getNumberFormatter().format(t)}
        roundDown(e){if(e<1e3)return e;const t=Math.floor(Math.log10(e)-2),o=t+(t%3?1:0);return Math.floor(e/10**o)*10**o}
        getNumberFormatter(){const e=document.documentElement.lang||navigator.language||"en",t={compactShort:{notation:"compact",compactDisplay:"short"},compactLong:{notation:"compact",compactDisplay:"long"},standard:{notation:"standard"}};return new Intl.NumberFormat(e,t[ReturnYouTubeDislike.config.numberDisplayFormat]||t.compactShort)}
        getTooltipHtml(e){const t=100-e,o=this.likes.toLocaleString(),n=this.dislikes.toLocaleString();switch(ReturnYouTubeDislike.config.tooltipPercentageMode){case"dash_like":return`${o} / ${n} - ${e.toFixed(1)}%`;case"dash_dislike":return`${o} / ${n} - ${t.toFixed(1)}%`;case"both":return`${e.toFixed(1)}% / ${t.toFixed(1)}%`;case"only_like":return`${e.toFixed(1)}%`;case"only_dislike":return`${t.toFixed(1)}%`;default:return`${o} / ${n}`}}
        getColor(e){const t={classic:{like:"#00FF00",dislike:"#FF0000"},accessible:{like:"#2186F2",dislike:"#F8C100"},neon:{like:"#00FFFF",dislike:"#FF00FF"}},o=t[ReturnYouTubeDislike.config.colorTheme]||t.classic;return e?o.like:o.dislike}
    }

    new ReturnYouTubeDislike();
})();