您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
UI tweaks for VRV. put new episodes at the top of the watchlist and series pages.
// ==UserScript== // @name VRV watchlist and other tweaks // @namespace Itsnotlupus Scripts // @description UI tweaks for VRV. put new episodes at the top of the watchlist and series pages. // @match https://vrv.co/* // @version 0.21 // @@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 // @grant GM_setValue // @grant GM_getValue // ==/UserScript== /*jshint ignore:start */ const win = unsafeWindow; // A set of strings that could be localized. const i18n = { 'en': { 'Watchlist userscript sort - Prioritize unwatched new items': 'Watchlist userscript sort - Prioritize unwatched new items', 'Natural Sort': 'Natural Sort', 'New Episodes 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}' } } // hook into React as early as possible let reactRoot; const h = '__REACT_DEVTOOLS_GLOBAL_HOOK__'; if (win[h]) { const ocfr = win[h].onCommitFiberRoot.bind(win[h]); win[h].onCommitFiberRoot = (_, root) => { reactRoot = root; return ocfr(_, root); }; } else { const listeners={}; win[h] = { inject: ()=>0, checkDCE: ()=>0, onCommitFiberRoot: (_, root) => reactRoot = root, onCommitFiberUnmount: ()=>0, supportsFiber: true, on: ()=>0, sub: ()=>0, renderers: [], emit: ()=>0 }; } function getPropsThatContain(name) { const getPropFromNode = node => { if (!node) return; const props = node.memoizedProps; if (props?.[name] !== undefined) return props; const siblingProp = getPropFromNode(node.sibling); if (siblingProp !== undefined) return siblingProp; const childProp = getPropFromNode(node.child); if (childProp !== undefined) return childProp; } return getPropFromNode(reactRoot?.current); } /** Magically obtain a prop value from the most top-level React component we can find */ function getProp(name) { return getPropsThatContain(name)?.[name]; } /** Horribly mutate a name prop value on zero or more React components */ function setProp(name, value) { let props; while (props = getPropsThatContain(name)) { if (props[name] === value) return; props[name] = value; } } const { el, svg, mount, setChildren } = redom; const f = n => function(q) { return this[n] ? this[n](q) : document[n](q); }; const $ = Node.prototype.$ = f('querySelector'); const $$ = Node.prototype.$$ = f('querySelectorAll'); Element.prototype.attr = function(k,v) { return v==null?this.getAttribute(k):this.setAttribute(k,v) }; // why am I like this const sleep = (w = 100) => new Promise(r=>setTimeout(r, w)); const until = async (f, w) => { while (!f()) await sleep(w) }; const loc = (s,locale=navigator.language, lang=locale.split('-')[0]) => i18n[locale]?.[s] ?? i18n[lang]?.[s] ?? s; const t = (s,o) => o?loc(s).replace(/\{([^{]*)\}/g,(a,b)=>o[b]||a):loc(s); const urlize = s => escape(s.replace(/\s+/g, '-').replace(/\W/g, '')); win.addEventListener('load', () => { mount(document.head, el('script', { src: 'https://unpkg.com/[email protected]/dist/moduleraid.umd.js', onload: go })); }); async function go() { //console.error('VRV watchlist userscript starting.'); await until(() => getProp('store')?.getState()?.accounts?.currentUser?.account); const mR = new moduleraid({ entrypoint: "webpackChunkvrvweb", debug: true }); const [_, { default: { Core, Disc, CMS } }] = mR.findModule('Core'); // track watchlist sort dropdown state let previousSort, previousSortLabel; if (window.devtools.isOpen) { const exposed = { reactRoot, Core, Disc, CMS, getProp, setProp, mR }; console.log('VRV Watchlist: DevTools open - exposing ', exposed); Object.assign(win, exposed); } async function decorateWatchlist() { if ($('.erc-watchlist-sorting') && !$('.erc-watchlist-sorting .smart-sort')) { const smartSortEnabled = !!GM_getValue('smartSort', true); let input; const radio = el('li.erc-watchlist-dropdown-list-item.smart-sort', el('.c-radio-button', el('label.c-radio-button__label', { title: t('Watchlist userscript sort - Prioritize unwatched new items') }, [ input = el('input.c-radio-button__input', { type: 'radio', 'name': 'sort_by', value:'smart_sort', ...(smartSortEnabled?{checked:true}:null)}), el('span.c-radio-button__checkmark'), t('Natural Sort') ]))); const radioContainer = $('.erc-watchlist-sorting .erc-watchlist-dropdown-list'); radioContainer.insertBefore(radio, radioContainer.firstChild); Array.from(radioContainer.$$('input')).forEach(radio => radio.addEventListener('input', e=> { GM_setValue('smartSort', !!input.checked) sortWatchlist(); })); } let decorated = false; let items; const decorations = []; for (const card of $$('.erc-watchlist-collection .watchlist-card:not(.decorated)')) { if (!items) { items = (await Disc.getAccountWatchlist({ n : 100 }))?.data?.items; } if (!items) continue; const [_,mode,item_id] = card.$('.c-watchlist-card__content-link').attr('href').split('/'); decorateWatchlistItem(card, item_id, items); decorated = true; } if (decorated) { sortWatchlist() } } function sortWatchlist() { const smartSortEnabled = !!GM_getValue('smartSort', true); // sort our decorated cards. if (smartSortEnabled) { itemCount = $$('.erc-watchlist-collection .watchlist-card[lastAirDate]').length; const resultTitle = $('.controls-result-wrapper .result-title'); if (!previousSort || !document.body.contains(previousSort[0])) { previousSortLabel = resultTitle?.textContent; previousSort = Array.from($$('.erc-watchlist-collection .watchlist-card[lastAirDate]')); } if (resultTitle) resultTitle.textContent = t('Natural Sort'); previousSort.slice().sort((a,b)=> { const sortByWatched = a.attr('watched')-b.attr('watched'); if (sortByWatched) return sortByWatched; const sortByAirDate = b.attr('lastAirDate') - a.attr('lastAirDate'); return a.attr('watched') == 1 ? -sortByAirDate : sortByAirDate; }).forEach(card => card.parentNode.appendChild(card)); } else { if (previousSort) { $('.controls-result-wrapper .result-title').textContent = previousSortLabel ?? ''; previousSort.forEach(card => card.parentNode.appendChild(card)); previousSort = null; previousSortLabel = null; } } } function decorateWatchlistItem(card, item_id, items) { const item = items.find(item => item.panel.id === item_id); if (!item || card.classList.contains('decorated')) return; const { completion_status, panel : { episode_metadata: ep, images: {thumbnail: [imgs] = []}, title }} = item; const lastAirDate = new Date(ep.episode_air_date); const ts = lastAirDate.getTime(); card.attr('lastAirDate', ts); if (completion_status) { // 2 weeks. why? because this is the original air date, and VRV doesn't provide a date of local availability. // So.. we wing it and assume most shows become available within a week of their original air date. sadness. if (Date.now() - lastAirDate < 14*24*3600*1000) { card.attr('watched', '1'); // half-dullify and show original air date. card.style = 'filter: grayscale(90%);opacity:.9'; const metadata = card.$('.c-watchlist-card__watch-status'); setChildren(metadata, [ el('span', { style: 'font-size:.8rem; margin-top: 1.5rem' }, t('New Episodes on {DATE}', { DATE: moment(lastAirDate).format('dddd LT')})) ]); } else { card.attr('watched', '2'); // old shows, fully watched. // dullify thoroughly. card.style = 'filter: grayscale(90%);opacity:0.5'; const metadata = card.$('.c-watchlist-card__watch-status'); setChildren(metadata, [ el('span', { style: 'font-size:.8rem; margin-top: 1.5rem' }, t('No Recent Episode')) ]); } } else { // use next episode image, title & number to decorate watchlist item. iffy CSS. card.attr('watched', '0'); const metadata = card.$('.c-watchlist-card__watch-status'); 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})) ]); metadata.style = 'height: 2.7em; overflow: hidden; text-overflow: ellipsis; -webkit-line-clamp: 2; display: -webkit-box; -webkit-box-orient: vertical;'; } card.classList.add('decorated'); } async function fixPlayer() { const [_,page] = location.pathname.split('/'); if (page !== 'watch') return; // disable auto-play in some situations const upNext = getProp('upNextResource'); if (upNext) { const currentlyPlaying = getProp('store').getState().watch.mediaResource; if (upNext?.json?.season_number !== currentlyPlaying?.json?.season_number || upNext?.json?.season_title !== currentlyPlaying?.json?.season_title) { // since VRV seasons are used for many things that are not actually seasons, // auto-playing a different season will often play something unexpected and damage the watchlist in the process // console.log("Last episode of season. Auto-play disabled.") setProp('upNextResource', undefined); } } } async function decorateSeriesPage() { const [_,page,series_id] = location.pathname.split('/'); if (page !== 'series') return; const parent = $('.series-page-container .content'); if (!parent) return; const playIcon = parent.$('.art-overlay .c-svg-play-icon'); if (!playIcon) return; // remove the XL status of the first episode of the season, because why is that even a thing. // this needs to keep happening even after we put our "up next" card together, because VRV can reset an XL card at anytime. const xlcard = parent.$('.erc-tabs .xl-card'); if (xlcard) xlcard.classList.remove('xl-card'); const xlarticle = parent.$('.erc-tabs article.xl-episode'); if (xlarticle) xlarticle.classList.remove('xl-episode'); // craft a plausible "Up Next" XL showcard that immediately shows what will play next if (parent.classList.contains('decorated')) return; // don't overfetch the API parent.classList.add('decorated'); const account_id = getProp('store').getState().accounts.currentUser.account.id ?? '-'; const data = await Core.getUpNext({account_id, mode: 'series', series_id}); const { data: { playhead, panel : { id, description, completion_status, episode_metadata: ep, images: {thumbnail: [imgs] = []}, title } } } = data; const timeString = sec => moment.duration(sec, 'seconds').format('hh:mm:ss'); const duration = timeString(ep.duration_ms/1000); parent.insertBefore(el('.upnext.item-list-wrapper', { style: 'margin-bottom: 1em' }, [ el('.erc-upsell-title', t('Up Next')), el('.item-list', { style: 'margin: 0' }, el('.media-list-element.xl-card', el('article.erc-episode-card.xl-episode', [ el('a.card-link', { title, href: `/watch/${id}/${urlize(ep.series_title)}:${urlize(title)}` }), el('.h-thumbnail', { style: `border-color: ${parent.$('.h-thumbnail').style.borderColor}` }, el('img.image.c-content-image', { src: imgs.find(obj=>obj.width===800).source, alt: title }), el('.art-overlay', e=>e.innerHTML=playIcon.outerHTML), el('.episode-state-info' + ( playhead ? '.state-progress-bar' : ''), playhead ? el('.erc-progress-bar', [ el('.progress-wrapper', el('.progress-bar', { style: `width: ${~~(playhead/ep.duration_ms*1e9)/1e4 + '%'}` })), el('.progress-info', `${timeString(playhead)} / ${duration}`) ]) : el('span.duration', duration) ) ), el('section.info', el('.series-title', ep.series_title), el('.erc-content-title episode-title', `S${ep.season_number || 1}E${ep.episode_number || 1} - ${title}`), el('p.episode-description', description), el('.details-metadata', el('.c-meta-tags.media-tag-group', [ el('span.c-meta-tags__type', t('Episode')), ep.is_dubbed && el('span.c-meta-tags__language', t('Dubbed')), ep.is_subbed && el('span.c-meta-tags__language', t('Subtitled')) ])) ) ]))) ]), parent.$('.information-tabs-wrapper')); } // check frequently for tweaks to apply to various pages. each check must be fast. async function main() { while (true) { const [_,page] = location.pathname.split('/'); switch (page) { case 'series': await decorateSeriesPage(); break; case 'watch': await fixPlayer(); break; case 'watchlist': await decorateWatchlist(); break; } await sleep(); } } await main(); }
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址