AndrewCramer / YouTube Thumbnail Width Fixer

// ==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();
    }
})();