CrunchyUtils

Adds a none option to the subtitle picker, more playback speeds, faster UI autohide, custom fast-forward and rewind values, and frame seeking!

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         CrunchyUtils
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  Adds a none option to the subtitle picker, more playback speeds, faster UI autohide, custom fast-forward and rewind values, and frame seeking!
// @author       DimitrovN
// @match        https://www.crunchyroll.com/*
// @run-at       document-start
// @noframes
// @grant        none
// @license      GPL-3.0-or-later
// @icon         https://www.crunchyroll.com/build/assets/img/favicons/favicon-v2-32x32.png
// ==/UserScript==

/* eslint-disable */

(function () {
  'use strict';

  // ============================================================
  //  FEATURE FLAGS  -  set to false to disable a feature
  // ============================================================
  const FEATURE_SUBTITLE_NONE    = true;   // Adds "None" option + deselect in subtitle menu
  const FEATURE_PLAYBACK_SPEED   = true;   // Adds extra playback speeds (3x, 2.5x, 2x, 1.75x, 1.5x, 1.25x)
  const FEATURE_AUTOHIDE         = true;   // Reduces UI autohide delay
  const FEATURE_SEEK_BUTTONS     = true;   // Changes skip buttons to SEEK_SECONDS
  const FEATURE_ARROW_KEY_SEEK   = true;   // Arrow keys seek by SEEK_SECONDS
  const FEATURE_FRAME_STEP       = true;   // , and . for frame-by-frame step

  // ============================================================
  //  SETTINGS
  // ============================================================
  const HIDE_DELAY_MS  = 500;                            // Autohide delay in milliseconds (default CR: 6000)
  const SEEK_SECONDS   = 5;                              // Seconds to fast-forward and rewind (seek) with buttons/arrow keys
  const FRAME_SECONDS  = 1 / 23.976;                     // Seconds per frame (adjust for 24/25/30 fps content)
  const EXTRA_SPEEDS   = [3, 2.5, 2, 1.75, 1.5, 1.25];   // Speeds to inject (highest first)

  // ============================================================
  //  SHARED UTILITIES
  // ============================================================
  const CHECKMARK_SVG = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="color:white;">
    <path fill-rule="evenodd" clip-rule="evenodd" d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12ZM15.7929 8.29289L17.2071 9.70711L10.5 16.4142L6.79289 12.7071L8.20711 11.2929L10.5 13.5858L15.7929 8.29289Z" fill="currentColor"></path>
  </svg>`;

  function getVideo() {
    return document.querySelector('video');
  }

  function getBitmovinPlayer() {
    const video = document.querySelector('video[id^="bitmovinplayer-video"]');
    return video?.parentElement?.player ?? null;
  }

  // ============================================================
  //  FEATURE: AUTOHIDE
  // ============================================================
  if (FEATURE_AUTOHIDE) {
    const _st = window.setTimeout;
    window.setTimeout = function (fn, delay, ...args) {
      if (delay === 6000 && fn?.toString?.() === '()=>{s(!1)}') {
        delay = HIDE_DELAY_MS;
      }
      return _st.call(window, fn, delay, ...args);
    };
  }

  // ============================================================
  //  FEATURE: SEEK BUTTONS + ARROW KEYS + FRAME STEP
  // ============================================================
  if (FEATURE_SEEK_BUTTONS || FEATURE_ARROW_KEY_SEEK || FEATURE_FRAME_STEP) {

    function seek(seconds) {
      const player = getBitmovinPlayer();
      if (!player) return;
      player.seek(player.getCurrentTime() + seconds);
    }

    if (FEATURE_SEEK_BUTTONS) {
      function attachSeekButtons() {
        const backward = document.querySelector('[data-testid="jump-backward-button"]');
        const forward  = document.querySelector('[data-testid="jump-forward-button"]');
        if (backward) {
          backward.addEventListener('click', e => {
            e.stopImmediatePropagation();
            seek(-SEEK_SECONDS);
          }, true);
        }
        if (forward) {
          forward.addEventListener('click', e => {
            e.stopImmediatePropagation();
            seek(SEEK_SECONDS);
          }, true);
        }
      }

      const seekBtnObserver = new MutationObserver(() => {
        const backward = document.querySelector('[data-testid="jump-backward-button"]');
        if (backward) {
          seekBtnObserver.disconnect();
          attachSeekButtons();
        }
      });
      document.addEventListener('DOMContentLoaded', () => {
        seekBtnObserver.observe(document.body, { childList: true, subtree: true });
      });
    }

    if (FEATURE_ARROW_KEY_SEEK) {
      document.addEventListener('keydown', e => {
        if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
          if (!getBitmovinPlayer()) return;
          e.stopImmediatePropagation();
          seek(e.key === 'ArrowLeft' ? -SEEK_SECONDS : SEEK_SECONDS);
        }
      }, true);
    }

    if (FEATURE_FRAME_STEP) {
      document.addEventListener('keydown', e => {
        if (e.key === ',' || e.key === '.') {
          const player = getBitmovinPlayer();
          if (!player) return;
          e.preventDefault();
          e.stopImmediatePropagation();
          if (!player.isPaused()) player.pause();
          seek(e.key === ',' ? -FRAME_SECONDS : FRAME_SECONDS);
        }
      }, true);
    }
  }

  // ============================================================
  //  FEATURE: SUBTITLE NONE / DESELECT  +  PLAYBACK SPEED
  //  (deferred until DOM is ready, since these need document.body)
  // ============================================================
  function initDOMFeatures() {

  if (FEATURE_SUBTITLE_NONE) {

    let bearerToken  = null;
    let noneIsActive = false;

    // - Subtitle preference persistence setup -
    const origFetch = window.fetch;
    window.fetch = async function (...args) {
      try {
        const opts    = args[1] || {};
        const headers = opts.headers || {};
        const auth    = headers instanceof Headers
          ? headers.get('authorization')
          : (headers['authorization'] || headers['Authorization'] || '');
        if (auth && auth.startsWith('Bearer ')) bearerToken = auth.slice(7);
      } catch (e) {}
      return origFetch.apply(this, args);
    };

    const origSetHeader = XMLHttpRequest.prototype.setRequestHeader;
    XMLHttpRequest.prototype.setRequestHeader = function (header, value) {
      if (header.toLowerCase() === 'authorization' && value.startsWith('Bearer ')) {
        bearerToken = value.slice(7);
      }
      return origSetHeader.apply(this, arguments);
    };

    function getProfileId() {
      const match = document.cookie.match(/ajs_user_id=([a-f0-9-]+)/);
      return match ? match[1] : null;
    }

    function getTracksOrchestrator() {
      const el  = document.querySelector('.player-container');
      if (!el) return null;
      const key = Object.keys(el).find(k => k.startsWith('__reactFiber'));
      if (!key) return null;
      const comp = el[key].return?.stateNode;
      return comp?.player?._katamariPlayer?.playerOrchestrator?.tracksOrchestrator || null;
    }

    function closeSubtitleMenu() {
      document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
      document.body.click();
    }

    function switchToCleanStream() {
      const tracks = getTracksOrchestrator();
      if (!tracks) return false;
      const available = tracks._availableTextTracks;
      if (!available?.length) return false;
      const existingUrl = available[0].videoUrl?.href || available[0].videoUrl;
      if (!existingUrl) return false;
      const cleanUrl = existingUrl.replace(/\/\d+\/[^/]+\/(dash\/manifest\.mpd)/, '/1/clean/$1');
      let cleanTrack = available.find(t => t.language === 'off');
      if (!cleanTrack) {
        cleanTrack = { role: 2, format: 2, language: 'off', displayName: 'None', videoUrl: new URL(cleanUrl) };
        available.push(cleanTrack);
      }
      closeSubtitleMenu();
      setTimeout(() => {
        tracks.setTextTrack(cleanTrack);
        console.log('[CR-None] Switched to clean stream');
      }, 100);
      return true;
    }

    function switchToSubtitleStream(locale) {
      const tracks = getTracksOrchestrator();
      if (!tracks) return false;
      const track = tracks._availableTextTracks.find(t => t.language === locale);
      if (!track) return false;
      closeSubtitleMenu();
      setTimeout(() => {
        tracks.setTextTrack(track);
        console.log('[CR-None] Switched to:', locale);
      }, 100);
      return true;
    }

    async function persistSubtitlePreference(lang) {
      const pid = getProfileId();
      if (!pid || !bearerToken) return;
      try {
        await origFetch(`https://www.crunchyroll.com/accounts/v2/me/multiprofile/${pid}`, {
          method: 'PATCH',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${bearerToken}`,
          },
          body: JSON.stringify({ preferred_content_subtitle_language: lang }),
        });
        console.log('[CR-None] Persisted:', lang);
      } catch (e) {}
    }

    const langMap = {
      'English': 'en-US', 'Deutsch': 'de-DE',
      'Español (América Latina)': 'es-419', 'Español (España)': 'es-ES',
      'Français': 'fr-FR', 'Italiano': 'it-IT',
      'Português (Brasil)': 'pt-BR', 'Русский': 'ru-RU', 'العربية': 'ar-SA',
    };

    function clearSubtitleItems(container) {
      container.querySelectorAll('[role="menuitemradio"]').forEach(item => {
        item.setAttribute('aria-checked', 'false');
        item.classList.remove('kat:bg-white/6');
        const icon = item.querySelector('div:last-child');
        if (icon) icon.innerHTML = '';
      });
    }

    function injectNoneOption(container) {
      if (container.querySelector('[data-none-option]')) return;

      // Find the scrollable container with the menu items
      const scrollableContainer = container.querySelector('.kat\\:overflow-y-auto') || container;
      
      // Remove any Crunchyroll-created "None" button (without our custom marker)
      const noneButtons = Array.from(scrollableContainer.querySelectorAll('[role="menuitemradio"]')).filter(el => 
        el.getAttribute('aria-label') === 'None' && !el.hasAttribute('data-none-option')
      );
      noneButtons.forEach(btn => btn.remove());
      
      const firstItem = scrollableContainer.querySelector('[role="menuitemradio"]');
      if (!firstItem) return;

      const noneItem = document.createElement('div');
      noneItem.setAttribute('data-none-option', 'true');
      noneItem.setAttribute('role', 'menuitemradio');
      noneItem.setAttribute('aria-label', 'None');
      noneItem.setAttribute('aria-checked', 'false');
      noneItem.className = 'kat:flex kat:items-center kat:gap-4 kat:cursor-pointer kat:transition-colors kat:ps-20 kat:pe-20 kat:pt-13 kat:pb-13 kat:hover:bg-neutral-600 kat:focus-visible:outline-4 kat:focus-visible:-outline-offset-4 kat:focus-visible:outline-orange-500 kat:focus-visible:bg-neutral-600 kat:active:bg-neutral-500';
      noneItem.innerHTML = `
        <div class="kat:flex kat:flex-col kat:flex-1 kat:min-w-0 kat:gap-2 kat:text-start">
          <span class="kat:text-sm kat:text-neutral-50">None (Custom)</span>
        </div>
        <div class="kat:w-24 kat:h-24 kat:shrink-0 cr-none-check"></div>
      `;

      try {
        scrollableContainer.insertBefore(noneItem, firstItem);
      } catch (e) {
        scrollableContainer.appendChild(noneItem);
      }

      function setNoneActive(active) {
        noneIsActive = active;
        noneItem.setAttribute('aria-checked', active ? 'true' : 'false');
        noneItem.classList.toggle('kat:bg-white/6', active);
        noneItem.querySelector('.cr-none-check').innerHTML = active ? CHECKMARK_SVG : '';
      }

      if (noneIsActive) {
        setNoneActive(true);
        clearSubtitleItems(scrollableContainer);
        noneItem.setAttribute('aria-checked', 'true');
        noneItem.classList.add('kat:bg-white/6');
        noneItem.querySelector('.cr-none-check').innerHTML = CHECKMARK_SVG;
      }

      noneItem.addEventListener('click', () => {
        clearSubtitleItems(scrollableContainer);
        setNoneActive(true);
        switchToCleanStream();
        persistSubtitlePreference('off');
      });

      scrollableContainer.addEventListener('click', (e) => {
        const clicked = e.target.closest('[role="menuitemradio"]');
        if (!clicked || clicked === noneItem) return;
        const wasChecked = clicked.getAttribute('aria-checked') === 'true';

        clearSubtitleItems(scrollableContainer);
        setNoneActive(false);

        if (!wasChecked) {
          clicked.setAttribute('aria-checked', 'true');
          clicked.classList.add('kat:bg-white/6');
          const icon = clicked.querySelector('div:last-child');
          if (icon) icon.innerHTML = CHECKMARK_SVG;
          const locale = langMap[clicked.getAttribute('aria-label')] || clicked.getAttribute('aria-label');
          switchToSubtitleStream(locale);
          persistSubtitlePreference(locale);
        } else {
          setNoneActive(true);
          switchToCleanStream();
          persistSubtitlePreference('off');
        }
      }, { capture: true });
    }

    let subtitleDebounce = null;
    const subtitleObserver = new MutationObserver(() => {
      clearTimeout(subtitleDebounce);
      subtitleDebounce = setTimeout(() => {
        // Find the subtitle menu by looking for the "Subtitles/CC" header
        const allMenus = document.querySelectorAll('[role="menu"]');
        
        allMenus.forEach((menu) => {
          // Look for a separator with "Subtitles/CC" text inside this menu's parent
          const parent = menu.parentElement;
          const header = parent?.querySelector('[role="separator"]');
          
          if (header && header.textContent.includes('Subtitles/CC')) {
            injectNoneOption(menu);
          }
        });
      }, 50);
    });

    subtitleObserver.observe(document.body, { childList: true, subtree: true });
  }

  // ============================================================
  //  FEATURE: PLAYBACK SPEED
  // ============================================================
  if (FEATURE_PLAYBACK_SPEED) {

    const SPEED_ITEM_CLASSES = [
      'kat:flex', 'kat:items-center', 'kat:gap-4', 'kat:cursor-pointer',
      'kat:transition-colors', 'kat:ps-20', 'kat:pe-20', 'kat:pt-13', 'kat:pb-13',
      'kat:hover:bg-neutral-600', 'kat:focus-visible:outline-4',
      'kat:focus-visible:-outline-offset-4', 'kat:focus-visible:outline-orange-500',
      'kat:focus-visible:bg-neutral-600', 'kat:active:bg-neutral-500',
    ].join(' ');

    let savedSpeed = 1;

    function updateSpeedButton(speed) {
      const btn = document.querySelector('[data-testid="playback-speed-button"]');
      if (btn) btn.textContent = `${speed}x`;
    }

    function updateCheckedState(menu, activeSpeed) {
      if (!menu) return;
      menu.querySelectorAll('[role="menuitemradio"]').forEach(el => {
        const speed    = parseFloat(el.getAttribute('aria-label'));
        const isActive = speed === activeSpeed;
        el.setAttribute('aria-checked', isActive ? 'true' : 'false');
        el.classList.toggle('kat:bg-white/6', isActive);
        const checkDiv = el.querySelector('div:last-child');
        if (checkDiv) checkDiv.innerHTML = isActive ? CHECKMARK_SVG : '';
      });
    }

    function createSpeedItem(speed) {
      const item = document.createElement('div');
      item.role = 'menuitemradio';
      item.setAttribute('aria-label', `${speed}x`);
      item.setAttribute('aria-checked', 'false');
      item.setAttribute('aria-disabled', 'false');
      item.setAttribute('tabindex', '0');
      item.className = SPEED_ITEM_CLASSES;
      item.dataset.crSpeed = speed;
      item.innerHTML = `
        <div class="kat:flex kat:flex-col kat:flex-1 kat:min-w-0 kat:gap-2 kat:text-start">
          <span class="kat:text-sm kat:text-neutral-50">${speed}x</span>
        </div>
        <div class="kat:w-24 kat:h-24 kat:shrink-0 cr-speed-check"></div>
      `;
      item.addEventListener('click', () => {
        savedSpeed = speed;
        const video = getVideo();
        if (video) video.playbackRate = speed;
        updateCheckedState(item.closest('[role="menu"]'), speed);
        updateSpeedButton(speed);
      });
      item.addEventListener('keydown', (e) => {
        if (e.key === 'Enter' || e.key === ' ') {
          e.preventDefault();
          item.click();
        }
      });
      return item;
    }

    function attachVideoListeners(video) {
      if (video.dataset.crSpeedListening) return;
      video.dataset.crSpeedListening = 'true';

      video.addEventListener('play', () => {
        if (video.playbackRate !== savedSpeed) video.playbackRate = savedSpeed;
      });

      video.addEventListener('ratechange', () => {
        const menu = document.querySelector('[data-testid="playback-speed-menu"] [role="menu"]');
        updateCheckedState(menu, video.playbackRate);
        updateSpeedButton(video.playbackRate);
        if (video.playbackRate !== savedSpeed && !video.dataset.crSpeedRestoring) {
          video.dataset.crSpeedRestoring = 'true';
          video.playbackRate = savedSpeed;
          delete video.dataset.crSpeedRestoring;
        }
      });
    }

    function injectSpeeds(menu) {
      if (menu.dataset.crSpeedInjected) return;
      menu.dataset.crSpeedInjected = 'true';

      const existingItems    = menu.querySelectorAll('[role="menuitemradio"]');
      const lastExistingItem = existingItems[existingItems.length - 1];

      existingItems.forEach(el => {
        el.addEventListener('click', () => {
          savedSpeed = parseFloat(el.getAttribute('aria-label'));
          updateSpeedButton(savedSpeed);
        });
      });

      EXTRA_SPEEDS.forEach(speed => {
        const item = createSpeedItem(speed);
        if (lastExistingItem) lastExistingItem.after(item);
        else menu.appendChild(item);
      });

      const video = getVideo();
      if (video) {
        attachVideoListeners(video);
        updateCheckedState(menu, video.playbackRate);
        updateSpeedButton(video.playbackRate);
      }
    }

    const speedObserver = new MutationObserver(() => {
      const speedMenu = document.querySelector('[data-testid="playback-speed-menu"] [role="menu"]');
      if (speedMenu && !speedMenu.dataset.crSpeedInjected) injectSpeeds(speedMenu);
    });

    speedObserver.observe(document.body, { childList: true, subtree: true });
  }

  } // end initDOMFeatures

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initDOMFeatures);
  } else {
    initDOMFeatures();
  }

})();