YouTube Enhancer

YouTube 增强:极简单图预览、D键开关、修复频道名、自动跳过 Shorts、J/K 导航、空格新标签页。

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         YouTube Enhancer
// @namespace    https://greasyfork.org/zh-CN/scripts/564767
// @version      2.6
// @description  YouTube 增强:极简单图预览、D键开关、修复频道名、自动跳过 Shorts、J/K 导航、空格新标签页。
// @author       Antigravity
// @match        *://www.youtube.com/*
// @grant        GM_download
// @grant        GM_notification
// @grant        GM_setValue
// @grant        GM_getValue
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    const SITE_CONFIG = {
        name: "YouTube",
        selectors: {
            item: 'ytd-rich-item-renderer, ytd-video-renderer, ytd-grid-video-renderer, ytd-compact-video-renderer',
            // watch 页面的推荐视频 - 使用新的 lockup-view-model 选择器
            watchRelated: 'yt-lockup-view-model',
            author: '#channel-name #text, ytd-channel-name #text, .yt-lockup-metadata-view-model__metadata a[href*="/@"], #byline-container a',
            // 改进 postLink 选择器 - 优先匹配缩略图链接,其次标题链接
            postLink: 'a.ytLockupViewModelContentImage, a.ytLockupMetadataViewModelTitle, a.video-thumb, a[href*="/watch?v="]',
            contentSpans: '#video-title, #video-title-link, .ytLockupMetadataViewModelTitle',
            media: { img: 'yt-image img, .yt-core-image, img, .ytThumbnailViewModelImage img' }
        },
        theme: { accent: '#ff0000', overlayBg: 'rgba(0,0,0,0.96)', borderRadius: '16px' }
    };

    let policy = { createHTML: (s) => s };
    if (window.trustedTypes?.createPolicy) {
        try { policy = window.trustedTypes.createPolicy('enhancer-policy', { createHTML: (s) => s }); } catch (e) { }
    }
    const setHTML = (el, html) => {
        if (!el) return;
        try { el.innerHTML = policy.createHTML(html); } catch (e) { el.textContent = html.replace(/<[^>]*>/g, ''); }
    };

    const App_Core = (() => {
        return {
            getPostData: (card) => {
                if (!card) return null;

                // 优先使用更精确的选择器定位
                const linkEl = card.querySelector('a.ytLockupViewModelContentImage, a.ytLockupMetadataViewModelTitle, a.video-thumb, a[href*="/watch?v="], a[href*="/shorts/"]') ||
                             card.querySelector('a[href*="/watch?v="]') ||
                             card.querySelector(SITE_CONFIG.selectors.postLink);
                const titleEl = card.querySelector('#video-title, #video-title-link, .ytLockupMetadataViewModelTitle, .yt-video-primary.renderer EN-THUMBNAIL') ||
                                card.querySelector(SITE_CONFIG.selectors.contentSpans);
                const authorEl = card.querySelector('#channel-name #text, ytd-channel-name #text, .yt-lockup-metadata-view-model__metadata a[href*="/@"], #byline-container a, a[href*="/channel/"], a[href*="/@"]') ||
                                card.querySelector(SITE_CONFIG.selectors.author);
                const avatarImg = card.querySelector('yt-img-shadow#avatar img, #avatar img, .yt-spec-avatar-shape__image, .yt-decorated-avatar-view-model img');
                const mainImg = card.querySelector('yt-image img, .yt-core-image--wrapped .yt-core-image, .ytThumbnailViewModelImage img, img.yt-core-image') ||
                              card.querySelector(SITE_CONFIG.selectors.media.img);

                // 调试输出
                console.log('[YouTube Enhancer] getPostData:', {
                    hasLink: !!linkEl,
                    linkHref: linkEl?.href,
                    hasTitle: !!titleEl,
                    hasImg: !!mainImg
                });

                return {
                    row: card,
                    url: linkEl?.href || null,
                    content: titleEl?.innerText.trim() || titleEl?.title || "",
                    author: authorEl?.title || authorEl?.innerText?.trim() || "Channel",
                    avatarUrl: avatarImg?.src || avatarImg?.getAttribute('src'),
                    src: mainImg?.src || mainImg?.getAttribute('src')
                };
            },
            findComments: () => {
                const all = Array.from(document.querySelectorAll(SITE_CONFIG.selectors.item));
                return all.filter(c => {
                    // 核心过滤:排除嵌套关系,确保每个评论只抓取一次最外层
                    let p = c.parentElement;
                    while (p) { if (p.matches?.(SITE_CONFIG.selectors.item)) return false; p = p.parentElement; }
                    return c.offsetHeight > 20;
                });
            },
            findCards: () => {
                // 首先尝试主页的视频卡片
                let all = Array.from(document.querySelectorAll(SITE_CONFIG.selectors.item));
                let cards = all.filter(c => {
                    if (c.hasAttribute('is-slim-media') || c.querySelector('ytm-shorts-lockup-view-model')) return false;
                    const link = c.querySelector(SITE_CONFIG.selectors.postLink);
                    if (link && link.href && link.href.includes('/shorts/')) return false;

                    // 核心过滤:排除嵌套关系
                    let p = c.parentElement;
                    while (p) { if (p.matches?.(SITE_CONFIG.selectors.item)) return false; p = p.parentElement; }

                    return c.offsetHeight > 50;
                });

                console.log('[YouTube Enhancer] findCards: main cards:', cards.length);

                // 如果没有主页卡片,尝试 watch 页面的推荐视频
                if (!cards.length) {
                    all = Array.from(document.querySelectorAll(SITE_CONFIG.selectors.watchRelated));
                    console.log('[YouTube Enhancer] findCards: related candidates:', all.length);
                    cards = all.filter(c => {
                        const link = c.querySelector(SITE_CONFIG.selectors.postLink);
                        console.log('[YouTube Enhancer] findCards: link element:', link, link?.href);
                        if (link && link.href && link.href.includes('/shorts/')) return false;
                        return c.offsetHeight > 30;
                    });
                    console.log('[YouTube Enhancer] findCards: filtered related cards:', cards.length);
                }

                return cards;
            }
        };
    })();

    const App_UI = (() => {
        let overlay, vImg, vText, vInfo, vAvatar;
        let isEnabled = true;

        const injectCSS = () => {
            if (document.getElementById('enhancer-css')) return;
            const s = document.createElement('style'); s.id = 'enhancer-css';
            s.textContent = `
                .eh-active { outline: 4px solid ${SITE_CONFIG.theme.accent} !important; outline-offset: 0px !important; border-radius: 12px !important; z-index: 10 !important; position: relative !important; }
                .eh-main-image { width: 100%; height: auto; object-fit: contain; border-radius: 12px; margin-bottom: 25px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); }
                .eh-avatar { width: 60px; height: 60px; border-radius: 50%; border: 3px solid rgba(255,255,255,0.2); object-fit: cover; }
                .eh-author { font-size: 24px; font-weight: 900; color: #fff; }
                .eh-title { font-size: 26px; font-weight: 700; line-height: 1.4; color: rgba(255,255,255,0.9); margin-top: 10px; }
            `;
            document.head.appendChild(s);
        };

        const update = (data, idx, total) => {
            if (!data) return;
            vImg.src = data.src || '';
            // 头部:头像 + 作者 (水平排列)
            setHTML(vAvatar, `
                <div style="display:flex; align-items:center; gap:20px; margin-bottom:15px;">
                    <img src="${data.avatarUrl || ''}" class="eh-avatar">
                    <div class="eh-author">${data.author}</div>
                </div>
            `);
            // 视频标题
            setHTML(vText, `<div class="eh-title">${data.content}</div>`);

            // 底部布局:左(链接) 中(提示) 右(滑块)
            setHTML(vInfo, `
                <div style="border-top:1px solid rgba(255,255,255,0.1); padding-top:15px; margin-top:10px; display:flex; align-items:center; justify-content:space-between; width:100%;">
                    <!-- 左下:更新地址 -->
                    <div style="flex:1; text-align:left;">
                        <a href="https://greasyfork.org/zh-CN/scripts/564767" target="_blank" style="color:rgba(255,255,255,0.2); font-size:10px; text-decoration:none; transition:color 0.2s;" onmouseover="this.style.color='rgba(255,255,255,0.5)'" onmouseout="this.style.color='rgba(255,255,255,0.2)'">GreasyFork / Info</a>
                    </div>

                    <!-- 中间:提示 -->
                    <div style="flex:1.5; text-align:center; color:rgba(255,255,255,0.4); font-size:11px; font-weight:500; display:flex; align-items:center; justify-content:center; gap:20px;">
                        <span>Pro Navigation Enabled</span>
                        <span><b>[SPACE]</b> 新标签页</span>
                        <span><b>[D]</b> 关闭</span>
                    </div>

                    <!-- 右下:卡片宽度 -->
                    <div style="flex:1; display:flex; align-items:center; justify-content:flex-end; gap:10px;">
                        <span style="font-size:10px; color:rgba(255,255,255,0.3); white-space:nowrap;">宽度</span>
                        <input type="range" id="eh-size-slider" min="400" max="1400" step="50" value="${GM_getValue('eh_card_width', 800)}" style="width:80px; cursor:pointer; accent-color:#ffffff00;">
                    </div>
                </div>
            `);
            // counter 已移除

            // 重新绑定宽度调节和设置
            const slider = document.getElementById('eh-size-slider');
            const card = vImg.parentElement;

            if (slider && card) {
                const updateWidth = (val) => { card.style.width = `${val}px`; GM_setValue('eh_card_width', val); };
                slider.oninput = (e) => updateWidth(e.target.value);
                updateWidth(slider.value);
            }
        };

        return {
            show: (data, idx, total) => {
                if (!isEnabled) return;
                if (!overlay) {
                    overlay = document.createElement('div');
                    Object.assign(overlay.style, {
                        position: 'fixed',
                        inset: 0,
                        backgroundColor: 'rgba(0,0,0,0.85)',
                        zIndex: 999999,
                        display: 'flex',
                        alignItems: 'center',
                        justifyContent: 'center',
                    });
                    const card = document.createElement('div');
                    Object.assign(card.style, {
                        width: GM_getValue('eh_card_width', 800) + 'px',
                        maxWidth: '90vw',
                        maxHeight: '90vh',
                        background: 'rgba(255,255,255,0.05)',
                        backdropFilter: 'blur(20px) saturate(180%)',
                        borderRadius: '30px',
                        padding: '40px',
                        position: 'relative',
                        boxShadow: '0 40px 100px rgba(0,0,0,0.8)',
                        border: '1px solid rgba(255,255,255,0.1)',
                        display: 'flex',
                        flexDirection: 'column',
                        overflowX: 'hidden',
                        overflowY: 'auto'
                    });

                    vImg = document.createElement('img'); vImg.className = 'eh-main-image';
                    vAvatar = document.createElement('div');
                    vText = document.createElement('div');
                    vInfo = document.createElement('div');

                    card.append(vImg, vAvatar, vText, vInfo); overlay.append(card); document.body.appendChild(overlay);
                    overlay.onclick = (e) => { if (e.target === overlay) overlay.style.display = 'none'; };
                }
                overlay.style.display = 'flex';
                update(data, idx, total);
            },
            hide: () => { if (overlay) overlay.style.display = 'none'; },
            toggle: () => { isEnabled = !isEnabled; if (!isEnabled) App_UI.hide(); App_UI.showToast(isEnabled ? '预览:开启' : '预览:关闭'); return isEnabled; },
            isVisible: () => overlay && overlay.style.display === 'flex' && isEnabled,
            injectCSS,
            showToast: (msg) => {
                const old = document.getElementById('enhancer-toast'); if (old) old.remove();
                const t = document.createElement('div'); t.id = 'enhancer-toast';
                Object.assign(t.style, { position: 'fixed', bottom: '50px', left: '50%', transform: 'translateX(-50%)', padding: '12px 30px', background: '#fff', color: '#000', borderRadius: '30px', zIndex: 1000005, fontWeight: '900', boxShadow: '0 15px 30px rgba(0,0,0,0.4)' });
                t.innerText = msg; document.body.appendChild(t);
                setTimeout(() => { t.style.opacity = '0'; t.style.transition = '0.3s'; setTimeout(() => t.remove(), 500); }, 1500);
            }
        };
    })();

    const App_Nav = (() => {
        let curIdx = -1, cards = [];
        return {
            move: (direction) => {
                cards = App_Core.findCards();
                if (!cards.length) return null;

                // 首次按下,寻找中心卡片
                if (curIdx === -1 || !cards[curIdx]) {
                    const centerTop = window.innerHeight / 2;
                    const sorted = [...cards].sort((a, b) => {
                        const ra = a.getBoundingClientRect();
                        const rb = b.getBoundingClientRect();
                        return Math.abs(ra.top + ra.height / 2 - centerTop) - Math.abs(rb.top + rb.height / 2 - centerTop);
                    });
                    curIdx = cards.indexOf(sorted[0]);
                    return { data: App_Core.getPostData(cards[curIdx]), index: curIdx, total: cards.length };
                }

                if (direction === 'left') {
                    curIdx = Math.max(0, curIdx - 1);
                } else if (direction === 'right') {
                    curIdx = Math.min(cards.length - 1, curIdx + 1);
                } else {
                    // 上下移动:空间几何查找
                    const rect = cards[curIdx].getBoundingClientRect();
                    const curX = rect.left + rect.width / 2;
                    let bestIdx = -1;
                    let minDiff = Infinity;

                    for (let i = 0; i < cards.length; i++) {
                        if (i === curIdx) continue;
                        const r = cards[i].getBoundingClientRect();
                        const targetX = r.left + r.width / 2;

                        const isBelow = r.top > rect.top + 10;
                        const isAbove = r.bottom < rect.bottom - 10;

                        if ((direction === 'down' && isBelow) || (direction === 'up' && isAbove)) {
                            const dy = Math.abs(r.top - rect.top);
                            const dx = Math.abs(targetX - curX);
                            const score = dy + dx * 2.8; // YouTube 权重稍微调高,确保垂直对齐

                            if (score < minDiff) {
                                minDiff = score;
                                bestIdx = i;
                            }
                        }
                    }
                    if (bestIdx !== -1) curIdx = bestIdx;
                }
                return { data: App_Core.getPostData(cards[curIdx]), index: curIdx, total: cards.length };
            },
            scroll: () => {
                document.querySelectorAll('.eh-active').forEach(e => e.classList.remove('eh-active'));
                const c = cards[curIdx];
                if (c) { c.classList.add('eh-active'); c.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
            },
            reset: () => { curIdx = -1; document.querySelectorAll('.eh-active').forEach(e => e.classList.remove('eh-active')); },
            getCard: () => cards[curIdx]
        };
    })();

    const init = () => {
        window.addEventListener('keydown', (e) => {
            if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName) || document.activeElement.hasAttribute('contenteditable') || document.activeElement.id === 'search') return;
            const k = e.key.toLowerCase();
            if (k === 'd') {
                const enabled = App_UI.toggle();
                if (enabled) { const r = App_Nav.move(0); if (r) App_UI.show(r.data, r.index, r.total); }
                return;
            }

            // 导航逻辑
            let dir = '';
            const isVisible = App_UI.isVisible();
            const isWatchPage = window.location.pathname.startsWith('/watch');

            // 只处理 J/K 上下导航(箭头键也可以)
            // if (k === 'j' || k === 'arrowdown') {
            //     dir = 'down';
            // } else if (k === 'k' || k === 'arrowup') {
            //     dir = 'up';
            // }else if (k === 'h' || k === 'arrowleft') {
            //     dir = 'left';
            // }
            // else if (k === 'l' || k === 'arrowright') {
            //     dir = 'right';
            // }
            if (k === 'j') {
                dir = 'down';
            } else if (k === 'k' ) {
                dir = 'up';
            }else if (k === 'h' ) {
                dir = 'left';
            }
            else if (k === 'l' ) {
                dir = 'right';
            }
            if (dir) {
                console.log('[YouTube Enhancer] Nav key pressed:', dir, 'path:', window.location.pathname);
                e.preventDefault();
                const r = App_Nav.move(dir);
                console.log('[YouTube Enhancer] Nav result:', r);
                if (r) {
                    if (isVisible) App_UI.show(r.data, r.index, r.total);
                    App_Nav.scroll();
                }
                return;
            }

            // Enter 键:在 watch 页面打开当前选中的推荐视频
            if (k === 'enter' && isWatchPage) {
                e.preventDefault(); e.stopPropagation();
                const cards = App_Core.findCards();
                if (cards.length) {
                    const data = App_Core.getPostData(cards[0]);
                    if (data?.url) window.open(data.url, '_blank');
                }
                return;
            }

            if (e.code === 'Space') {
                console.log('[YouTube Enhancer] Space pressed', { path: window.location.pathname, visible: App_UI.isVisible() });

                // 始终允许空格打开视频链接
                const path = window.location.pathname;
                const isWatchPage = path.startsWith('/watch');
                const isPreviewVisible = App_UI.isVisible();

                // 在 watch 页面且预览打开时,不处理(让 YouTube 原生处理)
                if (isWatchPage && isPreviewVisible) {
                    console.log('[YouTube Enhancer] Skipping - watch page with preview open');
                    return;
                }

                e.preventDefault(); e.stopPropagation();

                // 优先使用当前选中的卡片(.eh-active),否则找屏幕中心的卡片
                const activeCard = document.querySelector('.eh-active');
                let targetCard = null;

                if (activeCard) {
                    targetCard = activeCard;
                } else {
                    const cards = App_Core.findCards();
                    if (cards.length) {
                        const centerTop = window.innerHeight / 2;
                        const sorted = [...cards].sort((a, b) => {
                            const ra = a.getBoundingClientRect();
                            const rb = b.getBoundingClientRect();
                            return Math.abs(ra.top + ra.height / 2 - centerTop) - Math.abs(rb.top + rb.height / 2 - centerTop);
                        });
                        targetCard = sorted[0];
                    }
                }

                if (targetCard) {
                    const data = App_Core.getPostData(targetCard);
                    console.log('[YouTube Enhancer] Opening:', data?.url);
                    if (data?.url) window.open(data.url, '_blank');
                }
            }
            else if (k === 'enter') {
                // Enter 键:在 watch 页面打开右侧推荐视频
                if (window.location.pathname.startsWith('/watch')) {
                    e.preventDefault(); e.stopPropagation();
                    const cards = App_Core.findCards();
                    if (cards.length) {
                        const data = App_Core.getPostData(cards[0]);
                        if (data?.url) window.open(data.url, '_blank');
                    }
                }
            }
            else if (k === 'escape') {
                if (App_UI.isVisible()) {
                    App_UI.hide();
                } else {
                    window.close();
                }
            }
        }, true);
        App_UI.injectCSS();
        let lp = window.location.href; setInterval(() => { if (window.location.href !== lp) { lp = window.location.href; App_Nav.reset(); App_UI.hide(); } }, 800);
    };
    setTimeout(init, 2000);
})();