Crunchyroll Watchlist Userscript

UI tweaks for CrunchyRoll Beta. New sort for watchlists and an autoplay tweak.

当前为 2022-05-10 提交的版本,查看 最新版本

// ==UserScript==
// @name Crunchyroll Watchlist Userscript
// @namespace Itsnotlupus Scripts
// @description UI tweaks for CrunchyRoll Beta. New sort for watchlists and an autoplay tweak.
// @match https://beta.crunchyroll.com/*
// @version 0.1
// @require https://unpkg.com/[email protected]/dist/moduleraid.umd.js
// @require https://unpkg.com/[email protected]/dist/redom.min.js
// @require https://unpkg.com/[email protected]/min/moment.min.js
// @require https://unpkg.com/[email protected]/lib/moment-duration-format.js
// @require https://unpkg.com/[email protected]/index.js
// @run-at document-start
// @license MIT
// ==/UserScript==

/*jshint ignore:start */

const { el, svg, mount, setChildren } = redom;

const DAY = 24*3600*1000;
const WEEK = 7 * DAY;

const NOT_WATCHED = 0;
const WATCHED_WAIT = 1;
const WATCHED_DONE = 2;

// User-facing string handling
const i18n = {
  '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 Sort',
    'Airs on {DATE}': 'Airs on {DATE}',
    'No Recent Episode': 'No Recent Episode',
    'Up Next': 'Up Next',
    'Episode': 'Episode',
    'Dubbed': 'Dubbed',
    'Subtitled': 'Subtitled',
    'S{SEASON}E{EPISODE} - {TITLE}': 'S{SEASON}E{EPISODE} - {TITLE}',
    'Untitled': 'Untitled'
  }
}
const loc = (s,locale=navigator.language, 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 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.remove(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();
};
/** wait for every DOM change until a condition becomes 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);
            });
        }
    };
}

// hook into React as early as possible
const reactObservers = new Set;
const notifyReactObservers = debounce(() => reactObservers.forEach(fn=>fn()));
let reactRoot;
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);
}

// 

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: "Natural Sort", value: "natural", trigger: "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) {
          // 2 weeks. why? because this is the original air date, and 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 has.
        // 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();
          const upNextId = content.upNext.byId[watch.id].contentId;
          const { episode_number } = content.byId[upNextId].episode_metadata;
          if (episode_number === 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(watchlistItems) {
  for (const card of document.querySelectorAll('.erc-my-lists-collection .c-watchlist-card:not(.decorated)')) {
    const [_,mode,item_id] = card.querySelector('.c-watchlist-card__content-link').getAttribute('href').split('/');
    const item = watchlistItems.find(item => item.panel.id === item_id); 
    if (!item || card.classList.contains('decorated')) return;
    const { watched, panel : { episode_metadata: ep, title }} = item;
    const lastAirDate = new Date(ep.episode_air_date);
    switch (watched) {
      case NOT_WATCHED: {
        // use title & number to decorate watchlist item. iffy CSS.
        const metadata = card.querySelector('.c-watchlist-card__subtitle');
        setChildren(metadata, [
          el('span', { style: 'font-size:.8rem; margin-top: 1.5rem' }, 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;';
        break;
      }
      case WATCHED_WAIT: {
        // half-dullify and show original air date.
        card.style = 'filter: grayscale(90%);opacity:.9';
        const metadata = card.querySelector('.c-watchlist-card__subtitle');
        setChildren(metadata, [
          el('span', { style: 'font-size:.8rem; margin-top: 1.5rem' }, t('Airs on {DATE}', { DATE: moment(lastAirDate).format('dddd LT')}))
        ]);
        break;
      }
      case WATCHED_DONE: {
        // old shows, fully watched.
        // dullify thoroughly.
        card.style = 'filter: grayscale(90%);opacity:0.5';
        const metadata = card.querySelector('.c-watchlist-card__subtitle');
        setChildren(metadata, [
          el('span', { style: 'font-size:.8rem; margin-top: 1.5rem' }, t('No Recent Episode'))
        ]);
        break;
      }
    }
    card.classList.add('decorated');
  }
}

function main() {
  log('Starting.');

  // grab webpack modules as soon as we can, jumping over userscript sandboxing
  const mR = new moduleraid({ entrypoint: "__LOADABLE_LOADED_CHUNKS__", debug: false });
  const [ { default: { Content } } ] = mR.findModule('CMS'); // all the backend APIs are here.

  // state kept by this script
  const store = {
    // watchlist items last fetched
    watchlistItems : []
  };
  
  // debugging help
  const devToolsDone = observeDOM(() => {
    if (devtools.isOpen) {
      const exposed = { Content, getProp, updateNodeProps, findNodesWithProp, mR, store };
      log('DevTools open', exposed);
      Object.assign(window, exposed);
      devToolsDone();
    }
  });
  
  // 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.watchlistItems));
}

untilDOM(() => window.__LOADABLE_LOADED_CHUNKS__ ).then(main);

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址