Crunchyroll Watchlist Userscript

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

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

You will need to install an extension such as Tampermonkey to install this script.

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

// ==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);