UI tweaks for CrunchyRoll Beta. New watchlist order and an autoplay tweak.
// ==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);