Plex Swiper

一个为 Plex Web 客户端定制的 UI 美化脚本,为首页和媒体库推荐页添加轮播图。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Plex Swiper
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description 一个为 Plex Web 客户端定制的 UI 美化脚本,为首页和媒体库推荐页添加轮播图。
// @description:zh-CN 一个为 Plex Web 客户端定制的 UI 美化脚本,为首页和媒体库推荐页添加轮播图。
// @description:en  A polished, auto-discovering, mixed-library sorting swiper for Plex Home & Libraries
// @author       onelxzy
// @match        https://app.plex.tv/*
// @match        http://*:32400/*
// @icon         https://app.plex.tv/desktop/favicon.ico
// @grant        GM_addStyle
// @run-at       document-start
// @license      GPL-3.0-only
// ==/UserScript==

(function() {
    'use strict';

    // ==========================================
    // 1. Bootstrapper: Environment & Dependencies
    // ==========================================

    const safeAddStyle = (css) => {
        if (typeof GM_addStyle !== 'undefined') {
            GM_addStyle(css);
        } else {
            const style = document.createElement('style');
            style.textContent = css;
            document.head.appendChild(style);
        }
    };

    const loadDependencies = (callback) => {
        const swiperCssUrl = 'https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css';
        if (!document.querySelector(`link[href="${swiperCssUrl}"]`)) {
            const link = document.createElement('link');
            link.rel = 'stylesheet';
            link.href = swiperCssUrl;
            document.head.appendChild(link);
        }

        if (typeof Swiper !== 'undefined') {
            callback();
        } else {
            const script = document.createElement('script');
            script.src = 'https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.js';
            script.onload = () => callback();
            script.onerror = () => console.error("Plex Swiper: Failed to load Swiper.js");
            document.head.appendChild(script);
        }
    };

    // ==========================================
    // 2. Core Application Logic
    // ==========================================

    const initPlexSwiper = () => {
        const CONFIG = {
            serverUrl: null,
            token: null,
            machineIdentifier: null
        };

        const DataCache = new Map();
        const CACHE_TTL = 10 * 60 * 1000;

        const State = {
            isConfigReady: false,
            sections: null,
            activeObserver: null,
            currentContainer: null
        };

        // --- Styles Injection ---
        safeAddStyle(`
            .plex-home-swiper-wrapper {
                width: calc(100% - 48px);
                max-width: 1600px;
                margin-left: auto;
                margin-right: auto;
                margin-bottom: 32px;
                padding-bottom: 46%;
                height: 0;
                max-height: 75vh;
                border-radius: 8px;
                position: relative;
                z-index: 1;
                background: #0d0d0d;
                display: block !important;
                overflow: hidden;
                box-shadow: 0 15px 40px rgba(0,0,0,0.6);
                transform: translateZ(0); will-change: transform;
            }

            .plex-home-swiper-wrapper.is-home {
                margin-top: 2px;
            }

            .plex-home-swiper-wrapper.is-library {
                margin-top: -15px;
            }

            @media (min-width: 1921px) {
                .plex-home-swiper-wrapper {
                     width: calc(100% - 80px);
                     padding-bottom: 38%;
                }
            }

            @media (max-width: 1000px) {
                .plex-home-swiper-wrapper {
                    width: calc(100% - 24px);
                    margin-top: 10px !important;
                    margin-bottom: 16px;
                }
            }

            .swiper { width: 100%; height: 100%; position: absolute; top: 0; left: 0; }
            .swiper-slide { background-position: center top; background-size: cover; position: relative; }

            .main-swiper .swiper-slide:not(.swiper-slide-active) { pointer-events: none !important; z-index: 0; }
            .main-swiper .swiper-slide-active { pointer-events: auto !important; z-index: 10; }
            .main-swiper .swiper-slide:not(.swiper-slide-active) .info-layer a { pointer-events: none !important; }

            .banner-mask {
                position: absolute; inset: 0;
                background: linear-gradient(to right, #000 0%, rgba(0,0,0,0.5) 40%, transparent 100%),
                            linear-gradient(to top, #000 0%, rgba(0,0,0,0.2) 40%, transparent 100%);
                z-index: 1;
                pointer-events: none;
            }
            .info-layer {
                position: absolute; bottom: 10%; left: 4%; width: 45%; z-index: 20;
                color: #eeeff0; text-shadow: 0 2px 4px rgba(0,0,0,0.9); pointer-events: none;
            }
            .info-layer a { pointer-events: auto; }
            .title-link { text-decoration: none; color: inherit; display: inline-block; transition: transform 0.2s ease; cursor: pointer; }
            .title-link:hover { transform: scale(1.02); opacity: 0.9; }

            .info-logo { max-width: 280px; max-height: 110px; width: auto; height: auto; display: block; margin-bottom: 15px; object-fit: contain; object-position: left bottom; }
            .info-title-text { font-size: clamp(1.5rem, 2.5vw, 3rem); font-weight: 700; line-height: 1.1; margin-bottom: 8px; font-family: "Open Sans", sans-serif; display: none; }
            .info-meta { font-size: 1rem; color: #e5a00d; font-weight: 600; margin-bottom: 10px; display: flex; align-items: center; gap: 10px; }
            .info-desc { font-size: 0.95rem; line-height: 1.6; opacity: 0.85; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; max-width: 600px; }

            .thumb-layer {
                position: absolute; bottom: 20px;
                right: 20px; left: auto; transform: none;
                width: auto; z-index: 20; padding: 5px; background: transparent;
            }
            .thumb-layer .swiper-wrapper { justify-content: flex-end !important; width: auto !important; }

            .thumb-layer .swiper-slide {
                width: 40px !important; height: 60px !important; border-radius: 4px; overflow: hidden;
                opacity: 0.5; border: 2px solid transparent; background: #1a1a1a;
                cursor: pointer; transition: all 0.2s ease; margin: 0 4px !important; flex-shrink: 0;
                box-shadow: 0 2px 5px rgba(0,0,0,0.8); pointer-events: auto !important;
            }
            .thumb-layer .swiper-slide:hover { opacity: 0.9; transform: translateY(-2px); }
            .thumb-layer .swiper-slide-thumb-active { opacity: 1; border-color: #e5a00d; transform: scale(1.1); box-shadow: 0 4px 12px rgba(0,0,0,0.9); z-index: 2; }
            .thumb-layer img { width: 100%; height: 100%; object-fit: cover; }
        `);

        // --- Config Recovery (Direct Injection Support) ---
        function tryRecoverConfig() {
            if (State.isConfigReady) return;

            const localToken = localStorage.getItem('myPlexAccessToken');
            if (localToken) CONFIG.token = localToken;

            if (!CONFIG.serverUrl && window.location.hostname !== 'app.plex.tv') {
                CONFIG.serverUrl = window.location.origin;
            }

            if (CONFIG.token && CONFIG.serverUrl) {
                State.isConfigReady = true;
                initMachineId();
                fetchSections();
            }
        }

        // --- Hooks & Networking ---

        function hookHistory() {
            const wrap = function(type) {
                const orig = history[type];
                return function() {
                    const rv = orig.apply(this, arguments);
                    const e = new Event(type);
                    e.arguments = arguments;
                    window.dispatchEvent(e);
                    return rv;
                };
            };
            history.pushState = wrap('pushState');
            history.replaceState = wrap('replaceState');
        }

        function hookNetwork() {
            const checkUrl = (url) => {
                if (State.isConfigReady) return;
                if (!url) return;
                if (url.includes('/hubs') || url.includes('/library') || url.includes('/sections')) {
                    try {
                        const urlObj = new URL(url);
                        const params = new URLSearchParams(urlObj.search);
                        const token = params.get('X-Plex-Token');
                        const serverUrl = urlObj.origin;
                        if (token && serverUrl && serverUrl.startsWith('http')) {
                            CONFIG.serverUrl = serverUrl;
                            CONFIG.token = token;
                            State.isConfigReady = true;
                            initMachineId();
                            fetchSections();
                        }
                    } catch (e) {}
                }
            };
            const originalFetch = window.fetch;
            window.fetch = function(...args) {
                const [resource] = args;
                if (typeof resource === 'string') checkUrl(resource);
                else if (resource instanceof Request) checkUrl(resource.url);
                return originalFetch.apply(this, args);
            };
            const originalOpen = XMLHttpRequest.prototype.open;
            XMLHttpRequest.prototype.open = function(method, url) {
                checkUrl(url);
                return originalOpen.apply(this, arguments);
            };
        }

        // --- API Helpers ---

        function getScreenQuality() {
            const width = window.innerWidth * (window.devicePixelRatio || 1);
            if (width > 2560) return { w: 3840, h: 2160 };
            if (width > 1920) return { w: 2560, h: 1440 };
            return { w: 1920, h: 1080 };
        }

        function getTranscodeUrl(path, targetType) {
            if (!path || !CONFIG.serverUrl) return '';
            let width, height;
            if (targetType === 'art') {
                const quality = getScreenQuality();
                width = quality.w; height = quality.h;
            } else {
                width = 300; height = 450;
            }
            const internalBase = 'http://127.0.0.1:32400';
            const assetUrl = `${internalBase}${path}?X-Plex-Token=${CONFIG.token}`;
            return `${CONFIG.serverUrl}/photo/:/transcode?url=${encodeURIComponent(assetUrl)}&width=${width}&height=${height}&minSize=1&X-Plex-Token=${CONFIG.token}`;
        }

        function getLogoUrl(item) {
            if (!CONFIG.serverUrl) return '';
            const id = item._targetId || item.ratingKey;
            const internalBase = 'http://127.0.0.1:32400';
            const assetUrl = `${internalBase}/library/metadata/${id}/clearLogo?X-Plex-Token=${CONFIG.token}`;
            return `${CONFIG.serverUrl}/photo/:/transcode?url=${encodeURIComponent(assetUrl)}&width=300&height=120&minSize=1&format=png&X-Plex-Token=${CONFIG.token}`;
        }

        async function initMachineId() {
            if (CONFIG.machineIdentifier) return;
            // Fallback: Try parsing from URL first
            const hashMatch = window.location.hash.match(/server\/([a-zA-Z0-9]+)\//);
            if (hashMatch && hashMatch[1]) {
                CONFIG.machineIdentifier = hashMatch[1];
            }
            // API call
            try {
                const res = await fetch(`${CONFIG.serverUrl}/?X-Plex-Token=${CONFIG.token}`, { headers: {'Accept': 'application/json'} });
                const data = await res.json();
                if (data.MediaContainer?.machineIdentifier) {
                    CONFIG.machineIdentifier = data.MediaContainer.machineIdentifier;
                }
            } catch(e) {}
        }

        async function fetchSections() {
            if (State.sections) return State.sections;
            try {
                const res = await fetch(`${CONFIG.serverUrl}/library/sections?X-Plex-Token=${CONFIG.token}`, { headers: {'Accept': 'application/json'} });
                const data = await res.json();
                State.sections = data.MediaContainer?.Directory || [];
                return State.sections;
            } catch { return []; }
        }

        async function hydrateItemDetails(item) {
            if (item.type === 'movie' || !item._isEpisodeAnchor) return item;
            const showId = item._targetId;
            const url = `${CONFIG.serverUrl}/library/metadata/${showId}?X-Plex-Token=${CONFIG.token}`;
            try {
                const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
                const data = await res.json();
                const showMetadata = data.MediaContainer?.Metadata?.[0];
                if (showMetadata) {
                    return { ...item, summary: showMetadata.summary || item.summary, year: showMetadata.year || item.year, rating: showMetadata.rating || item.rating, title: showMetadata.title || item.title };
                }
            } catch (e) {}
            return item;
        }

        // --- Data Fetching ---

        async function fetchSectionData(sec, limit = 15) {
            const typeStr = sec.type === 'show' ? '4' : '1';
            const url = `${CONFIG.serverUrl}/library/sections/${sec.key}/all?type=${typeStr}&sort=addedAt%3Adesc&limit=${limit}&X-Plex-Token=${CONFIG.token}`;
            return fetch(url, { headers: { 'Accept': 'application/json' } })
                .then(r => r.json())
                .then(d => d.MediaContainer?.Metadata || [])
                .then(items => items.map(i => ({
                    ...i,
                    title: i.grandparentTitle || i.title,
                    art: i.grandparentArt || i.art,
                    thumb: i.grandparentThumb || i.thumb,
                    _sortDate: parseInt(i.addedAt),
                    _targetId: i.grandparentRatingKey || i.ratingKey,
                    _isEpisodeAnchor: sec.type === 'show'
                })));
        }

        async function getDataForContext(contextType, sectionId = null, contextKey) {
            if (!State.isConfigReady) tryRecoverConfig();
            if (!State.isConfigReady) return [];

            if (!CONFIG.machineIdentifier) await initMachineId();

            const cached = DataCache.get(contextKey);
            if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) return cached.data;

            const sections = await fetchSections();
            let rawItems = [];

            if (contextType === 'home') {
                let promises = sections.map(sec => fetchSectionData(sec, 15));
                const results = await Promise.all(promises);
                rawItems = results.flat();
            } else if (contextType === 'library' && sectionId) {
                const targetSection = sections.find(s => s.key === sectionId);
                if (targetSection) rawItems = await fetchSectionData(targetSection, 15);
            }

            if (rawItems.length === 0) return [];
            rawItems.sort((a, b) => b._sortDate - a._sortDate);

            const uniqueMap = new Map();
            const candidateItems = [];
            for (const item of rawItems) {
                if (candidateItems.length >= 11) break;
                if (!item.art) continue;
                if (!uniqueMap.has(item._targetId)) {
                    uniqueMap.set(item._targetId, true);
                    candidateItems.push(item);
                }
            }

            const finalItems = await Promise.all(candidateItems.map(hydrateItemDetails));
            DataCache.set(contextKey, { data: finalItems, timestamp: Date.now() });
            return finalItems;
        }

        // --- Rendering ---

        function renderSwiper(container, items, contextKey) {
            if (container.dataset.swiperContext === contextKey && container.querySelector('.plex-home-swiper-wrapper')) return;
            const oldWrapper = container.querySelector('.plex-home-swiper-wrapper');
            if (oldWrapper) oldWrapper.remove();

            const serverId = CONFIG.machineIdentifier;

            const wrapper = document.createElement('div');
            wrapper.className = `plex-home-swiper-wrapper ${contextKey === 'home' ? 'is-home' : 'is-library'}`;
            wrapper.innerHTML = `
                <div class="swiper main-swiper"><div class="swiper-wrapper">
                ${items.map((item, index) => {
                    const logoUrl = getLogoUrl(item);
                    const slideId = `slide-${index}-${item.ratingKey}`;
                    const targetId = item._targetId;
                    const href = serverId ? `#!/server/${serverId}/details?key=${encodeURIComponent('/library/metadata/' + targetId)}` : '#';
                    const clickAction = serverId ? `window.location.hash='${href}'; return false;` : 'return false;';

                    return `<div class="swiper-slide" style="background-image: url('${getTranscodeUrl(item.art, 'art')}')">
                        <div class="banner-mask"></div>
                        <div class="info-layer">
                            <a href="${href}" class="title-link" onclick="${clickAction}">
                                <img src="${logoUrl}" class="info-logo" onload="this.style.display='block'" onerror="this.style.display='none'; document.getElementById('title-${slideId}').style.display='block';" />
                                <h1 id="title-${slideId}" class="info-title-text">${item.title}</h1>
                            </a>
                            <div class="info-meta">
                                <span>${item.year || ''}</span>
                                <span style="border:1px solid #e5a00d; padding:0 4px; border-radius:3px; font-size:0.8em;">${item.type === 'movie' ? '电影' : '剧集'}</span>
                                <span>${item.rating ? '★ ' + item.rating : ''}</span>
                            </div>
                            <div class="info-desc">${item.summary || ''}</div>
                        </div>
                    </div>`;
                }).join('')}
                </div></div>
                <div class="swiper thumb-layer"><div class="swiper-wrapper">
                ${items.map(item => `<div class="swiper-slide"><img src="${getTranscodeUrl(item.thumb, 'thumb')}" /></div>`).join('')}
                </div></div>`;

            if (container.firstChild) container.insertBefore(wrapper, container.firstChild);
            else container.appendChild(wrapper);
            container.dataset.swiperContext = contextKey;

            try {
                const thumbSwiper = new Swiper('.thumb-layer', { slidesPerView: 'auto', spaceBetween: 0, watchSlidesProgress: true, allowTouchMove: false, loop: false, centerInsufficientSlides: true });
                new Swiper('.main-swiper', { spaceBetween: 0, effect: 'fade', speed: 1000, loop: true, autoplay: { delay: 10000, disableOnInteraction: false }, thumbs: { swiper: thumbSwiper } });
            } catch (e) { console.error("Plex Swiper Error:", e); }
        }

        // --- Context Detection & Loop ---

        function isContextPage() {
            const hash = window.location.hash;
            const cleanBase = hash.split('?')[0].replace(/\/$/, '');
            if ((cleanBase === '#!' || cleanBase === '#!/desktop') && !hash.includes('tab=') && !hash.includes('/details')) return { type: 'home', key: 'home' };
            if (hash.includes('com.plexapp.plugins.library') && hash.includes('source=') && !hash.includes('pivot=library')) {
                const isRecommended = hash.includes('pivot=recommended') || document.querySelector('a[class*="TabButton-selected"]')?.innerText === '推荐' || document.querySelector('a[class*="TabButton-selected"]')?.innerText === 'Recommended';
                if (isRecommended || document.querySelector('[class*="Hub-hub-"]')) {
                    const sid = new URLSearchParams(hash.split('?')[1]).get('source');
                    if (sid) return { type: 'library', key: `lib_${sid}`, id: sid };
                }
            }
            return null;
        }

        function startObserver(container, context) {
            if (State.activeObserver && State.currentContainer === container && container.dataset.observerContext === context.key) return;
            if (State.activeObserver) { State.activeObserver.disconnect(); State.activeObserver = null; }

            State.currentContainer = container;
            container.dataset.observerContext = context.key;
            checkAndRender(container, context);

            const observer = new MutationObserver(() => checkAndRender(container, context));
            observer.observe(container, { childList: true });
            State.activeObserver = observer;
        }

        async function checkAndRender(container, context) {
            const currentCtx = isContextPage();
            if (!currentCtx || currentCtx.key !== context.key) return;
            if (!container.querySelector('.plex-home-swiper-wrapper')) {
                const items = await getDataForContext(context.type, context.id, context.key);
                if (items.length > 0) renderSwiper(container, items, context.key);
            }
        }

        const runLoop = () => {
            if (!State.isConfigReady) tryRecoverConfig();
            if (!State.isConfigReady) return;

            const context = isContextPage();
            const selectors = ['.PageContent-pageContentScroller-dvaH3C', '[class*="PageContent-pageContentScroller"]', '.DirectoryHubsPageContent-pageContentScroller-jceJrG', '[class*="DirectoryHubsPageContent-pageContentScroller"]', '[data-testid="home-page-content"]'];
            let container = null;
            for (let sel of selectors) {
                const el = document.querySelector(sel);
                if (el && el.offsetHeight > 0) { container = el; break; }
            }

            if (context && container) {
                startObserver(container, context);
            } else {
                if (State.activeObserver) { State.activeObserver.disconnect(); State.activeObserver = null; }
                if (container) {
                    const swiper = container.querySelector('.plex-home-swiper-wrapper');
                    if (swiper) swiper.remove();
                    container.removeAttribute('data-swiper-context');
                    container.removeAttribute('data-observer-context');
                }
            }
        };

        hookNetwork();
        hookHistory();
        setInterval(runLoop, 200);
        window.addEventListener('hashchange', runLoop);
        window.addEventListener('popstate', runLoop);
        document.addEventListener('click', () => setTimeout(runLoop, 50));
    };

    // ==========================================
    // 3. Execution
    // ==========================================
    loadDependencies(initPlexSwiper);

})();