// ==UserScript==
// @name Fuck-YouTube
// @namespace https://t.me/Impart_Chat
// @version 0.1
// @description 每行合并 6 个缩略图、删除 Shorts、禁用 AV1/WebRTC、添加视频适配切换、清理 URL。
// @author https://t.me/Impart_Chat
// @match https://*.youtube.com/*
// @exclude https://accounts.youtube.com/*
// @exclude https://studio.youtube.com/*
// @exclude https://music.youtube.com/*
// @grant GM_addStyle
// @grant unsafeWindow
// @run-at document-start
// @license MIT; https://opensource.org/licenses/MIT
// ==/UserScript==
(function() {
'use strict';
// --- Helper Functions from Bilibili Script ---
const o$1 = () => {}; // No-op function
const noopNeverResolvedPromise = () => new Promise(o$1);
/* eslint-disable no-restricted-globals -- logger */
const consoleLog = unsafeWindow.console.log;
const consoleError = unsafeWindow.console.error;
const consoleWarn = unsafeWindow.console.warn;
const consoleInfo = unsafeWindow.console.info;
const consoleDebug = unsafeWindow.console.debug;
const consoleTrace = unsafeWindow.console.trace;
const consoleGroup = unsafeWindow.console.group;
const consoleGroupCollapsed = unsafeWindow.console.groupCollapsed;
const consoleGroupEnd = unsafeWindow.console.groupEnd;
const logger = {
log: consoleLog.bind(console, '[YT Enhanced]'),
error: consoleError.bind(console, '[YT Enhanced]'),
warn: consoleWarn.bind(console, '[YT Enhanced]'),
info: consoleInfo.bind(console, '[YT Enhanced]'),
debug: consoleDebug.bind(console, '[YT Enhanced]'),
trace(...args) {
consoleGroupCollapsed.bind(console, '[YT Enhanced]')(...args);
consoleTrace(...args);
consoleGroupEnd();
},
group: consoleGroup.bind(console, '[YT Enhanced]'),
groupCollapsed: consoleGroupCollapsed.bind(console, '[YT Enhanced]'),
groupEnd: consoleGroupEnd.bind(console)
};
function defineReadonlyProperty(target, key, value, enumerable = true) {
Object.defineProperty(target, key, {
get() {
return value;
},
set: o$1,
configurable: false, // Make it harder to change
enumerable
});
}
// Simple template literal tag for CSS readability
function e(r, ...t) {
return r.reduce((e, r, n) => e + r + (t[n] ?? ""), "")
}
// --- Feature Modules ---
// 1. Disable AV1 Codec (From Bilibili Script)
const disableAV1 = {
name: 'disable-av1',
description: 'Prevent YouTube from using AV1 codec',
apply() {
try {
const originalCanPlayType = HTMLMediaElement.prototype.canPlayType;
// Check if prototype and function exist before overriding
if (HTMLMediaElement && typeof originalCanPlayType === 'function') {
HTMLMediaElement.prototype.canPlayType = function(type) {
if (type && type.includes('av01')) {
logger.info('AV1 canPlayType blocked:', type);
return '';
}
// Ensure 'this' context is correct and call original
return originalCanPlayType.call(this, type);
};
} else {
logger.warn('HTMLMediaElement.prototype.canPlayType not found or not a function.');
}
const originalIsTypeSupported = unsafeWindow.MediaSource?.isTypeSupported;
if (typeof originalIsTypeSupported === 'function') {
unsafeWindow.MediaSource.isTypeSupported = function(type) {
if (type && type.includes('av01')) {
logger.info('AV1 isTypeSupported blocked:', type);
return false;
}
return originalIsTypeSupported.call(this, type);
};
} else {
logger.warn('MediaSource.isTypeSupported not found or not a function, cannot block AV1 via MediaSource.');
}
logger.log(this.name, 'applied');
} catch (err) {
logger.error('Error applying', this.name, err);
}
}
};
// 2. Disable WebRTC (From Bilibili Script)
const noWebRTC = {
name: 'no-webrtc',
description: 'Disable WebRTC Peer Connections',
apply() {
try {
const rtcPcNames = [];
if ('RTCPeerConnection' in unsafeWindow) rtcPcNames.push('RTCPeerConnection');
if ('webkitRTCPeerConnection' in unsafeWindow) rtcPcNames.push('webkitRTCPeerConnection');
if ('mozRTCPeerConnection' in unsafeWindow) rtcPcNames.push('mozRTCPeerConnection');
const rtcDcNames = [];
if ('RTCDataChannel' in unsafeWindow) rtcDcNames.push('RTCDataChannel');
if ('webkitRTCDataChannel' in unsafeWindow) rtcDcNames.push('webkitRTCDataChannel');
if ('mozRTCDataChannel' in unsafeWindow) rtcDcNames.push('mozRTCDataChannel');
class MockDataChannel {
close = o$1; send = o$1; addEventListener = o$1; removeEventListener = o$1;
onbufferedamountlow = null; onclose = null; onerror = null; onmessage = null; onopen = null;
get bufferedAmount() { return 0; } get id() { return null; } get label() { return ''; }
get maxPacketLifeTime() { return null; } get maxRetransmits() { return null; } get negotiated() { return false; }
get ordered() { return true; } get protocol() { return ''; } get readyState() { return 'closed'; }
get reliable() { return false; } get binaryType() { return 'blob'; } set binaryType(val) {}
get bufferedAmountLowThreshold() { return 0; } set bufferedAmountLowThreshold(val) {}
toString() { return '[object RTCDataChannel]'; }
}
class MockRTCSessionDescription {
type; sdp;
constructor(init){ this.type = init?.type ?? 'offer'; this.sdp = init?.sdp ?? ''; }
toJSON() { return { type: this.type, sdp: this.sdp }; }
toString() { return '[object RTCSessionDescription]'; }
}
const mockedRtcSessionDescription = new MockRTCSessionDescription();
class MockRTCPeerConnection {
createDataChannel() { return new MockDataChannel(); }
close = o$1; createOffer = noopNeverResolvedPromise; setLocalDescription = async () => {};
setRemoteDescription = async () => {}; addEventListener = o$1; removeEventListener = o$1;
addIceCandidate = async () => {}; getConfiguration = () => ({}); getReceivers = () => [];
getSenders = () => []; getStats = () => Promise.resolve(new Map()); getTransceivers = () => [];
addTrack = () => null; removeTrack = o$1; addTransceiver = () => null; setConfiguration = o$1;
get localDescription() { return mockedRtcSessionDescription; } get remoteDescription() { return mockedRtcSessionDescription; }
get currentLocalDescription() { return mockedRtcSessionDescription; } get pendingLocalDescription() { return mockedRtcSessionDescription; }
get currentRemoteDescription() { return mockedRtcSessionDescription; } get pendingRemoteDescription() { return mockedRtcSessionDescription; }
get canTrickleIceCandidates() { return null; } get connectionState() { return 'disconnected'; }
get iceConnectionState() { return 'disconnected'; } get iceGatheringState() { return 'complete'; }
get signalingState() { return 'closed'; }
onconnectionstatechange = null; ondatachannel = null; onicecandidate = null; onicecandidateerror = null;
oniceconnectionstatechange = null; onicegatheringstatechange = null; onnegotiationneeded = null;
onsignalingstatechange = null; ontrack = null; createAnswer = noopNeverResolvedPromise;
toString() { return '[object RTCPeerConnection]'; }
}
for (const rtc of rtcPcNames) defineReadonlyProperty(unsafeWindow, rtc, MockRTCPeerConnection);
for (const dc of rtcDcNames) defineReadonlyProperty(unsafeWindow, dc, MockDataChannel);
defineReadonlyProperty(unsafeWindow, 'RTCSessionDescription', MockRTCSessionDescription);
logger.log(this.name, 'applied');
} catch (err) {
logger.error('Error applying', this.name, err);
}
}
};
// 3. Player Video Fit (Adapted from Bilibili Script)
const playerVideoFit = {
name: 'player-video-fit',
description: 'Adds a toggle for video fit mode (cover/contain)',
apply() {
try {
// Inject CSS first
GM_addStyle(e`
/* Style for the body when fit mode is active */
body[video-fit-mode-enabled] .html5-video-player video.video-stream,
body[video-fit-mode-enabled] .html5-video-player .html5-main-video {
object-fit: cover !important;
}
/* Style for the button in the settings menu */
.ytp-settings-menu .ytp-menuitem[aria-haspopup="false"][role="menuitemcheckbox"] {
justify-content: space-between; /* Align label and checkbox */
}
.ytp-settings-menu .ytp-menuitem-label {
flex-grow: 1;
margin-right: 10px; /* Space before checkbox */
}
.ytp-menuitem-toggle-checkbox {
/* Style the checkbox appearance if needed */
margin: 0 !important; /* Reset margin */
height: 100%;
display: flex;
align-items: center;
}
`);
let fitModeEnabled = localStorage.getItem('yt-enhanced-video-fit') === 'true';
function toggleMode(enabled) {
fitModeEnabled = enabled;
if (enabled) {
document.body.setAttribute('video-fit-mode-enabled', '');
localStorage.setItem('yt-enhanced-video-fit', 'true');
} else {
document.body.removeAttribute('video-fit-mode-enabled');
localStorage.setItem('yt-enhanced-video-fit', 'false');
}
}
function injectButtonLogic() { // Renamed function for clarity
// Use MutationObserver to detect when the settings menu is added
const observer = new MutationObserver((mutationsList, obs) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
const settingsMenu = document.querySelector('.ytp-settings-menu');
const panelMenu = settingsMenu?.querySelector('.ytp-panel-menu'); // Target the inner menu list
// Check if the menu is visible and our button isn't already there
if (settingsMenu && panelMenu && !panelMenu.querySelector('#ytp-fit-mode-toggle')) {
// Check if settings menu is actually visible (has style other than display: none)
const style = window.getComputedStyle(settingsMenu);
if (style.display !== 'none') {
logger.debug('Settings menu opened, attempting to inject button.');
addButtonToMenu(panelMenu);
// Maybe disconnect observer once button is added, or keep it for dynamic changes?
// obs.disconnect(); // Disconnect if only needed once per menu open
}
}
}
}
});
// Observe the player container or body for changes
const player = document.getElementById('movie_player');
if (player) {
observer.observe(player, { childList: true, subtree: true });
logger.log('MutationObserver attached to player for settings menu.');
} else {
// Wait a bit and try again if player isn't immediately available
setTimeout(() => {
const playerRetry = document.getElementById('movie_player');
if (playerRetry) {
observer.observe(playerRetry, { childList: true, subtree: true });
logger.log('MutationObserver attached to player after retry.');
} else {
logger.warn('Player element not found for MutationObserver, Fit Mode button might not appear.');
}
}, 2000); // Wait 2 seconds
}
// Initial check in case the menu is already open when script runs
const initialPanelMenu = document.querySelector('.ytp-settings-menu .ytp-panel-menu');
if (initialPanelMenu && !initialPanelMenu.querySelector('#ytp-fit-mode-toggle')) {
const style = window.getComputedStyle(initialPanelMenu.closest('.ytp-settings-menu'));
if (style.display !== 'none') {
addButtonToMenu(initialPanelMenu);
}
}
// Initial body attribute application
if (fitModeEnabled) {
document.body.setAttribute('video-fit-mode-enabled', '');
}
}
function addButtonToMenu(panelMenu) {
if (!panelMenu || panelMenu.querySelector('#ytp-fit-mode-toggle')) return; // Already added or menu gone
try {
const newItem = document.createElement('div');
newItem.className = 'ytp-menuitem';
newItem.setAttribute('role', 'menuitemcheckbox');
newItem.setAttribute('aria-checked', fitModeEnabled.toString());
newItem.id = 'ytp-fit-mode-toggle';
newItem.tabIndex = 0;
const label = document.createElement('div');
label.className = 'ytp-menuitem-label';
label.textContent = '裁切模式 (Fit Mode)'; // Or 'Video Fit Mode'
const content = document.createElement('div');
content.className = 'ytp-menuitem-content';
// Simple checkbox look-alike
content.innerHTML = `<div class="ytp-menuitem-toggle-checkbox"> ${fitModeEnabled ? '☑' : '☐'} </div>`;
newItem.appendChild(label);
newItem.appendChild(content);
newItem.addEventListener('click', (e) => { // Use event object
e.stopPropagation(); // Prevent menu closing
const newState = !fitModeEnabled;
toggleMode(newState);
newItem.setAttribute('aria-checked', newState.toString());
content.innerHTML = `<div class="ytp-menuitem-toggle-checkbox"> ${newState ? '☑' : '☐'} </div>`;
});
// Insert before the "Stats for nerds" or Quality item, or just append
const qualityItem = Array.from(panelMenu.children).find(el => el.textContent.includes('Quality') || el.textContent.includes('画质')); // Added Chinese Quality
if (qualityItem) {
panelMenu.insertBefore(newItem, qualityItem.nextSibling); // Insert after Quality
} else {
// Try inserting before Loop or Stats if Quality not found
const loopItem = Array.from(panelMenu.children).find(el => el.textContent.includes('Loop') || el.textContent.includes('循环播放'));
if (loopItem) {
panelMenu.insertBefore(newItem, loopItem);
} else {
const statsItem = Array.from(panelMenu.children).find(el => el.textContent.includes('Stats for nerds') || el.textContent.includes('详细统计信息'));
if (statsItem) {
panelMenu.insertBefore(newItem, statsItem);
} else {
panelMenu.appendChild(newItem); // Append as last resort
}
}
}
logger.log('Fit Mode button injected.');
} catch (e) {
logger.error("Error injecting Fit Mode button:", e);
}
}
// Wait for the page elements to likely exist
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectButtonLogic);
} else {
injectButtonLogic(); // Already loaded
}
logger.log(this.name, 'applied');
} catch (err) {
logger.error('Error applying', this.name, err);
}
}
};
// 4. Remove Black Backdrop Filter (From Bilibili Script - Generic)
const removeBlackBackdropFilter = {
name: 'remove-black-backdrop-filter',
description: 'Removes potential site-wide grayscale filters',
apply() {
try {
GM_addStyle(e`html, body { filter: none !important; -webkit-filter: none !important; }`);
logger.log(this.name, 'applied');
} catch (err) {
logger.error('Error applying', this.name, err);
}
}
};
// 5. Remove Useless URL Parameters (Adapted from Bilibili Script)
const removeUselessUrlParams = {
name: 'remove-useless-url-params',
description: 'Clean URLs from tracking parameters',
apply() {
try {
// Common YouTube tracking parameters (add more as needed)
const youtubeUselessUrlParams = [
'si', // Share ID? Added recently
'pp', // ??? Related to recommendations/playback source?
'feature', // e.g., feature=share, feature=emb_logo
'gclid', // Google Click ID
'dclid', // Google Display Click ID
'fbclid', // Facebook Click ID
'utm_source', // Urchin Tracking Module params
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
'oac', // ?? Found sometimes
'_hsenc', // HubSpot
'_hsmi', // HubSpot
'mc_eid', // Mailchimp
'mc_cid', // Mailchimp
];
function removeTracking(url) {
if (!url) return url;
let urlObj;
try {
// Handle relative URLs and ensure it's a valid URL format
if (typeof url === 'string' && (url.startsWith('/') || url.startsWith('./') || url.startsWith('../'))) {
urlObj = new URL(url, unsafeWindow.location.href);
} else if (typeof url === 'string') {
urlObj = new URL(url); // Assume absolute if not clearly relative
} else if (url instanceof URL){
urlObj = url;
} else {
logger.warn('Invalid URL type for removeTracking:', url);
return url; // Return original if type is wrong
}
if (!urlObj.search) return urlObj.href; // No params to clean
const params = urlObj.searchParams;
let changed = false;
// Iterate over a copy of keys because deleting modifies the collection
const keysToDelete = [];
for (const key of params.keys()) {
for (const item of youtubeUselessUrlParams) {
let match = false;
if (typeof item === 'string') {
if (item === key) match = true;
} else if (item instanceof RegExp && item.test(key)) {
match = true;
}
if (match) {
keysToDelete.push(key);
break; // Move to next key once a match is found
}
}
}
if (keysToDelete.length > 0) {
keysToDelete.forEach(key => params.delete(key));
changed = true;
}
// Return original string if no changes, href otherwise
return changed ? urlObj.href : (typeof url === 'string' ? url : url.href);
} catch (e) {
// Catch potential URL parsing errors
if (e instanceof TypeError && e.message.includes("Invalid URL")) {
// Ignore invalid URL errors often caused by non-standard URIs like about:blank
return url;
}
logger.error('Failed to remove useless urlParams for:', url, e);
return (typeof url === 'string' ? url : url?.href ?? ''); // Return original on other errors
}
}
// Initial clean
const initialHref = unsafeWindow.location.href;
const cleanedHref = removeTracking(initialHref);
if (initialHref !== cleanedHref) {
logger.log('Initial URL cleaned:', initialHref, '->', cleanedHref);
// Use try-catch for replaceState as well, as it can fail on certain pages/frames
try {
unsafeWindow.history.replaceState(unsafeWindow.history.state, '', cleanedHref);
} catch (histErr) {
logger.error("Failed to replaceState for initial URL:", histErr);
}
}
// Hook history API
const originalPushState = unsafeWindow.history.pushState;
unsafeWindow.history.pushState = function(state, title, url) {
const cleaned = removeTracking(url);
if (url && url !== cleaned) { // Check if url is not null/undefined
logger.log('pushState URL cleaned:', url, '->', cleaned);
}
// Use try-catch for safety
try {
return originalPushState.call(unsafeWindow.history, state, title, cleaned ?? url); // Pass original url if cleaning fails
} catch (pushErr) {
logger.error("Error in hooked pushState:", pushErr);
// Attempt to call original with original URL as fallback
return originalPushState.call(unsafeWindow.history, state, title, url);
}
};
const originalReplaceState = unsafeWindow.history.replaceState;
unsafeWindow.history.replaceState = function(state, title, url) {
const cleaned = removeTracking(url);
if (url && url !== cleaned) { // Check if url is not null/undefined
logger.log('replaceState URL cleaned:', url, '->', cleaned);
}
// Use try-catch for safety
try {
return originalReplaceState.call(unsafeWindow.history, state, title, cleaned ?? url); // Pass original url if cleaning fails
} catch (replaceErr) {
logger.error("Error in hooked replaceState:", replaceErr);
// Attempt to call original with original URL as fallback
return originalReplaceState.call(unsafeWindow.history, state, title, url);
}
};
logger.log(this.name, 'applied');
} catch (err) {
logger.error('Error applying', this.name, err);
}
}
};
// 6. Use System Fonts (Adapted from Bilibili Script)
const useSystemFonts = {
name: 'use-system-fonts',
description: 'Force system default fonts instead of YouTube specific fonts',
apply() {
try {
// Force system UI font on main elements
GM_addStyle(e`
html, body, #masthead, #content, ytd-app, tp-yt-app-drawer, #guide,
input, button, textarea, select, .ytd-video-primary-info-renderer,
.ytd-video-secondary-info-renderer, #comments, #comment,
.ytd-rich-grid-media .ytd-rich-item-renderer #video-title, /* Titles in grids */
.ytp-tooltip-text, .ytp-menuitem-label, .ytp-title-text /* Player UI elements */
{
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, Cantarell, "Helvetica Neue", sans-serif !important;
}
`);
logger.log(this.name, 'applied');
} catch (err) {
logger.error('Error applying', this.name, err);
}
}
};
// 7. 6 Thumbnails Per Row (Original YouTube Script's Core Function)
const sixThumbs = {
name: 'six-thumbnails-per-row',
description: 'Sets YouTube grid items to 6 per row',
apply() {
try {
GM_addStyle(e`
/* Set the number of items per row in main grids (Home, Subscriptions, etc.) */
ytd-rich-grid-renderer {
--ytd-rich-grid-items-per-row: 6 !important;
}
/* Handle browse grids (e.g., channel pages, maybe search, subscriptions) more broadly */
ytd-two-column-browse-results-renderer[is-grid] #primary #contents.ytd-section-list-renderer > *.ytd-section-list-renderer,
ytd-browse #primary #contents.ytd-section-list-renderer > *.ytd-section-list-renderer:has(ytd-rich-grid-renderer), /* Target sections containing a rich grid */
ytd-browse[page-subtype="subscriptions"] #contents.ytd-section-list-renderer /* Specifically target subs grid */ {
--ytd-rich-grid-items-per-row: 6 !important;
}
/* Wider container for grids to accommodate 6 items better */
ytd-rich-grid-renderer #contents.ytd-rich-grid-renderer {
/* Use viewport width units for better scaling, with a max-width */
width: calc(100vw - var(--ytd-guide-width, 240px) - 48px); /* Adjust guide width and margins */
max-width: calc(var(--ytd-rich-grid-item-max-width, 360px) * 6 + var(--ytd-rich-grid-item-margin, 16px) * 12 + 24px); /* Original max-width as fallback */
margin: auto; /* Center the grid */
}
/* Ensure shelf renderers also use 6 */
ytd-shelf-renderer[use-show-fewer] #items.ytd-shelf-renderer {
--ytd-shelf-items-per-row: 6 !important;
}
`);
logger.log(this.name, 'applied');
} catch (err) {
logger.error('Error applying', this.name, err);
}
}
};
// 8. Remove Shorts (NEW MODULE)
const removeShorts = {
name: 'remove-shorts',
description: 'Hides YouTube Shorts elements from the UI',
apply() {
try {
GM_addStyle(e`
/* Hide Shorts tab in sidebar guide */
ytd-guide-entry-renderer:has(a#endpoint[title='Shorts']),
ytd-guide-entry-renderer:has(yt-icon path[d^='M10 14.14V9.86']), /* Alternative selector based on SVG icon path (might change) */
ytd-mini-guide-entry-renderer[aria-label='Shorts'] {
display: none !important;
}
/* Hide Shorts shelves/sections */
ytd-reel-shelf-renderer,
ytd-rich-shelf-renderer[is-shorts] {
display: none !important;
}
/* Hide individual Shorts videos in feeds/grids */
ytd-grid-video-renderer:has(ytd-thumbnail-overlay-time-status-renderer[overlay-style='SHORTS']),
ytd-video-renderer:has(ytd-thumbnail-overlay-time-status-renderer[overlay-style='SHORTS']),
ytd-rich-item-renderer:has(ytd-reel-item-renderer) {
display: none !important;
}
/* Hide Shorts tab on Channel pages */
tp-yt-paper-tab:has(.tab-title) {
/* Using attribute selector for potential future proofing if YT adds one */
&[aria-label*="Shorts"],
/* Check title attribute as well */
&.ytd-browse[title="Shorts"],
/* Fallback using text content - least reliable */
&:has(span.tab-title:only-child:contains("Shorts")) {
display: none !important;
}
}
/* Hide the "Shorts" header above grid sections on channel pages */
ytd-rich-grid-renderer #title-container.ytd-rich-grid-renderer:has(h2 yt-formatted-string:contains("Shorts")) {
display: none !important;
}
`);
logger.log(this.name, 'applied');
} catch (err) {
logger.error('Error applying', this.name, err);
}
}
};
// --- Apply Features ---
logger.log('Initializing YouTube Enhanced script...');
// Apply features immediately at document-start where possible
disableAV1.apply();
noWebRTC.apply();
removeUselessUrlParams.apply();
// Apply CSS-based features
sixThumbs.apply();
useSystemFonts.apply();
removeBlackBackdropFilter.apply();
removeShorts.apply(); // Apply the new feature
playerVideoFit.apply(); // Sets up button injection logic
logger.log('YouTube Enhanced script initialization complete.');
})();