Adds a none option to the subtitle picker, more playback speeds, faster UI autohide, custom fast-forward and rewind values, and frame seeking!
// ==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();
}
})();