2U Better

Adds enhancements to the LMS 2U

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

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

(I already have a user script manager, let me install it!)

Advertisement:

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.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @name           2U Better
// @description    Adds enhancements to the LMS 2U
// @author         Jared Beach
// @include        https://2vu.engineeringonline.vanderbilt.edu/*
// @version        1.0
// @namespace      2uBetter
// ==/UserScript==

function TwoVuBetter() {
  /** @type {import("two-vu-better").TwoVuBetter} */
  const self = this;
  const STORAGE_PLAYBACK_RATE = 'playback-rate';
  const STORAGE_CURRENT_TIME = 'current-time_';
  const SKIP_SIZE = 15;
  self.vjs = undefined;
  self.player = undefined;

  const getWindow = () => {
    const frame = document.querySelector('iframe');
    return (frame && frame.contentWindow) || window;
  }

  const addCustomCss = () => {
    const styleTag = getWindow().document.createElement('link');
    styleTag.rel = 'stylesheet';
    styleTag.href = 'https://gistcdn.githack.com/jmbeach/3b73fc8a33565789ee1b73d1a68276be/raw/3fa09484bcb32dcc4f776871fb8000296ff4e527/2vu.css';
    getWindow().document.body.prepend(styleTag);
    window.document.body.prepend(styleTag);
  }

  const getNextLectureButton = () => {
    return getWindow().document.querySelectorAll('.styles__Arrow-sc-1vkc84i-0')[1];
  }

  const getLectureButtons = () => {
    return getWindow().document.querySelectorAll('.button.button--hover.styles__NavigationItemButton-v6r7uk-3.ijvtUw');
  }

  const getCurrentSection = () => {
    // happens when there's only one video
    if (!getLectureButtons().length) {
      return '';
    }

    // @ts-ignore
    return getWindow().document.querySelector('button.button--primary.styles__NavigationItemButton-v6r7uk-3.ijvtUw').innerText;
  }

  const getStorageKeyCurrentTime = () => {
    return STORAGE_CURRENT_TIME + window.location.href + '_' + getCurrentSection();
  }

  const addSkipForwardButton = () => {
    if (self.player.controlBar.getChildById('skipForwardButton')) {
      return;
    }

    const btn = self.player.controlBar.addChild('button', {id: 'skipForwardButton'});
    'vjs-control vjs-button vjs-control-skip-forward'.split(' ').forEach(c => {
      btn.addClass(c);
    });
    // @ts-ignore
    btn.el().onclick = () => {
      self.player.currentTime(self.player.currentTime() + SKIP_SIZE)
    }
  }

  const addSkipBackwardButton = () => {
    if (self.player.controlBar.getChildById('skipBackwardButton')) {
      return;
    }

    const btn = self.player.controlBar.addChild('button', {id: 'skipBackwardButton'});
    'vjs-control vjs-button vjs-control-skip-backward'.split(' ').forEach(c => {
      btn.addClass(c);
    });
    // @ts-ignore
    btn.el().onclick = () => {
      self.player.currentTime(self.player.currentTime() - SKIP_SIZE)
    }
  }

  const storeCurrentTime = () => {
    if (self.player.paused() || self.player.currentTime() <= 1) {
      return;
    }

    localStorage.setItem(getStorageKeyCurrentTime(), self.player.currentTime().toString());
  }

  const getCourseCards = () => {
    return document.querySelector('div._3N6Oy._38vEw.k83C2').children;
  }

  const onRateChange = () => {
    localStorage.setItem(STORAGE_PLAYBACK_RATE, self.player.playbackRate().toString());
  }

  const onVideoEnded = () => {
    if (parseInt(getCurrentSection()) >= getLectureButtons().length) {
      return;
    }

    // auto-advance
    // wait for arrow to enable
    const isEnabledTimer = setInterval(() => {
      // @ts-ignore
      if (getNextLectureButton().disabled) {
        return;
      }

      clearInterval(isEnabledTimer);
      const event = getWindow().document.createEvent('Events');
      event.initEvent('click', true, false);
      getNextLectureButton().dispatchEvent(event);
    }, 100);
  }

  const onVideoChanged = () => {
    setTimeout(() => {
      init();
    }, 500);
  }

  const setCurrentTimeFromStorage = () => {
    const storedCurrentTime = localStorage.getItem(getStorageKeyCurrentTime());

    // only set if not at the very end of the video
    if (storedCurrentTime && self.player.duration() - parseFloat(storedCurrentTime) >= 5) {
      self.player.currentTime(parseFloat(storedCurrentTime));
    }
  }

  const setPlayBackRateFromStorage = () => {
    const storedPlaybackRate = localStorage.getItem(STORAGE_PLAYBACK_RATE);
    if (storedPlaybackRate) {
      self.player.playbackRate(parseFloat(storedPlaybackRate));
    }
  }

  const onDurationChanged = () => {
    setCurrentTimeFromStorage();
  }

  const onLoaded = () => {
    // @ts-ignore
    if ((new Date() - window.twoVuLoaded) < 500) {
      return;
    }

    // do only once
    // @ts-ignore
    if (!window.twoVuLoaded) {
      var observer = new MutationObserver((mutationList) => {
        if (mutationList.length !== 2
          || mutationList[0].type !== 'childList'
          || mutationList[1].type !== 'childList'
          // @ts-ignore
          || mutationList[0].target.className !== 'card__body') {
          return;
        }
  
        onVideoChanged();
      });
  
      try {
        observer.observe(document.querySelectorAll(
          '[class*=styles__Player] [class*=ContentWrapper] [class*=ElementCardWrapper] [class*=HarmonyCardStyles] .card__body')[1],
          {childList: true});
      }
      catch {
        // let this fail when there's only one video
      }
    }

    // @ts-ignore
    window.twoVuLoaded = new Date();
    addCustomCss();
    const player = self.vjs('vjs-player');
    self.player = player;
    addSkipBackwardButton();
    addSkipForwardButton();
    player.on('ratechange', onRateChange);
    player.on('ended', onVideoEnded);
    setPlayBackRateFromStorage();
    player.play();
    player.on('durationchange', onDurationChanged);
    setInterval(storeCurrentTime, 1000);
  }

  const initVideoPage = () => {
    self.player = undefined;
    const loadTimer = setInterval(() => {
      if (typeof self.vjs === 'undefined') {
        // @ts-ignore
        self.vjs = getWindow().videojs;
        if (typeof self.vjs === 'undefined') {
          return;
        }
      }
  
      // the player itself isn't loaded yet
      if (!getWindow().document.getElementById('vjs-player')) {
        return;
      }
  
      clearInterval(loadTimer);
      onLoaded();
    }, 500);
  }

  const onDashboardLoaded = _ => {
    fetch('https://2vu.engineeringonline.vanderbilt.edu/graphql', {
      method: 'POST',
      headers: {
        'apollographql-client-version': '0.98.2',
        'apollographql-client-name': 'dashboard',
        'content-type': 'application/json'
      },
      body: JSON.stringify({
        "operationName": "CourseworkForStudentSection",
        "variables": {},
        "query": "fragment DashboardStudyListTopicFragment on StudyListTopic {\n  moduleUuid\n  topicUuid\n  name\n  moduleOrder\n  moduleOrderLabel\n  order\n  orderLabel\n  url\n  __typename\n}\n\nquery CourseworkForStudentSection {\n  sections(filterByIsLive: true, ignoreMismatchedSectionEntitlements: true) {\n    name\n    uuid\n    courseOutline {\n      modules {\n        uuid\n        name\n        order\n        orderLabel\n        videoDurationSeconds\n        topics {\n          uuid\n          name\n          order\n          orderLabel\n          typeLabel\n          __typename\n        }\n        __typename\n      }\n      __typename\n    }\n    topicCompletions {\n      userUuid\n      topicUuid\n      __typename\n    }\n    dueDates {\n      topicUuid\n      dueDate\n      __typename\n    }\n    studyListTopics {\n      ...DashboardStudyListTopicFragment\n      __typename\n    }\n    __typename\n  }\n}\n"
      })
    }).then(res => {
      return res.json()
    }).then(data => {
      for (const section of data.data.sections) {
        for (const module of section.courseOutline.modules) {
          const match = document.querySelector(`a[href*="${module.uuid}"]`);
          if (match) {
            match.innerHTML = `${module.orderLabel}: ${match.innerHTML}`;
            const card = match.parentElement.parentElement.parentElement;
            const h2 = card.querySelector('h2');
            // for some reason the last number after the dash isn't in the page
            const nameParts = section.name.split('-');
            const shortName = `${nameParts[0]}-${nameParts[1]}`;
            h2.innerHTML = h2.innerHTML.replace(shortName, '');
            const smallName = document.createElement('span');
            smallName.innerHTML = shortName;
            smallName.setAttribute('style', 'font-size: 0.8rem; display: block;');
            h2.appendChild(smallName)
          }
        }
      }
    }).catch(err => {
      throw err;
    })
  }

  const initDashboard = () => {
    const loadTimer = setInterval(() => {
      const cards = getCourseCards();
      if (cards && cards.length) {
        clearInterval(loadTimer);
        onDashboardLoaded(cards);
      }
    }, 250)
  }

  const init = () => {
    if (document.location.href.endsWith('dashboard')) {
      initDashboard();
    } else {
      initVideoPage();
    }
  }

  init();
}

// @ts-ignore
window.twoVuBetter = new TwoVuBetter();