YouTube Enhancer (Simple)

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         YouTube Enhancer (Simple)
// @namespace    http://tampermonkey.net/
// @version      2.4
// @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',
            author: '#channel-name #text, ytd-channel-name #text, .yt-lockup-metadata-view-model__metadata a[href*="/@"], #byline-container a',
            postLink: 'a#video-title-link, a#video-title, .yt-lockup-metadata-view-model__title, .yt-lockup-view-model__content-image',
            contentSpans: '#video-title, #video-title-link, .yt-lockup-metadata-view-model__title',
            media: { img: 'yt-image img, .yt-core-image, 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(SITE_CONFIG.selectors.postLink);
                const titleEl = card.querySelector(SITE_CONFIG.selectors.contentSpans);
                const authorEl = card.querySelector(SITE_CONFIG.selectors.author);
                const avatarImg = card.querySelector('yt-img-shadow#avatar img, #avatar img, .yt-spec-avatar-shape__image');
                const mainImg = card.querySelector(SITE_CONFIG.selectors.media.img);

                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: () => {
                const all = Array.from(document.querySelectorAll(SITE_CONFIG.selectors.item));
                return 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;
                });
            }
        };
    })();

    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();

            if (k === 'j' || k === 'arrowdown') {
                // 如果预览开着,简单的索引加 1 往往符合直觉,但为了统一,全部走空间导航
                dir = 'down';
            } else if (k === 'k' || k === 'arrowup') {
                dir = 'up';
            } else if (k === 'l' || k === 'arrowright') {
                dir = 'right';
            } else if (k === 'h' || k === 'arrowleft') {
                dir = 'left';
            }

            if (dir) {
                e.preventDefault();
                const r = App_Nav.move(dir);
                if (r) {
                    if (isVisible) App_UI.show(r.data, r.index, r.total);
                    App_Nav.scroll();
                }
                return;
            }

            if (e.code === 'Space') {
                if (App_UI.isVisible() || !window.location.pathname.startsWith('/watch')) {
                    e.preventDefault(); e.stopPropagation();
                    const card = App_Nav.getCard();
                    if (card) { const data = App_Core.getPostData(card); if (data?.url) window.open(data.url, '_blank'); }
                }
            }
            else if (k === 'escape' && App_UI.isVisible()) App_UI.hide();
        }, 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);
})();