Stripchat Native Fullscreen - Smart Exit

Detects native or site fullscreen and hides the chat once immediately, while still allowing the user to show it again.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Stripchat Native Fullscreen - Smart Exit
// @namespace    http://tampermonkey.net/
// @version      1.8
// @description  Detects native or site fullscreen and hides the chat once immediately, while still allowing the user to show it again.
// @author       Thiago
// @match        https://stripchat.com/*
// @match        https://*.stripchat.com/*
// @grant        none
// ==/UserScript==

// Changelog:
// v1.8 - Hides the chat once immediately after native or site fullscreen starts.
//        Supports auto-hidden player controls and lets the user show the chat again.
// v1.7 - Native fullscreen with manual-exit detection and the "T" shortcut.

(function () {
    'use strict';

    const CHAT_BUTTON_RETRY_MS = 200;
    const CHAT_BUTTON_MAX_WAIT_MS = 10000;
    const FULLSCREEN_STATE_POLL_MS = 250;

    const STATE = {
        userExited: false,
        autoTried: false,
        fullscreenActive: false,
        chatClickDone: false,
        chatHideTimer: null,
        chatSearchTimer: null
    };

    const isVisible = (element) => {
        if (!element) return false;
        const rect = element.getBoundingClientRect();
        const style = getComputedStyle(element);
        return rect.width > 0 &&
               rect.height > 0 &&
               style.display !== 'none' &&
               style.visibility !== 'hidden';
    };

    const getFSButton = () => {
        return document.querySelector('button .icon-fullscreen-on')?.closest('button') ||
               document.querySelector('button .icon-fullscreen-off')?.closest('button');
    };

    const isFullscreenActive = () => {
        const siteFullscreenButton = document.querySelector('button .icon-fullscreen-off')?.closest('button');
        return Boolean(
            document.fullscreenElement ||
            (siteFullscreenButton && isVisible(siteFullscreenButton))
        );
    };

    const toggleNativeFS = (force = false) => {
        const fsButton = getFSButton();
        const isCurrentlyFS = isFullscreenActive();

        if (fsButton && (force || (!isCurrentlyFS && !STATE.userExited))) {
            fsButton.click();
        }
    };

    const wakeFullscreenControls = () => {
        const fullscreenRoot = document.fullscreenElement ||
                               document.querySelector('.player-controls-fullscreen__main') ||
                               document.documentElement;

        const rect = fullscreenRoot.getBoundingClientRect();
        const clientX = Math.round(rect.left + rect.width / 2);
        const clientY = Math.round(rect.top + Math.min(rect.height * 0.25, 180));
        const targets = [
            fullscreenRoot.querySelector('video'),
            fullscreenRoot,
            document.body,
            document,
            window
        ].filter(Boolean);

        const mouseOptions = {
            bubbles: true,
            cancelable: true,
            composed: true,
            view: window,
            clientX,
            clientY,
            screenX: clientX,
            screenY: clientY,
            movementX: 1,
            movementY: 1
        };

        for (const target of targets) {
            try {
                if (typeof PointerEvent === 'function') {
                    target.dispatchEvent(new PointerEvent('pointermove', {
                        ...mouseOptions,
                        pointerId: 1,
                        pointerType: 'mouse',
                        isPrimary: true
                    }));
                }
                target.dispatchEvent(new MouseEvent('mousemove', mouseOptions));
                target.dispatchEvent(new MouseEvent('mouseover', mouseOptions));
                target.dispatchEvent(new MouseEvent('mouseenter', {
                    ...mouseOptions,
                    bubbles: false
                }));
            } catch (_) {}
        }

        console.log('[SC-FS] Fullscreen controls wake-up requested.');
    };

    const describeControl = (element) => {
        const icon = element.querySelector('[class], svg, i');
        return [
            element.getAttribute('title'),
            element.getAttribute('aria-label'),
            element.getAttribute('data-testid'),
            element.getAttribute('data-test'),
            element.className,
            icon?.getAttribute('class'),
            element.textContent
        ].filter(Boolean).join(' ').toLowerCase();
    };

    const getChatToggleButton = () => {
        const fullscreenRoot = document.fullscreenElement || document;

        // Known specific selectors take priority.
        const specificSelectors = [
            '.player-chat-button',
            '.fullscreen-chat-toggle',
            '.player-chat-toggle',
            '.chat-toggle',
            '.toggle-chat',
            '[class*="chat-toggle" i]',
            '[class*="chat_button" i]',
            '[data-testid*="chat-toggle" i]',
            'button:has(.icon-chat-off)',
            'button:has(.icon-chat-on)',
            'button:has([class*="chat-disabled" i])',
            'button:has([class*="chat-enabled" i])'
        ];

        for (const selector of specificSelectors) {
            const match = Array.from(fullscreenRoot.querySelectorAll(selector))
                .map((element) => element.closest('button, [role="button"]') || element)
                .find(isVisible);
            if (match) return match;
        }

        // Fallback that does not depend on a fixed Stripchat class name.
        const candidates = Array.from(
            fullscreenRoot.querySelectorAll('button, [role="button"]')
        ).filter(isVisible);

        return candidates.find((element) => {
            const description = describeControl(element);
            const controlsChatOrInterface = /chat|interface|overlay/.test(description);
            const hidesIt = /hide|disable|close|off|ocultar|desabilitar|desativar|fechar/.test(description);
            const unrelated = /resolution|quality|qualidade|volume|sound|mute|tip|fullscreen-(on|off)/.test(description);
            return controlsChatOrInterface && hidesIt && !unrelated;
        }) || null;
    };

    const chatIsAlreadyHidden = (button) => {
        const actionText = [
            button.getAttribute('title'),
            button.getAttribute('aria-label'),
            button.textContent
        ].filter(Boolean).join(' ').toLowerCase();

        // The label describes what clicking will do. "Hide Chat" therefore
        // means the chat is visible now and the button must be clicked.
        if (/hide|disable|close|ocultar|desabilitar|desativar|fechar/.test(actionText)) return false;
        if (/show|enable|open|mostrar|habilitar|ativar/.test(actionText)) return true;

        const iconState = [
            button.className,
            button.querySelector('[class], svg, i')?.getAttribute('class')
        ].filter(Boolean).join(' ').toLowerCase();

        // Stripchat uses icon-chat-enabled while the chat is visible and
        // icon-chat-disabled after it has been hidden.
        if (/chat-disabled|chat-off|chat-hidden/.test(iconState)) return true;
        if (/chat-enabled|chat-on|chat-active/.test(iconState)) return false;

        // Fullscreen opens with chat visible by default.
        return false;
    };

    const clearChatTimers = () => {
        if (STATE.chatHideTimer) {
            clearTimeout(STATE.chatHideTimer);
            STATE.chatHideTimer = null;
        }
        if (STATE.chatSearchTimer) {
            clearInterval(STATE.chatSearchTimer);
            STATE.chatSearchTimer = null;
        }
    };

    const clickChatToggleOnce = () => {
        if (STATE.chatClickDone || !isFullscreenActive()) return true;

        const button = getChatToggleButton();
        if (!button) return false;

        if (chatIsAlreadyHidden(button)) {
            STATE.chatClickDone = true;
            console.log('[SC-FS] The chat was already hidden; no click was needed.');
            return true;
        }

        // Set the flag before clicking to prevent repetition from synchronous events.
        STATE.chatClickDone = true;
        console.log('[SC-FS] Hiding the chat/interface with a single click.', button);
        button.click();
        return true;
    };

    const findAndClickChatToggle = () => {
        wakeFullscreenControls();
        return clickChatToggleOnce();
    };

    const hideChatOnFullscreen = () => {
        clearChatTimers();

        if (!isFullscreenActive() || STATE.chatClickDone) return;

        // The controls are normally still visible at this point, so try now.
        if (findAndClickChatToggle()) return;

        // If Stripchat has not mounted the button yet, retry lookup without
        // performing any extra click after the first successful one.
        const searchStartedAt = Date.now();
        STATE.chatSearchTimer = setInterval(() => {
            const timedOut = Date.now() - searchStartedAt >= CHAT_BUTTON_MAX_WAIT_MS;
            const clickedOrAlreadyHidden = findAndClickChatToggle();
            if (clickedOrAlreadyHidden || timedOut || !isFullscreenActive()) {
                clearInterval(STATE.chatSearchTimer);
                STATE.chatSearchTimer = null;
            }
        }, CHAT_BUTTON_RETRY_MS);
    };

    const syncFullscreenState = () => {
        const fullscreenActive = isFullscreenActive();
        if (fullscreenActive === STATE.fullscreenActive) return;

        STATE.fullscreenActive = fullscreenActive;

        if (fullscreenActive) {
            STATE.chatClickDone = false;
            console.log('[SC-FS] Fullscreen active; hiding the chat once now.');
            hideChatOnFullscreen();
        } else {
            STATE.userExited = true;
            STATE.chatClickDone = false;
            clearChatTimers();
            console.log('[SC-FS] Manual fullscreen exit detected and respected.');
        }
    };

    document.addEventListener('fullscreenchange', () => {
        // Stripchat may update its own fullscreen icon immediately after this event.
        setTimeout(syncFullscreenState, 0);
    });

    // Use the user's first interaction to request fullscreen.
    document.addEventListener('click', function once() {
        if (!STATE.autoTried) {
            toggleNativeFS();
            STATE.autoTried = true;
        }
    }, { capture: true, once: true });

    // Press "T" to toggle fullscreen manually.
    document.addEventListener('keydown', (event) => {
        const activeTag = document.activeElement?.tagName || '';
        if (event.key.toLowerCase() === 't' && !['INPUT', 'TEXTAREA'].includes(activeTag)) {
            STATE.userExited = false;
            toggleNativeFS(true);
        }
    });

    const observer = new MutationObserver(() => {
        if (!STATE.autoTried && getFSButton()) {
            toggleNativeFS();
            STATE.autoTried = true;
        }
        syncFullscreenState();
    });

    observer.observe(document.body, { childList: true, subtree: true });
    setInterval(syncFullscreenState, FULLSCREEN_STATE_POLL_MS);
    syncFullscreenState();
})();