您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
UI tweaks for CrunchyRoll Beta. New sort for watchlists and an autoplay tweak.
当前为
// ==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或关注我们的公众号极客氢云获取最新地址