diff --git a/src/config.js b/src/config.js index 43ea8551..d87aefdd 100644 --- a/src/config.js +++ b/src/config.js @@ -76,6 +76,13 @@ const configOptions = new Map([ default: false, desc: 'Bypass initial account selection on startup' } + ], + [ + 'enableReturnYouTubeDislike', + { + default: true, + desc: 'Show dislike counts (Return YouTube Dislike)' + } ] ]); diff --git a/src/return-dislikes.css b/src/return-dislikes.css new file mode 100644 index 00000000..5fc8ab0c --- /dev/null +++ b/src/return-dislikes.css @@ -0,0 +1,53 @@ +.ytaf-ryd-badge { + position: fixed; + top: 3.5rem; + right: 2.5rem; + z-index: 9998; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.8rem; + background-color: rgba(18, 18, 18, 0.85); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 1.2rem; + color: #ffffff; + font-family: 'Roboto', 'Arial', sans-serif; + font-size: 0.95rem; + font-weight: 500; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + transition: + opacity 0.3s ease, + transform 0.3s ease; + pointer-events: none; +} + +.ytaf-ryd-badge-hidden { + opacity: 0; + transform: translateY(-10px); + pointer-events: none; +} + +.ytaf-ryd-dislike-icon { + color: #ff5555; + font-weight: bold; +} + +.ytaf-ryd-ratio { + color: #aaaaaa; + font-size: 0.85rem; + margin-left: 0.2rem; +} + +/* Hide dislike text next to icon by default */ +[idomkey='dislike-button']:not(:focus):not(:hover) .ytaf-ryd-native-label { + display: none !important; +} + +/* Show native label cleanly on focus or hover */ +[idomkey='dislike-button']:focus .ytaf-ryd-native-label, +[idomkey='dislike-button']:hover .ytaf-ryd-native-label, +[idomkey='dislike-button'] .focused .ytaf-ryd-native-label { + display: inline-block !important; + font-weight: 500 !important; +} diff --git a/src/return-dislikes.ts b/src/return-dislikes.ts new file mode 100644 index 00000000..09c60109 --- /dev/null +++ b/src/return-dislikes.ts @@ -0,0 +1,239 @@ +import { configAddChangeListener, configRead } from './config.js'; +import { getPlayerManager, PlayerMode } from './player_api'; +import type { EventMapOf, PlayerManager, VideoID } from './player_api'; +import './return-dislikes.css'; +import { showNotification } from './ui.js'; + +interface RYDData { + id: string; + likes: number; + dislikes: number; + rating: number; + viewCount: number; + deleted: boolean; +} + +const RYD_API = 'https://returnyoutubedislikeapi.com/votes?videoId='; +const cache = new Map(); + +let badgeElement: HTMLElement | null = null; +let currentVideoID: VideoID | null = null; +let currentDislikeText: string | null = null; +let observer: MutationObserver | null = null; + +function isEnabled(): boolean { + return configRead('enableReturnYouTubeDislike'); +} + +function formatCount(num: number): string { + if (typeof num !== 'number' || isNaN(num)) return '0'; + if (num >= 1_000_000_000) { + return (num / 1_000_000_000).toFixed(1).replace(/\.0$/, '') + 'B'; + } + if (num >= 1_000_000) { + return (num / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M'; + } + if (num >= 1_000) { + return (num / 1_000).toFixed(1).replace(/\.0$/, '') + 'K'; + } + return num.toLocaleString(); +} + +function calculateRatio(likes: number, dislikes: number): string { + const total = likes + dislikes; + if (total === 0) return '100%'; + const percentage = Math.round((likes / total) * 100); + return `${percentage}%`; +} + +function getOrCreateBadge(): HTMLElement { + if (!badgeElement) { + badgeElement = document.createElement('div'); + badgeElement.className = 'ytaf-ryd-badge ytaf-ryd-badge-hidden'; + document.body.appendChild(badgeElement); + } + return badgeElement; +} + +function updateBadge(data: RYDData | null) { + const badge = getOrCreateBadge(); + + if (!data || !isEnabled()) { + badge.classList.add('ytaf-ryd-badge-hidden'); + return; + } + + const dislikeText = formatCount(data.dislikes); + const ratioText = calculateRatio(data.likes, data.dislikes); + + badge.innerHTML = `👎 ${dislikeText} (${ratioText})`; + badge.classList.remove('ytaf-ryd-badge-hidden'); +} + +function injectDislikeToPlayerControls(dislikeText: string | null) { + if (!isEnabled() || !dislikeText) { + const customLabels = document.querySelectorAll( + '.ytaf-ryd-sublabel, .ytaf-ryd-count-text, .ytaf-ryd-native-label' + ); + customLabels.forEach((el) => el.remove()); + return; + } + + const dislikeBtn = document.querySelector( + '[idomkey="dislike-button"]' + ) as HTMLElement | null; + + if (!dislikeBtn) return; + + // Remove any legacy custom elements from prior builds + const oldSublabel = dislikeBtn.querySelector('.ytaf-ryd-sublabel'); + if (oldSublabel) oldSublabel.remove(); + const oldInlineText = dislikeBtn.querySelector('.ytaf-ryd-count-text'); + if (oldInlineText) oldInlineText.remove(); + + const container = + dislikeBtn.querySelector('yt-button-container') || dislikeBtn; + + // Look for YouTube's native text element inside dislike button or create one with native classes + let formattedString = dislikeBtn.querySelector( + 'yt-formatted-string' + ) as HTMLElement | null; + + if (formattedString) { + if (formattedString.textContent !== dislikeText) { + formattedString.textContent = dislikeText; + } + } else { + formattedString = document.createElement('yt-formatted-string'); + formattedString.className = 'XGffTd OqGroe ytaf-ryd-native-label'; + formattedString.setAttribute('dir', 'auto'); + formattedString.setAttribute('tabindex', '-1'); + formattedString.textContent = dislikeText; + container.appendChild(formattedString); + } +} + +function setupMutationObserver() { + if (observer) return; + + observer = new MutationObserver(() => { + if (currentDislikeText && isEnabled()) { + injectDislikeToPlayerControls(currentDislikeText); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true + }); +} + +async function fetchDislikes(videoID: string): Promise { + if (cache.has(videoID)) { + return cache.get(videoID) || null; + } + + try { + const response = await fetch(`${RYD_API}${encodeURIComponent(videoID)}`); + if (!response.ok) { + console.warn('[return-dislikes] API response not ok:', response.status); + return null; + } + + const data: RYDData = await response.json(); + cache.set(videoID, data); + return data; + } catch (err) { + console.error('[return-dislikes] Failed to fetch dislikes:', err); + return null; + } +} + +async function processVideo(videoID: VideoID) { + if (!isEnabled() || !videoID) { + currentDislikeText = null; + updateBadge(null); + injectDislikeToPlayerControls(null); + return; + } + + currentVideoID = videoID; + const data = await fetchDislikes(videoID); + + if (currentVideoID !== videoID) return; // Video changed while fetching + + if (data) { + const dislikeText = formatCount(data.dislikes); + const ratioText = calculateRatio(data.likes, data.dislikes); + + currentDislikeText = dislikeText; + updateBadge(data); + injectDislikeToPlayerControls(dislikeText); + showNotification(`👎 ${dislikeText} dislikes (${ratioText} rating)`, 3500); + } else { + currentDislikeText = null; + updateBadge(null); + injectDislikeToPlayerControls(null); + } +} + +function getVideoIDFromHash(): string | null { + try { + const hash = window.location.hash.substring(1); + if (!hash) return null; + const url = new URL(hash, window.location.href); + if (url.pathname === '/watch') { + return url.searchParams.get('v'); + } + } catch (e) { + // Ignore invalid hash formats + } + return null; +} + +// Initialize Return YouTube Dislike event listeners +async function init() { + setupMutationObserver(); + + const manager = await getPlayerManager(); + + type EventMap = EventMapOf; + + manager.addEventListener('newVideo', (evt: EventMap['newVideo']) => { + const videoID = evt.detail; + if (manager.playerMode === PlayerMode.PREVIEW) return; + if (videoID) { + processVideo(videoID); + } + }); + + window.addEventListener('hashchange', () => { + const videoID = getVideoIDFromHash(); + if (videoID && videoID !== currentVideoID) { + processVideo(videoID); + } else if (!videoID) { + currentDislikeText = null; + updateBadge(null); + injectDislikeToPlayerControls(null); + } + }); + + configAddChangeListener('enableReturnYouTubeDislike', (evt) => { + const enabled = (evt as CustomEvent).detail.newValue; + if (!enabled) { + currentDislikeText = null; + updateBadge(null); + injectDislikeToPlayerControls(null); + } else if (currentVideoID) { + processVideo(currentVideoID); + } + }); + + // Initial check + const initialID = manager.currentVideoID || getVideoIDFromHash(); + if (initialID) { + processVideo(initialID); + } +} + +init(); diff --git a/src/ui.css b/src/ui.css index b90ea08c..f1b63e04 100644 --- a/src/ui.css +++ b/src/ui.css @@ -1,9 +1,9 @@ .ytaf-ui-container { position: absolute; - top: 10%; + top: 5%; left: 10%; right: 10%; - bottom: 10%; + bottom: 5%; background: rgba(18, 18, 18, 0.9); backdrop-filter: blur(23px); @@ -11,7 +11,7 @@ border: 0.1rem solid rgb(52 52 52); border-radius: 20px; padding: 1em; - font-size: 1.4rem; + font-size: 1.3rem; z-index: 1000; } @@ -22,18 +22,22 @@ .ytaf-ui-container h1 { margin: 0; - margin-bottom: 0.5em; + margin-bottom: 0.3em; text-align: center; } .ytaf-ui-container input[type='checkbox'] { - width: 1.5rem; - height: 1.3rem; + width: 1.4rem; + height: 1.2rem; } .ytaf-ui-container label { display: block; - font-size: 1.3rem; + font-size: 1.2rem; +} + +.ytaf-ui-container blockquote { + margin: 0.4em 0 0.4em 2em; } .ytaf-notification-container { diff --git a/src/ui.js b/src/ui.js index c7c34b1a..7c2b102f 100644 --- a/src/ui.js +++ b/src/ui.js @@ -130,6 +130,7 @@ function createOptionsPanel() { elmContainer.appendChild(createConfigCheckbox('forceHighResVideo')); elmContainer.appendChild(createConfigCheckbox('removeEndscreen')); elmContainer.appendChild(createConfigCheckbox('autoAccountSelect')); + elmContainer.appendChild(createConfigCheckbox('enableReturnYouTubeDislike')); elmContainer.appendChild(createConfigCheckbox('enableSponsorBlock')); const elmBlock = document.createElement('blockquote'); diff --git a/src/userScript.ts b/src/userScript.ts index 1edea6b7..2a67d99d 100644 --- a/src/userScript.ts +++ b/src/userScript.ts @@ -29,3 +29,4 @@ import './remove-endscreen'; import './hooks'; import './block-webos-cast'; import './auto-account-select'; +import './return-dislikes';