Youtube Fullscreen Mode

-

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name              Youtube Fullscreen Mode
// @name:ko           유튜브 풀스크린
// @description       -
// @description:ko    -
// @version           2025.09.11
// @author            ndaesik
// @icon              https://upload.wikimedia.org/wikipedia/commons/thumb/f/fc/Youtube_shorts_icon.svg/193px-Youtube_shorts_icon.svg.png
// @match             *://*.youtube.com/*
// @grant             none
// @namespace https://ndaesik.tistory.com/
// ==/UserScript==

(() => {
    'use strict';

    // 최상위 문서만 (embed/iframe 차단)
    if (window.top !== window.self) return;

    // ---------- CSS 정의 (노드 재사용) ----------
    const suggestBoxToDarkCSS = document.createElement('style');
    suggestBoxToDarkCSS.dataset.tm = 'yt-fullscreen-suggest';
    suggestBoxToDarkCSS.textContent = `
    body{overflow-y:auto;}
  `.replaceAll(';','!important;');

    const fullscreenVideoCSS = document.createElement('style');
    fullscreenVideoCSS.dataset.tm = 'yt-fullscreen-video';
    fullscreenVideoCSS.textContent = `
    ytd-app:not([guide-persistent-and-visible]) [theater] #player video,
    :is(ytd-watch-flexy[theater],ytd-watch-flexy[fullscreen]) #full-bleed-container {
      height:100vh;max-height:100vh;min-height:100vh;
    }
    ytd-watch-flexy[theater]{scrollbar-width:none;}
    ytd-watch-flexy[theater]::-webkit-scrollbar{display:none;}
    ytd-watch-flexy[theater] ~ body{scrollbar-width:none;-ms-overflow-style:none;}
    ytd-watch-flexy[theater] ~ body::-webkit-scrollbar{display:none;}
  `.replaceAll(';','!important;');

    const autoHideTopCSS = document.createElement('style');
    autoHideTopCSS.dataset.tm = 'yt-fullscreen-autohide';
    autoHideTopCSS.className = 'autoHideTopCSS';
    autoHideTopCSS.textContent = `
    #masthead-container.ytd-app:hover,#masthead-container.ytd-app:focus-within{width:100%;}
    #masthead-container.ytd-app,
    #masthead-container.ytd-app:not(:hover):not(:focus-within){width:calc(50% - 150px);}
    #masthead-container.ytd-app:not(:hover):not(:focus-within){transition:width .4s ease-out .4s;}
    ytd-app:not([guide-persistent-and-visible]) :is(#masthead-container ytd-masthead, #masthead-container.ytd-app::after){
      transform:translateY(-56px);transition:transform .1s .3s ease-out;
    }
    ytd-app:not([guide-persistent-and-visible]) :is(#masthead-container:hover ytd-masthead, #masthead-container:hover.ytd-app::after, #masthead-container:focus-within ytd-masthead){
      transform:translateY(0);
    }
    ytd-app:not([guide-persistent-and-visible]) ytd-page-manager{margin-top:0;}
  `.replaceAll(';','!important;');

    // ---------- 유틸 ----------
    const $ = {
        els: { ytdApp: null, player: null, chatFrame: null },
        update() {
            this.els.ytdApp = document.querySelector('ytd-app');
            this.els.player = document.querySelector('#ytd-player');
            this.els.chatFrame = document.querySelector('ytd-live-chat-frame');
        }
    };

    let scrollTimer = null, isContentHidden = false;
    let observerForTheater = null;
    let listenersAttached = false;

    // (3) 리스너 수명관리용 AbortController
    let listenerAbort = null;

    const isWatchPage = () => location.pathname === '/watch';
    const inTheater = () => {
        $.update();
        const { ytdApp, player, chatFrame } = $.els;
        return ytdApp && player && isWatchPage() &&
            (window.innerWidth - ytdApp.offsetWidth + player.offsetWidth +
             (chatFrame && !chatFrame.attributes.collapsed ? chatFrame.offsetWidth : 0)) === window.innerWidth;
    };

    const resetScrollTopHard = () => {
        try {
            if ('scrollRestoration' in history) history.scrollRestoration = 'manual';
        } catch (_) {}

        // 1) 즉시
        window.scrollTo(0, 0);
        // 2) 첫 rAF
        requestAnimationFrame(() => {
            window.scrollTo(0, 0);
            // 3) 두 번째 rAF
            requestAnimationFrame(() => {
                window.scrollTo(0, 0);
            });
        });
        // 4) 레이아웃/광고/플레이어 로딩 후 혹시 모를 밀림 보정
        setTimeout(() => window.scrollTo(0, 0), 1000);
    };

    // (4) autoHide 상태 캐싱
    let autoHideOn = false;

    const shouldShowAutoHideCSS = () => {
        const scrollPosition = window.scrollY;
        const viewportHeight = window.innerHeight + 56;
        return isWatchPage() && inTheater() && scrollPosition <= viewportHeight;
    };

    const updateAutoHideCSS = () => {
        const need = shouldShowAutoHideCSS();
        if (need === autoHideOn) return; // 상태 변화 없으면 skip
        autoHideOn = need;
        if (need) {
            if (!document.head.querySelector('style[data-tm="yt-fullscreen-autohide"]')) {
                document.head.appendChild(autoHideTopCSS);
            }
        } else {
            autoHideTopCSS.remove();
        }
    };

    const checkConditions = () => {
        if (!isWatchPage()) return;

        const watchFlexy = document.querySelector('ytd-watch-flexy');
        const primaryContent = document.querySelector('#primary');
        const secondaryContent = document.querySelector('#secondary');
        const isTheater = watchFlexy?.hasAttribute('theater');
        const isScrollTop = window.scrollY === 0;

        if (!primaryContent || !secondaryContent || !isTheater) return;

        if (isScrollTop && !isContentHidden) {
            if (scrollTimer) clearTimeout(scrollTimer);
            scrollTimer = setTimeout(() => {
                primaryContent.style.display = 'none';
                secondaryContent.style.display = 'none';
                isContentHidden = true;
            }, 2000);
        } else if (!isScrollTop && scrollTimer) {
            clearTimeout(scrollTimer);
            scrollTimer = null;
            if (isContentHidden) {
                primaryContent.style.display = '';
                secondaryContent.style.display = '';
                isContentHidden = false;
            }
        }
    };

    const showContent = () => {
        const primaryContent = document.querySelector('#primary');
        const secondaryContent = document.querySelector('#secondary');
        if (isContentHidden && primaryContent && secondaryContent) {
            primaryContent.style.display = '';
            secondaryContent.style.display = '';
            isContentHidden = false;
            if (scrollTimer) { clearTimeout(scrollTimer); scrollTimer = null; }
            setTimeout(checkConditions, 1000);
        }
    };

    // ---------- CSS attach/detach ----------
    const attachCSS = () => {
        // 이미 붙어 있으면 skip
        if (!document.head.querySelector('style[data-tm="yt-fullscreen-suggest"]')) {
            document.head.appendChild(suggestBoxToDarkCSS);
        }
        if (!document.head.querySelector('style[data-tm="yt-fullscreen-video"]')) {
            document.head.appendChild(fullscreenVideoCSS);
        }
        updateAutoHideCSS();
    };

    const detachCSS = () => {
        // CSS 회수
        suggestBoxToDarkCSS.remove();
        fullscreenVideoCSS.remove();
        autoHideTopCSS.remove();

        // (3) 옵저버 종료
        observerForTheater?.disconnect();
        observerForTheater = null;

        // (3) 리스너 종료
        if (listenerAbort) { listenerAbort.abort(); listenerAbort = null; }
        listenersAttached = false;

        // 상태 원복
        if (isContentHidden) {
            const primaryContent = document.querySelector('#primary');
            const secondaryContent = document.querySelector('#secondary');
            if (primaryContent) primaryContent.style.display = '';
            if (secondaryContent) secondaryContent.style.display = '';
            isContentHidden = false;
        }
        if (scrollTimer) { clearTimeout(scrollTimer); scrollTimer = null; }

        // (4) 캐시 초기화
        autoHideOn = false;
    };

    // ---------- 이벤트 바인딩 (중복 방지) ----------
    const setupEventListeners = () => {
        if (listenersAttached) return;
        listenersAttached = true;

        // (3) AbortController 기반 일괄 수명관리
        listenerAbort = new AbortController();
        const sig = listenerAbort.signal;

        window.addEventListener('scroll', () => requestAnimationFrame(() => {
            updateAutoHideCSS();
            checkConditions();
        }), { passive: true, signal: sig });

        window.addEventListener('click', () => {
            setTimeout(updateAutoHideCSS, 100);
            requestAnimationFrame(showContent);
        }, { passive: true, signal: sig });

        window.addEventListener('wheel', () => requestAnimationFrame(showContent), { passive: true, signal: sig });

        // theater 속성 변화 감시 (현 구조 유지)
        const watchFlexyInit = () => {
            const watchFlexy = document.querySelector('ytd-watch-flexy');
            if (!watchFlexy) return;
            observerForTheater?.disconnect();
            observerForTheater = new MutationObserver(() => requestAnimationFrame(checkConditions));
            observerForTheater.observe(watchFlexy, { attributes: true, attributeFilter: ['theater'] });
        };
        new MutationObserver(watchFlexyInit).observe(document.documentElement, { childList: true, subtree: true });
        watchFlexyInit();
    };

    // ---------- URL 감시 (SPA 지원) ----------
    const URL_EVENT = 'tm-url-change';
    const fireUrlEvent = () => window.dispatchEvent(new Event(URL_EVENT));

    // history API 패치
    const _pushState = history.pushState;
    const _replaceState = history.replaceState;
    history.pushState = function(...args) { const r = _pushState.apply(this, args); fireUrlEvent(); return r; };
    history.replaceState = function(...args) { const r = _replaceState.apply(this, args); fireUrlEvent(); return r; };

    window.addEventListener('popstate', fireUrlEvent);
    window.addEventListener('hashchange', fireUrlEvent);
    // YouTube 자체 이벤트도 훅
    window.addEventListener('yt-navigate-finish', fireUrlEvent);
    window.addEventListener('yt-navigate-start', fireUrlEvent);

    let lastPath = location.pathname + location.search;

    const onRoute = () => {
        const now = location.pathname + location.search;
        if (now === lastPath) return;
        lastPath = now;

        if (isWatchPage()) {
            attachCSS();
            setupEventListeners();
            resetScrollTopHard();
            requestAnimationFrame(() => {
                updateAutoHideCSS();
                checkConditions();
            });
        } else {
            detachCSS();
        }
    };

    // 초기 1회 실행
    const boot = () => {
        if (isWatchPage()) {
            attachCSS();
            setupEventListeners();
            resetScrollTopHard();
            requestAnimationFrame(() => {
                updateAutoHideCSS();
                checkConditions();
            });
        } else {
            detachCSS();
        }
    };

    // URL 변화 감지 핸들러 등록
    window.addEventListener(URL_EVENT, onRoute);

    // 첫 로드
    boot();
})();