xHamster Ultimate Enhancer

Enhances xHamster: hides watched videos, auto large player, advanced filters, mark viewed, scroll to top

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         xHamster Ultimate Enhancer
// @namespace    Violentmonkey Scripts
// @version      1.2
// @description  Enhances xHamster: hides watched videos, auto large player, advanced filters, mark viewed, scroll to top
// @author       dweebz
// @license      MIT
// @match        https://xhamster.com/*
// @match        https://*.xhamster.com/*
// @match        https://xhamster2.com/*
// @match        https://*.xhamster2.com/*
// @match        https://xhamster3.com/*
// @match        https://*.xhamster3.com/*
// @include      *xhamster.com/*
// @grant        GM.addStyle
// @require      https://code.jquery.com/jquery-3.7.1.min.js
// @run-at       document-end
// ==/UserScript==

/* global $ */

(function() {
    'use strict';

    // Options storage
    const options = {
        autoResizePlayer: JSON.parse(localStorage.getItem('xh_autoResizePlayer')) || false,
        hdOnly: JSON.parse(localStorage.getItem('xh_hdOnly')) || false,
        minViews: parseInt(localStorage.getItem('xh_minViews')) || 0,
        maxViews: parseInt(localStorage.getItem('xh_maxViews')) || 10000000,
        minDuration: parseInt(localStorage.getItem('xh_minDuration')) || 0,
        maxDuration: parseInt(localStorage.getItem('xh_maxDuration')) || 9999,
        minRating: parseInt(localStorage.getItem('xh_minRating')) || 0,
        maxRating: parseInt(localStorage.getItem('xh_maxRating')) || 100,
        textSearch: localStorage.getItem('xh_textSearch') || '',
        textWhitelist: localStorage.getItem('xh_textWhitelist') || '',
        textBlacklist: localStorage.getItem('xh_textBlacklist') || '',
        textSanitize: localStorage.getItem('xh_textSanitize') || '',
        disableFilters: JSON.parse(localStorage.getItem('xh_disableFilters')) || false
    };

    // CSS: watched hiding + layout tweaks + buttons
    GM.addStyle(`
        .plus-buttons {
            background: rgba(67,67,67,0.85);
            box-shadow: 0 0 12px rgba(20,111,223,0.85);
            font-size: 12px;
            position: fixed;
            bottom: 10px;
            padding: 10px 22px 8px 24px;
            right: 0;
            z-index: 100;
            transition: all 0.3s ease;
            color: white;
            border-radius: 8px 0 0 8px;
        }
        .plus-buttons:hover { box-shadow: 0 0 3px rgba(0,0,0,0.3); }
        .plus-button {
            margin: 10px 0;
            padding: 6px 15px;
            border-radius: 4px;
            font-weight: 700;
            display: block;
            text-align: center;
            cursor: pointer;
            border: none;
            text-decoration: none;
            background: rgb(221,221,221);
            color: rgb(51,51,51);
        }
        .plus-button:hover { background: rgb(187,187,187); }
        .plus-button.isOn { background: rgb(20,111,223); color: #fff; }
        .plus-button.isOn:hover { background: rgb(0,91,203); }
        .plus-hidden { display: none !important; }

        /* Hide watched videos - display:none for better reflow */
        .thumb-list__item:has(> a > div > div.thumb-image-container__watched),
        .thumb-list-mobile-item:has(div > a > div > div.thumb-image-container__watched),
        [class*="watched"], .watched-overlay, .thumb-watched {
            display: none !important;
        }

        /* Override on excluded pages (e.g., favorites) */
        .xh-no-hide-watched .thumb-list__item:has(> a > div > div.thumb-image-container__watched),
        .xh-no-hide-watched .thumb-list-mobile-item:has(div > a > div > div.thumb-image-container__watched),
        .xh-no-hide-watched [class*="watched"], .xh-no-hide-watched .watched-overlay, .xh-no-hide-watched .thumb-watched {
            display: block !important;
        }

        /* Layout improvements */
        .video-page.video-page--large-mode .player-container__player {
            height: 720px;
            max-height: 90vh !important;
        }
        .favorites-dropdown__list { max-height: unset !important; }
        .entity-container { margin: 22px 0; border-top: 1px solid #ccc; }
    `);

    function applyEnhancements() {
        $('.thumb-list__item, .thumb-list-mobile-item').each(function() {
            const item = $(this);
            if (item.hasClass('plus-hidden')) return;

            const titleEl = item.find('a.video-thumb-info__name, .video-thumb-info__name-link');
            const title = titleEl.length ? titleEl.text().toLowerCase() : '';

            const durationStr = item.find('.thumb-image-container__duration, [class*="duration"]').text() || '';
            const viewsStr = item.find('.views .metric-text, [class*="views"]').text().replace(/[^0-9]/g, '') || '0';
            const ratingStr = item.find('.rating .metric-text, [class*="rating"]').text().replace('%', '') || '0';
            const hasHD = item.find('i.thumb-image-container__icon--hd, i.thumb-image-container__icon--uhd, [class*="hd-icon"]').length > 0;

            const durationMin = parseDuration(durationStr);
            const views = parseInt(viewsStr) || 0;
            const rating = parseInt(ratingStr) || 0;

            let hide = false;

            if (options.hdOnly && !hasHD) hide = true;
            if (views < options.minViews || views > options.maxViews) hide = true;
            if (durationMin < options.minDuration || durationMin > options.maxDuration) hide = true;
            if (rating < options.minRating || rating > options.maxRating) hide = true;

            const searchTerms = options.textSearch.toLowerCase().split('\n').filter(t => t.trim());
            const whitelist = options.textWhitelist.toLowerCase().split('\n').filter(t => t.trim());
            const blacklist = options.textBlacklist.toLowerCase().split('\n').filter(t => t.trim());

            if (searchTerms.length && !searchTerms.every(term => title.includes(term))) hide = true;
            if (whitelist.length && !whitelist.some(term => title.includes(term))) hide = true;
            if (blacklist.some(term => title.includes(term))) hide = true;

            if (hide) item.addClass('plus-hidden');
        });
    }

    function parseDuration(str) {
        const parts = str.match(/(\d+):?(\d+)?/) || [];
        const min = parseInt(parts[1]) || 0;
        const sec = parseInt(parts[2]) || 0;
        return min + (sec / 60);
    }

    function applySanitization(text) {
        const rules = options.textSanitize.split('\n').filter(r => r.trim());
        rules.forEach(rule => {
            const [from, to = ''] = rule.split('=').map(s => s.trim());
            if (from) text = text.replace(new RegExp(from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), to);
        });
        return text;
    }

    function markAllViewed() {
        const currentUrl = window.location.href;
        $('a.video-thumb-info__name, .video-thumb-info__name-link').each((_, el) => {
            history.replaceState({}, '', el.href);
        });
        history.replaceState({}, '', currentUrl);
        location.reload();
    }

    function forceReflow() {
        const containers = ['.thumb-list', '.thumb-list-mobile', 'main', '.video-list'];
        const scrollPos = window.scrollY;
        containers.forEach(sel => {
            const el = document.querySelector(sel);
            if (el) {
                el.style.display = 'none';
                void el.offsetHeight; // trigger reflow
                el.style.display = '';
            }
        });
        window.scrollTo(0, scrollPos);
    }

    const observer = new MutationObserver(() => {
        if (!options.disableFilters) {
            applyEnhancements();
            forceReflow();
        }
    });
    observer.observe(document.body, { childList: true, subtree: true });

    window.addEventListener('DOMContentLoaded', () => {
        const isVideoPage = window.location.pathname.startsWith('/videos');
        const isFavoritesPage = window.location.pathname.startsWith('/my/favorites/videos');
        const player = document.querySelector('#player-container');
        const video = player ? player.querySelector('video') : null;

        // Exclude watched hiding on favorites page
        if (isFavoritesPage) {
            document.body.classList.add('xh-no-hide-watched');
        }

        if (video && options.autoResizePlayer && isVideoPage) {
            video.addEventListener('canplay', () => {
                const largeBtn = document.querySelector('.large-mode, [class*="large"], .player-large-toggle');
                if (largeBtn) largeBtn.click();
            }, { once: true });
        }

        if (player) {
            window.addEventListener('resize', () => {
                if (document.querySelector('.xplayer-large-mode, [class*="large-mode"]')) {
                    player.style.maxHeight = `${window.innerHeight - 60}px`;
                }
            });
        }

        if (options.textSanitize && isVideoPage) {
            const title = $('h1').first();
            if (title.length) title.text(applySanitization(title.text()));
        }

        if (!options.disableFilters) {
            applyEnhancements();
            forceReflow();
        }

        const buttons = $('<div class="plus-buttons"></div>');

        const resizeBtn = $('<a class="plus-button"></a>').text('Auto Large Player').addClass(options.autoResizePlayer ? 'isOn' : '');
        resizeBtn.click(() => {
            options.autoResizePlayer = !options.autoResizePlayer;
            localStorage.setItem('xh_autoResizePlayer', options.autoResizePlayer);
            resizeBtn.toggleClass('isOn');
            location.reload();
        });

        const scrollBtn = $('<a class="plus-button"></a>').text('Scroll to Top').click(() => window.scrollTo({ top: 0, behavior: 'smooth' }));

        const markBtn = $('<a class="plus-button"></a>').text('Mark All Viewed').click(markAllViewed);

        const disableBtn = $('<a class="plus-button"></a>').text('Disable Filters').addClass(options.disableFilters ? 'isOn' : '');
        disableBtn.click(() => {
            options.disableFilters = !options.disableFilters;
            localStorage.setItem('xh_disableFilters', options.disableFilters);
            disableBtn.toggleClass('isOn');
            location.reload();
        });

        const filtersForm = $(`
            <div id="xh-filters" style="background: rgba(255,255,255,0.92); color: black; padding: 12px; border: 1px solid #aaa; border-radius: 6px; margin-top: 10px; display: none; max-width: 280px;">
                <strong>Filters (save & reload):</strong><br>
                <label><input type="checkbox" ${options.hdOnly ? 'checked' : ''}> HD Only</label><br>
                Min Views: <input type="number" value="${options.minViews}" class="minViews"><br>
                Max Views: <input type="number" value="${options.maxViews}" class="maxViews"><br>
                Min Duration (min): <input type="number" value="${options.minDuration}" class="minDuration"><br>
                Max Duration (min): <input type="number" value="${options.maxDuration}" class="maxDuration"><br>
                Min Rating (%): <input type="number" value="${options.minRating}" class="minRating"><br>
                Max Rating (%): <input type="number" value="${options.maxRating}" class="maxRating"><br>
                Text Search:<br><textarea class="textSearch" rows="3">${options.textSearch}</textarea><br>
                Whitelist:<br><textarea class="textWhitelist" rows="3">${options.textWhitelist}</textarea><br>
                Blacklist:<br><textarea class="textBlacklist" rows="3">${options.textBlacklist}</textarea><br>
                Sanitize (old=new):<br><textarea class="textSanitize" rows="3">${options.textSanitize}</textarea><br>
                <button id="saveFilters" style="margin-top:8px; width:100%;">Save & Reload</button>
            </div>
        `);

        const showFiltersBtn = $('<a class="plus-button"></a>').text('Filters').click(() => $('#xh-filters').toggle());

        $('#saveFilters', filtersForm).click(() => {
            options.hdOnly = filtersForm.find('input[type=checkbox]').is(':checked');
            options.minViews = parseInt(filtersForm.find('.minViews').val()) || 0;
            options.maxViews = parseInt(filtersForm.find('.maxViews').val()) || 10000000;
            options.minDuration = parseInt(filtersForm.find('.minDuration').val()) || 0;
            options.maxDuration = parseInt(filtersForm.find('.maxDuration').val()) || 9999;
            options.minRating = parseInt(filtersForm.find('.minRating').val()) || 0;
            options.maxRating = parseInt(filtersForm.find('.maxRating').val()) || 100;
            options.textSearch = filtersForm.find('.textSearch').val();
            options.textWhitelist = filtersForm.find('.textWhitelist').val();
            options.textBlacklist = filtersForm.find('.textBlacklist').val();
            options.textSanitize = filtersForm.find('.textSanitize').val();

            Object.entries(options).forEach(([key, val]) => localStorage.setItem(`xh_${key}`, JSON.stringify(val)));
            location.reload();
        });

        buttons.append(resizeBtn, scrollBtn, markBtn, disableBtn, showFiltersBtn, filtersForm);
        $('body').append(buttons);

        // Dynamic slide-out
        setTimeout(() => {
            const width = buttons.outerWidth();
            GM.addStyle(`.plus-buttons { margin-right: -${width - 23}px; } .plus-buttons:hover { margin-right: 0; }`);
        }, 500);
    });
})();