Greasy Fork is available in English.

VRV watchlist and other tweaks

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/moduleraid@5.1.2/dist/moduleraid.umd.js
// @require https://unpkg.com/redom@3.14.2/dist/redom.min.js
// @require https://unpkg.com/moment@2.22.2/min/moment.min.js
// @require https://unpkg.com/moment-duration-format@2.2.2/lib/moment-duration-format.js
// @require https://unpkg.com/devtools-detect@3.0.1/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/moduleraid@5.1.2/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();
}