Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)'
}
]
]);

Expand Down
53 changes: 53 additions & 0 deletions src/return-dislikes.css
Original file line number Diff line number Diff line change
@@ -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;
}
239 changes: 239 additions & 0 deletions src/return-dislikes.ts
Original file line number Diff line number Diff line change
@@ -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<string, RYDData>();

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add the @formatjs/intl-numberformat polyfill and use:

const preferredLang // from YouTube's pref cookie
new Intl.NumberFormat(preferredLang, {
  notation: "compact",
  compactDisplay: "short",
}).format()

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use Intl.NumberFormat here as well.

}

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 = `<span class="ytaf-ryd-dislike-icon">👎</span> <span>${dislikeText}</span> <span class="ytaf-ryd-ratio">(${ratioText})</span>`;
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';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hard coding obfuscated CSS classes seems fragile. What alternatives have you considered before you settled on this?

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<RYDData | null> {
if (cache.has(videoID)) {
return cache.get(videoID) || null;
}

try {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scope the try-catch to only the fetch unless you expect the JSON parse to fail.

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

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();
18 changes: 11 additions & 7 deletions src/ui.css

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why were these values changed?

Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
.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);
color: white;
border: 0.1rem solid rgb(52 52 52);
border-radius: 20px;
padding: 1em;
font-size: 1.4rem;
font-size: 1.3rem;
z-index: 1000;
}

Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
1 change: 1 addition & 0 deletions src/userScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ import './remove-endscreen';
import './hooks';
import './block-webos-cast';
import './auto-account-select';
import './return-dislikes';