🏠 

Crunchyroll Watchlist Userscript

UI tweaks for CrunchyRoll Beta. New watchlist order and an autoplay tweak.


Install this script?
// ==UserScript==
// @name Crunchyroll Watchlist Userscript
// @namespace Itsnotlupus Scripts
// @description UI tweaks for CrunchyRoll Beta. New watchlist order and an autoplay tweak.
// @description:de UI-Optimierungen für Crunchyroll Beta. Neue Watchlist-Reihenfolge und Autoplay-Optimierung.
// @description:fr Tweaks pour Crunchyroll Beta. Nouvel ordre pour la watchlist et un ajustement de l'autoplay.
// @match https://beta.crunchyroll.com/*
// @version 0.9
// @require https://unpkg.com/[email protected]/dist/moduleraid.umd.js
// @require https://unpkg.com/[email protected]/index.js
// @run-at document-start
// @license MIT
// ==/UserScript==
/**
* Techniques used to monkeypatch Crunchyroll:
*
* - Webpack modules contain a lot of useful functionality. moduleraid is essential in reaching those.
*   The builtin find*() methods are constrained by minimization, and you may need to inspect its .modules
*   array to find what you need. see sortInit() for an example.
* - The site uses Axios to perform network requests. Some of its modules expose utilities to define
*   interceptors on its axios instances, which allows to modify and decorate data fetched by the site.
* - This is a React app, so modifying the rendered markup on the site is tricky. There are 2 approaches:
*   1. Observe for DOM changes or React renders and re-apply your modifications each time.
*      This is how the watchlist card text changes and dimming are implemented.
*   2. Find the source data used by the React components to render the app, and change that data.
*      This is how custom watchlist sorting is implemented.
* - Useful data can found on React component props, including access to the Redux store. This is done
*   by using the React dev hooks and traversing the React Fiber tree to find matches. It is also possible
*   to change a React component behavior by changing its props, but as with markup, this will not stick
*   unless it is reapplied on every render. See tweakPlayer() for an example.
*
* The module devtools-detect is used to know when to make a few useful object available to the console,
*   which makes poking around and understanding what's there somewhat easier.
*/
/*jshint ignore:start */
const DAY = 24*3600*1000;
const WEEK = 7 * DAY;
const NOT_WATCHED = 0;
const WATCHED_WAIT = 1;
const WATCHED_DONE = 2;
// Localization
const i18n = {
// reference strings
'en': {
'Crunchyroll Watchlist Userscript: {MSG}': 'Crunchyroll Watchlist Userscript: {MSG}',
'Starting.': 'Starting.',
'DevTools open': 'DevTools open',
'Autoplay blocked at end of season.': 'Autoplay blocked at end of season.',
'Natural Sort': 'Natural Order',
'Airs on {DATE}': 'Airs on {DATE}',
'No Recent Episode': 'No Recent Episode',
'S{SEASON}E{EPISODE} - {TITLE}': 'S{SEASON}E{EPISODE} - {TITLE}',
'Untitled': 'Untitled',
'Better Cards': 'Enhanced Cards',
'Show/Hide Comments': 'Show/Hide Comments'
},
// hasty translations
'de': {
'Crunchyroll Watchlist Userscript: {MSG}': 'Crunchyroll Watchlist Userscript: {MSG}',
'Starting.': 'Anfang.',
'DevTools open': 'DevTools geöffnet',
'Autoplay blocked at end of season.': 'Autoplay am Saisonende gesperrt.',
'Natural Sort': 'Natürliche Reihenfolge',
'Airs on {DATE}': 'Am {DATE} ausgestrahlt',
'No Recent Episode': 'Keine aktuelle Folge',
'S{SEASON}E{EPISODE} - {TITLE}': 'S{SEASON}E{EPISODE} - {TITLE}',
'Untitled': 'Ohne Titel',
'Better Cards': 'Erweiterte Karten',
'Show/Hide Comments': 'Kommentare ein-/ausblenden'
},
'fr': {
'Crunchyroll Watchlist Userscript: {MSG}': 'Crunchyroll Watchlist Userscript: {MSG}',
'Starting.': 'Démarrage.',
'DevTools open': 'DevTools ouverts',
'Autoplay blocked at end of season.': 'Autoplay bloqué en fin de saison.',
'Natural Sort': 'Ordre Naturel',
'Airs on {DATE}': 'Diffusion le {DATE}',
'No Recent Episode': 'Pas d\'épisode récent',
'S{SEASON}E{EPISODE} - {TITLE}': 'S{SEASON}E{EPISODE} - {TITLE}',
'Untitled': 'Sans titre',
'Better Cards': 'Cartes Améliorées',
'Show/Hide Comments': 'Afficher/Masquer les commentaires'
}
// ...
}
const getLocale = (_ = location.pathname.split('/')[1]) => _ in i18n ? _ : navigator.language;
const loc = (s, locale=getLocale(), lang=locale.split('-')[0]) => i18n[locale]?.[s] ?? i18n[lang]?.[s] ?? i18n.en[s] ?? s;
const t = (s,o) => o?loc(s).replace(/\{([^{]*)\}/g,(a,b)=>o[b]??loc(a)):loc(s);
const dateFormatter = Intl.DateTimeFormat(getLocale(), { weekday: "long", hour: "numeric", minute: "numeric"});
// other utilities
const crel = (name, attrs, ...children) => ((e = Object.assign(document.createElement(name), attrs)) => (e.append(...children), e))();
const svg = (name, attrs, ...children) =>  {
const e = document.createElementNS('http://www.w3.org/2000/svg', name);
Object.entries(attrs).forEach(([key,val]) => e.setAttribute(key, val));
e.append(...children);
return e;
}
const log = (msg, ...args) => console.log(`%c${t('Crunchyroll Watchlist Userscript: {MSG}', { MSG: t(msg) })}`, 'font-weight:600;color:green', ...args);
/** calls a function whenever react renders */
const observeReact = (fn) => {
reactObservers.add(fn);
return () => reactObservers.delete(fn);
};
/** calls a function whenever the DOM changes */
const observeDOM = (fn, e = document.documentElement, config = { attributes: 1, childList: 1, subtree: 1 }) => {
const observer = new MutationObserver(fn);
observer.observe(e, config);
return () => observer.disconnect();
};
/** check a condition on every DOM change until true */
const untilDOM = f => new Promise((r,_,d = observeDOM(() => f() && d() | r() )) => 0);
function debounce(fn) {
let latestArgs, scheduled = false;
return (...args) => {
latestArgs = args;
if (!scheduled) {
scheduled = true;
Promise.resolve().then(() => {
scheduled = false;
fn(...latestArgs);
});
}
};
}
// React monkey-patching. the stuff belows allows us to:
// - observe every react renders
// - inspect nodes for props (which exposes redux and other useful state)
// - modify props value (which may not stick unless reapplied on every render)
const reactObservers = new Set;
const notifyReactObservers = debounce(() => reactObservers.forEach(fn=>fn()));
let reactRoot; // We assume we'll only ever see one react root. Seems to hold here.
const h = '__REACT_DEVTOOLS_GLOBAL_HOOK__';
if (window[h]) {
const ocfr = window[h].onCommitFiberRoot.bind(window[h]);
window[h].onCommitFiberRoot = (_, root) => {
notifyReactObservers();
reactRoot = root;
return ocfr(_, root);
};
} else {
const listeners={};
window[h] = {
onCommitFiberRoot: (_, root) => {
notifyReactObservers();
reactRoot = root
},
onCommitFiberUnmount: ()=>0,
inject: ()=>0,
checkDCE: ()=>0,
supportsFiber: true,
on: ()=>0,
sub: ()=>0,
renderers: [],
emit: ()=>0
};
}
/** Traversal of React's tree to find nodes that match a props name */
function findNodesWithProp(name, firstOnly = false) {
const acc = new Set;
const visited = new Set;
const getPropFromNode = node => {
if (!node || visited.has(node)) return;
visited.add(node);
const props = node.memoizedProps;
if (props && typeof props === 'object' && name in props) {
acc.add(node);
if (firstOnly) throw 0; // goto end
}
getPropFromNode(node.sibling);
getPropFromNode(node.child);
}
try { getPropFromNode(reactRoot?.current) } catch {}
return Array.from(acc);
}
/** Magically obtain a prop value from the most top-level React component we can find */
function getProp(name) {
return findNodesWithProp(name, true)[0]?.memoizedProps?.[name];
}
/** Forcefully mutate props on a component node in the react tree. */
function updateNodeProps(node, props) {
Object.assign(node.memoizedProps, props);
Object.assign(node.pendingProps, props);
Object.assign(node.stateNode?.props??{}, props);
}
// Actual script logic starts here
function sortInit(mR) {
// add sort elements we need. this needs to run before first render.
// find sort data module and its members by shape, since it's all minimized
const sortData = Object.values(mR.modules).find(m=>Object.values(m).includes("watchlist.sort"));
const sortTypes = Object.entries(sortData).find(pair=>pair[1].alphabetical?.trigger)[0];
const sortItems = Object.entries(sortData).find(pair=>pair[1][0]?.trigger)[0];
const sortFilters = Object.entries(sortData).find(pair=>pair[1].alphabetical?.desc)[0];
if ("natural" in sortData[sortTypes]) return;
sortData[sortTypes].natural = { name: t("Natural Sort"), value: "natural", trigger: t("Natural Sort")}
sortData[sortItems].unshift(sortData[sortTypes].natural);
sortData[sortFilters]["natural"] = {}; // we don't want sort filters available for natural sort XXX this isn't enough.
return true;
}
function axiosInit(Content, store) {
Content.addRequestInterceptor(function (config) {
if (config?.params?.sort_by === 'natural') {
config.params.sort_by = '';
config.__this_is_a_natural_sort = true;
}
return config;
});
Content.addResponseInterceptor(function (response) {
if (response.config.url.endsWith('/watchlist')) {
// save the watchlist items so we don't need to double fetch it.
store.watchlistItems = response.data.items;
// decorate watchlist items with 'watched' and 'lastAirDate' (for sorting and render)
store.watchlistItems.forEach(item => {
const { completion_status, panel: { episode_metadata: ep }} = item;
const lastAirDate = new Date(ep.episode_air_date);
item.lastAirDate = lastAirDate.getTime();
if (completion_status) {
// Cut off at 2 weeks after the original air date since VRV doesn't provide a date of next availability.
// This works well enough for weekly shows, accounting for the occasional skipped week.
item.watched = Date.now() - lastAirDate < 2 * WEEK ? WATCHED_WAIT : WATCHED_DONE;
} else {
item.watched = NOT_WATCHED;
}
});
if (response.config.__this_is_a_natural_sort) {
// the "Natural Sort" sorts items that haven't been watched above items that have.
// it also sorts watched items likely to have new episode above items that aren't likely to have any.
// it sorts items available to watch with more recent release first,
// and items likely to have new episodes with closest next release first.
// (If we had a sense of which shows are most eagerly watched, we could use that and have a plausible "Scientific Sort"..)
store.watchlistItems.sort((a,b) => {
// 1. sortByWatched
const sortByWatched = a.watched - b.watched;
if (sortByWatched) return sortByWatched;
// 2. sortByAirDate
const sortByAirDate = b.lastAirDate - a.lastAirDate;
return a.watched === 1 ? -sortByAirDate : sortByAirDate;
});
}
}
return response;
});
}
/** As long as Crunchyroll has nonsensical seasons, it's better
*  to prevent autoplay across seasons
*/
function tweakPlayer() {
const [_,page] = location.pathname.split('/');
if (page !== 'watch') return;
// find player React component
const node = findNodesWithProp('upNextLink', true)[0];
const props = node?.memoizedProps;
if (props && !props.injected) {
// wrap the changeLocation props and check if it's being asked to
// navigate to the "upnext" address.
const { videoEnded, changeLocation, upNextLink } = props;
let videoJustEnded = false;
updateNodeProps(node, {
videoEnded() {
// track this to only block autoplay but still allow user to use the "next video" button in the player.
videoJustEnded = true;
return videoEnded;
},
changeLocation(go) {
if (videoJustEnded && go.to === upNextLink) {
videoJustEnded = false;
// check if the next episode would be an "episode 1", indicative of another season
const { content, watch } = getProp('store').getState(); // grab some state from redux
const upNextId = content.upNext.byId[watch.id].contentId;
const { episodeNumber } = content.media.byId[upNextId];
if (episodeNumber === 1) {
log('Autoplay blocked at end of season.');
return;
}
}
return changeLocation(go);
},
injected: true
});
}
}
/**
* Dim items that have been watched, gray out shows with no recent episodes.
* Show title of next episode to play, or expected air date for one to show up.
*/
function decorateWatchlist(store) {
const { watchlistItems, classNames } = store;
if (!classNames.watchlistCard) {
// get a fresh mR, because not all CSS classnames are available at startup
const mR = new moduleraid({ entrypoint: "__LOADABLE_LOADED_CHUNKS__", debug: false });
const canari = Object.values(mR.modules).filter(o=>o?.Z?.watchlistCard)[0]?.Z;
if (!canari) return; // too soon. retry on next mutation.
// laboriously extract various classnames from whatever is exported
// This mostly involves calling spurious react component renders and grabbing data from the trees generated.
// This became necessary because classnames are now assigned random ids at build time.
// checkbox
const X = Object.values(mR.modules).filter(o=>o?.Z?.CheckboxOption)[0].Z;
const { className: dropdownCheckboxOption, labelClassName: dropdownCheckboxOptionLabel } = X.CheckboxOption().props.children({onOptionClick:0, onBlur:0}).props;
const C = Object.values(mR.modules).filter(o=>o?.Z?.defaultProps?.labelClassName==="")[0].Z.prototype.render.call({props:{ isChecked: true }}).props;
const checkbox = C.className;
const CL = C.children.props;
const [checkboxLabel, checkboxLabelIsChecked] = CL.className.split(' ');
const I = CL.children[0].props;
const checkboxInput = I.className;
const M = CL.children[1].props;
const checkboxCheckmark = M.className;
const S = M.children.props;
const checkboxSvg = S.className;
const P = S.children.props;
const checkboxPath = P.className;
const T = CL.children[2].props;
const checkboxText = Object.values(mR.modules).filter(o=>o?.Z?.displayName === 'Text')[0].Z.render(T).props.className;
// card elements
const { watchlistCard, watchlistCard__contentLink } = canari;
const watchListCardSubtitle = Object.values(mR.modules).filter(o=>o?.Z?.Subtitle)[0].Z.Subtitle({className: ''}).props.className;
Object.assign(classNames, {
dropdownCheckboxOption,
dropdownCheckboxOptionLabel,
checkbox,
checkboxLabel,
checkboxLabelIsChecked,
checkboxInput,
checkboxCheckmark,
checkboxSvg,
checkboxPath,
checkboxText,
watchlistCard,
watchlistCard__contentLink,
watchListCardSubtitle
})
}
const c = classNames;
let useBetterCards = localStorage.BETTER_CARDS !== "false"; // be optimistic
const controls = document.querySelector(".erc-watchlist-controls");
if (controls && !document.querySelector(".better-cards")) {
const checkbox = crel('li', { className: "controls-item" },
crel('div', { className: `${c.checkbox} ${c.dropdownCheckboxOption}` },
crel('label', { className: `${c.checkboxLabel} ${c.dropdownCheckboxOptionLabel} better-cards`+(useBetterCards ? ` ${c.checkboxLabelIsChecked}`: ""), tabIndex: "0" },
crel('input', { className: c.checkboxInput, type: "checkbox", value: "better_cards" }),
crel('span', { className: c.checkboxCheckmark },
svg('svg', { class: c.checkboxSvg, viewBox: "2 2 16 16" },
svg('path', { class: c.checkboxPath, d: "M6,10 C7.93333333,12 8.93333333,13 9,13 C9.06666667,13 10.7333333,11 14,7", "stroke-width": "2"}))),
crel('span', { className: c.checkboxText }, t("Better Cards")))));
const label = checkbox.querySelector(".better-cards");
label.addEventListener("click", (e) => {
if (e.target !== label.querySelector('input')) return;
label.classList.toggle(c.checkboxLabelIsChecked, localStorage.BETTER_CARDS = useBetterCards = !useBetterCards);
// remove all 'decorated' classes. triggers a mutation so this function runs again, and the loop below adjusts each card's appearance
document.querySelectorAll(`.erc-my-lists-collection .${c.watchlistCard}.decorated`).forEach(e=>e.classList.remove('decorated'));
});
controls.insertBefore(checkbox, controls.firstChild);
}
for (const card of document.querySelectorAll(`.erc-my-lists-collection .${c.watchlistCard}:not(.decorated)`)) {
const metadata = card.querySelector(`.${c.watchListCardSubtitle}`);
if (useBetterCards) {
const [item_id] = card.querySelector(`.${c.watchlistCard__contentLink}`).getAttribute('href').split('/').slice(-2); // XXX .at(-2)
const item = watchlistItems.find(item => item.panel.id === item_id);
if (!item) return;
const { watched, panel : { episode_metadata: ep, title }} = item;
metadata.dataset.originalHTML = metadata.innerHTML;
metadata.innerHTML = `<span style="font-size: 0.875rem; margin-top: 1.5rem"></span>`;
const label = metadata.firstChild;
switch (watched) {
default:
case NOT_WATCHED:
// use title & number to decorate watchlist item. iffy CSS.
label.textContent = t('S{SEASON}E{EPISODE} - {TITLE}', {SEASON:ep.season_number || 1, EPISODE:ep.episode_number || 1, TITLE: title || t('Untitled')});
metadata.style = 'height: 2.7em; overflow: hidden; text-overflow: ellipsis; -webkit-line-clamp: 2; display: -webkit-box; -webkit-box-orient: vertical; white-space: normal';
break;
case WATCHED_WAIT:
// half-dullify and show original air date.
label.textContent = t('Airs on {DATE}', { DATE: dateFormatter.format(new Date(ep.episode_air_date)) });
card.style = 'filter: grayscale(90%);opacity:.9';
break;
case WATCHED_DONE:
// old shows, fully watched.
// dullify thoroughly.
label.textContent = t('No Recent Episode');
card.style = 'filter: grayscale(90%);opacity:0.5';
break;
}
} else {
// restore original markup if we can.
if (metadata.dataset.originalHTML) metadata.innerHTML=metadata.dataset.originalHTML;
metadata.style='';
card.style='';
}
card.classList.add('decorated');
}
}
function hideComments() {
const comments = document.querySelector(".commenting-wrapper");
if (!comments) return;
const comments_toggle = comments.querySelector('.comments-toggle');
if (comments_toggle) return;
const button = crel('div', { role: "button", className: "comments-toggle c-button c-button--type-two-weak", tabindex: "0"},
crel('span', { className: "c-call-to-action c-call-to-action--m c-button__cta", style: "cursor:pointer" }, t("Show/Hide Comments")));
button.addEventListener('click', () => document.body.classList.toggle('show-comments'));
comments.insertBefore(button, comments.firstChild);
}
function hideCookieBanner() {
document.head.append(crel('style', {
type: 'text/css',
textContent: `body:not(.show-evidon) .evidon-banner {
display: none !important;
}`
}));
// not an unconditional hiding. let the user click a banner button once to trigger future hiding.
if (!localStorage.evidon_clicked) {
document.body.classList.add('show-evidon');
document.body.addEventListener('click', e => {
if (Array.from(document.querySelectorAll(`button[class*="evidon"]`)).includes(e.target)) {
localStorage.evidon_clicked = true;
}
}, true);
}
}
function main() {
log('Starting.');
// grab webpack modules first
const mR = new moduleraid({ entrypoint: "__LOADABLE_LOADED_CHUNKS__", debug: false });
const [ { Content } ] = mR.findModule('CMS'); // all the backend APIs are here.
// state kept by this script
const store = {
// watchlist items last fetched
watchlistItems : [],
// classnames extracted from webpack exports, then cached here
classNames: {}
};
// debugging help
const devToolsDone = observeDOM(() => {
if (devtools.isOpen) {
const exposed = { Content, getProp, updateNodeProps, findNodesWithProp, mR, store, moduleraid };
log('DevTools open', exposed);
Object.assign(window, exposed);
devToolsDone();
}
});
// initial setup
// wait for React so we can peek at Redux' state and get the accountId
const setupDone = observeReact(() => {
const accountId = getProp("store")?.getState()?.userManagement?.account?.accountId;
if (!accountId) return;
if (!localStorage.watchlist_userscript_setup) {
console.log("accountId",accountId);
// override watchlist_sort with our new order once after install.
localStorage.WATCHLIST_SORT = JSON.stringify({ [accountId]: { type: "natural", order: "desc" }});
localStorage.watchlist_userscript_setup = true;
}
setupDone();
});
// add our sort data early enough to be used by first render
sortInit(mR);
// intercept and augment watchlist requests
axiosInit(Content, store);
// player fixes - code triggers on React tree changes
observeReact(tweakPlayer);
// watchlist fixes - code triggers on DOM tree changes
observeDOM(() => decorateWatchlist(store));
// hide video comments by default
document.head.append(crel('style', {
type: 'text/css',
textContent: `body:not(.show-comments) .commenting-wrapper>div:last-child {
display: none;
}`
}));
observeDOM(hideComments);
// the cookie banner keeps on popping up even after interacting with it. make it not do that.
hideCookieBanner();
}
untilDOM(() => window.__LOADABLE_LOADED_CHUNKS__ ).then(main);