-
-
Notifications
You must be signed in to change notification settings - Fork 114
feat: add Return YouTube Dislike support and adjust UI option box layout #477
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| } |
| 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 { | ||
| 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}%`; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use |
||
| } | ||
|
|
||
| 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'; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Scope the |
||
| 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(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why were these values changed? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add the
@formatjs/intl-numberformatpolyfill and use: