NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name YouTube Thumbnail Width Fixer
// @namespace http://tampermonkey.net/
// @version 2.5.1
// @description Ширина превью в боковой панели + глобальные фиксы метаданных: обрезание текста, лишняя иконка, слово "просмотров", полная дата
// @author Hacker3310
// @license MIT
// @match https://*.youtube.com/*
// @grant none
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
// ==================== КОНФИГУРАЦИЯ ====================
const CONFIG = {
// Ширина превью в боковой панели (меняйте здесь, не лезя в код ниже)
width: '45%',
// Ресайз превью применять только на странице просмотра видео (/watch)
onlyWatchPage: true
};
const THUMB_STYLE_ID = 'yt-thumb-width-fixer-v2';
const META_STYLE_ID = 'yt-metadata-fixer-v1';
// ==================== СЕЛЕКТОРЫ: ШИРИНА ПРЕВЬЮ (только сайдбар /watch) ====================
// Явно исключаем ytd-playlist-panel-video-renderer (превью в плейлисте) — у него
// своя компактная раскладка, и принудительная ширина 45% её ломает (превью
// превращается в узкую вертикальную полоску).
const THUMBNAIL_SELECTORS = [
'#secondary ytd-thumbnail:not(ytd-playlist-panel-video-renderer ytd-thumbnail)',
'#related ytd-thumbnail:not(ytd-playlist-panel-video-renderer ytd-thumbnail)',
'#secondary a#thumbnail:not(ytd-playlist-panel-video-renderer a#thumbnail)',
'#related a#thumbnail:not(ytd-playlist-panel-video-renderer a#thumbnail)',
'#secondary a.ytLockupViewModelContentImage:not(ytd-playlist-panel-video-renderer a.ytLockupViewModelContentImage)',
'#related a.ytLockupViewModelContentImage:not(ytd-playlist-panel-video-renderer a.ytLockupViewModelContentImage)'
];
// ==================== СЕЛЕКТОРЫ: МЕТАДАННЫЕ ====================
// Снятие обрезания текста (overflow/white-space) оставляем ТОЛЬКО в сайдбаре
// (#secondary/#related) и только для строки "просмотры • дата" — определяем
// её по наличию разделителя (.ytContentMetadataViewModelDelimiter) внутри.
// Остальные строки (название канала, список исполнителей в миксах и т.п.)
// НЕ трогаем — там обрезание многоточием нужно, иначе длинный текст
// (например, список из нескольких исполнителей) вылезает за пределы
// страницы и создаёт горизонтальный скролл.
const METADATA_SELECTORS = [
'#secondary #metadata-line',
'#related #metadata-line',
'#secondary ytd-video-meta-block',
'#related ytd-video-meta-block',
'#secondary .ytContentMetadataViewModelMetadataRow:has(.ytContentMetadataViewModelDelimiter)',
'#related .ytContentMetadataViewModelMetadataRow:has(.ytContentMetadataViewModelDelimiter)',
'#secondary .ytContentMetadataViewModelMetadataRow:has(.ytContentMetadataViewModelDelimiter) .ytContentMetadataViewModelMetadataText',
'#related .ytContentMetadataViewModelMetadataRow:has(.ytContentMetadataViewModelDelimiter) .ytContentMetadataViewModelMetadataText'
];
// Обёртка иконки-"мусора" перед количеством просмотров.
// ВАЖНО: используем именно класс .ytContentMetadataViewModelLeadingIcon,
// а не общий "span:has(svg)" — тот случайно цеплял и span с названием
// канала, если внутри него был вложен значок верификации (svg на любой
// глубине), из-за чего пропадало само название канала.
const ICON_HIDE_SELECTORS = [
'yt-content-metadata-view-model .ytContentMetadataViewModelLeadingIcon'
];
// Span с количеством просмотров (первый ytContentMetadataViewModelMetadataText,
// у которого нет класса ...TextLastPart — этим он отличается от даты выхода).
// Замена текста через JS безопасна везде — оставляем глобальным.
const VIEWS_SELECTOR =
'yt-content-metadata-view-model .ytContentMetadataViewModelMetadataText:not(.ytContentMetadataViewModelMetadataTextLastPart)';
// Span с датой выхода ("11 мес. назад", "Трансляция закончилась 4 дня назад" и т.п.)
const DATE_SELECTOR =
'yt-content-metadata-view-model .ytContentMetadataViewModelMetadataTextLastPart';
// Разделитель между просмотрами и датой (изначально пустой пробел)
const DELIMITER_SELECTOR = '.ytContentMetadataViewModelDelimiter';
// ==================== УТИЛИТЫ ====================
function isWatchPage() {
return window.location.pathname.startsWith('/watch');
}
function debounce(fn, delay) {
let timer = null;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// ==================== СТИЛИ: ШИРИНА ПРЕВЬЮ ====================
function getThumbCSS() {
const thumbSelectors = THUMBNAIL_SELECTORS.join(',\n');
return `
${thumbSelectors} {
width: ${CONFIG.width} !important;
max-width: ${CONFIG.width} !important;
min-width: unset !important;
}
`;
}
function injectThumbStyles() {
try {
const existing = document.getElementById(THUMB_STYLE_ID);
if (existing) existing.remove();
if (CONFIG.onlyWatchPage && !isWatchPage()) {
return;
}
const style = document.createElement('style');
style.id = THUMB_STYLE_ID;
style.textContent = getThumbCSS();
(document.head || document.documentElement).appendChild(style);
} catch (err) {
console.error('[YT Thumb Fixer] Не удалось внедрить стили превью:', err);
}
}
function removeThumbStyles() {
const existing = document.getElementById(THUMB_STYLE_ID);
if (existing) existing.remove();
}
// ==================== СТИЛИ: МЕТАДАННЫЕ (глобально, всегда) ====================
function getMetaCSS() {
const metaSelectors = METADATA_SELECTORS.join(',\n');
const iconSelectors = ICON_HIDE_SELECTORS.join(',\n');
return `
/* Снимаем обрезание текста просмотров/даты многоточием.
Важно: white-space остаётся nowrap, а не normal — иначе на узких
карточках (например, в сетке видео на странице канала) текст
переносится на новую строку вместо того, чтобы просто не обрезаться */
${metaSelectors} {
overflow: visible !important;
text-overflow: unset !important;
white-space: nowrap !important;
max-width: none !important;
width: auto !important;
}
/* Убираем лишнюю декоративную иконку перед просмотрами */
${iconSelectors} {
display: none !important;
}
/* Возвращаем слово "просмотров"/"просмотра"/"просмотр" после числа —
подставляется скриптом ниже в data-атрибут с уже верным склонением */
.ytContentMetadataViewModelMetadataText[data-yt-views-suffix]::after {
content: attr(data-yt-views-suffix);
}
/* Разделитель "•" между просмотрами и датой добавляется через JS
напрямую в текст (см. fixDelimiter). Сбрасываем ширину/overflow,
иначе YouTube обрезает его, т.к. изначально там был один пробел */
.ytContentMetadataViewModelDelimiter {
display: inline !important;
overflow: visible !important;
text-overflow: unset !important;
white-space: pre !important;
width: auto !important;
max-width: none !important;
min-width: 0 !important;
margin: 0 4px !important;
}
`;
}
function injectMetaStyles() {
try {
if (document.getElementById(META_STYLE_ID)) return;
const style = document.createElement('style');
style.id = META_STYLE_ID;
style.textContent = getMetaCSS();
(document.head || document.documentElement).appendChild(style);
} catch (err) {
console.error('[YT Thumb Fixer] Не удалось внедрить стили метаданных:', err);
}
}
// ==================== ВОЗВРАТ СЛОВА "ПРОСМОТРОВ" ====================
// aria-label у YouTube всегда содержит полностью прописанный и правильно
// склонённый текст, например: "19 тысяч просмотров" или "24 просмотра".
// Берём из него последнее слово (само склонение) и дописываем его к
// сокращённому видимому числу через CSS ::after, не трогая исходный узел.
function appendViewsWord(el) {
if (el.dataset.ytViewsFixed === '1') return;
const label = el.getAttribute('aria-label');
if (!label) return;
const parts = label.trim().split(/\s+/);
const lastWord = parts[parts.length - 1];
if (!lastWord) return;
const visible = (el.textContent || '').trim();
if (visible.toLowerCase().endsWith(lastWord.toLowerCase())) {
el.dataset.ytViewsFixed = '1';
return;
}
el.dataset.ytViewsSuffix = ' ' + lastWord;
el.dataset.ytViewsFixed = '1';
}
function processViewsLabels() {
document.querySelectorAll(VIEWS_SELECTOR).forEach(appendViewsWord);
}
// ==================== ПОЛНАЯ ДАТА ВЫХОДА ====================
// "11 мес. назад" -> "11 месяцев назад", "4 дня назад" вместо обрезанного варианта,
// а также случаи вроде "Трансляция закончилась 4 дня назад".
// aria-label уже содержит полный текст — просто подставляем его вместо сокращённого.
function fixDateText(el) {
if (el.dataset.ytDateFixed === '1') return;
const label = el.getAttribute('aria-label');
if (!label) return;
const fullText = label.trim();
const visible = (el.textContent || '').trim();
if (visible !== fullText) {
el.textContent = fullText;
}
el.dataset.ytDateFixed = '1';
}
function processDateLabels() {
document.querySelectorAll(DATE_SELECTOR).forEach(fixDateText);
}
// ==================== РАЗДЕЛИТЕЛЬ "•" ====================
// Изначально этот span содержит просто пробел (aria-hidden="true"),
// подставляем точку прямо в текст — так она окажется на одной
// базовой линии с окружающими словами.
function fixDelimiter(el) {
if (el.dataset.ytDelimFixed === '1') return;
el.textContent = '\u2022';
el.dataset.ytDelimFixed = '1';
}
function processDelimiters() {
document.querySelectorAll(DELIMITER_SELECTOR).forEach(fixDelimiter);
}
function processAllMetadata() {
processViewsLabels();
processDateLabels();
processDelimiters();
}
// ==================== SPA-НАВИГАЦИЯ И ЛЕНИВАЯ ПОДГРУЗКА ====================
// Синхронизирует состояние ширины превью с текущей страницей: добавляет
// стиль на /watch, убирает на остальных страницах. Вызывается многократно
// из разных источников (события навигации + MutationObserver) — это
// специально, т.к. одиночного события навигации YouTube может не хватать
// при переключении на следующее видео (авто- или вручную), из-за чего
// превью раньше залипали маленькими.
function syncThumbStyleState() {
if (CONFIG.onlyWatchPage && !isWatchPage()) {
removeThumbStyles();
} else {
injectThumbStyles();
}
}
const debouncedNavUpdate = debounce(() => {
syncThumbStyleState();
processAllMetadata();
}, 150);
window.addEventListener('yt-page-data-updated', debouncedNavUpdate);
window.addEventListener('yt-navigate-finish', debouncedNavUpdate);
// Лента/сайдбар подгружают новые видео при скролле или при переключении
// на следующее видео без надёжных событий навигации — ловим это через
// MutationObserver и заодно перепроверяем состояние ширины превью, чтобы
// оно само восстанавливалось, если конкретное событие навигации не сработало.
const debouncedProcessMetadata = debounce(() => {
syncThumbStyleState();
processAllMetadata();
}, 200);
const mo = new MutationObserver(debouncedProcessMetadata);
mo.observe(document.documentElement, { childList: true, subtree: true });
// ==================== ИНИЦИАЛИЗАЦИЯ ====================
function init() {
injectMetaStyles();
injectThumbStyles();
processAllMetadata();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();