2U Better

Adds enhancements to the LMS 2U

  1. // ==UserScript==
  2. // @name 2U Better
  3. // @description Adds enhancements to the LMS 2U
  4. // @author Jared Beach
  5. // @include https://2vu.engineeringonline.vanderbilt.edu/*
  6. // @version 1.0
  7. // @namespace 2uBetter
  8. // ==/UserScript==
  9.  
  10. function TwoVuBetter() {
  11. /** @type {import("two-vu-better").TwoVuBetter} */
  12. const self = this;
  13. const STORAGE_PLAYBACK_RATE = 'playback-rate';
  14. const STORAGE_CURRENT_TIME = 'current-time_';
  15. const SKIP_SIZE = 15;
  16. self.vjs = undefined;
  17. self.player = undefined;
  18.  
  19. const getWindow = () => {
  20. const frame = document.querySelector('iframe');
  21. return (frame && frame.contentWindow) || window;
  22. }
  23.  
  24. const addCustomCss = () => {
  25. const styleTag = getWindow().document.createElement('link');
  26. styleTag.rel = 'stylesheet';
  27. styleTag.href = 'https://gistcdn.githack.com/jmbeach/3b73fc8a33565789ee1b73d1a68276be/raw/3fa09484bcb32dcc4f776871fb8000296ff4e527/2vu.css';
  28. getWindow().document.body.prepend(styleTag);
  29. window.document.body.prepend(styleTag);
  30. }
  31.  
  32. const getNextLectureButton = () => {
  33. return getWindow().document.querySelectorAll('.styles__Arrow-sc-1vkc84i-0')[1];
  34. }
  35.  
  36. const getLectureButtons = () => {
  37. return getWindow().document.querySelectorAll('.button.button--hover.styles__NavigationItemButton-v6r7uk-3.ijvtUw');
  38. }
  39.  
  40. const getCurrentSection = () => {
  41. // happens when there's only one video
  42. if (!getLectureButtons().length) {
  43. return '';
  44. }
  45.  
  46. // @ts-ignore
  47. return getWindow().document.querySelector('button.button--primary.styles__NavigationItemButton-v6r7uk-3.ijvtUw').innerText;
  48. }
  49.  
  50. const getStorageKeyCurrentTime = () => {
  51. return STORAGE_CURRENT_TIME + window.location.href + '_' + getCurrentSection();
  52. }
  53.  
  54. const addSkipForwardButton = () => {
  55. if (self.player.controlBar.getChildById('skipForwardButton')) {
  56. return;
  57. }
  58.  
  59. const btn = self.player.controlBar.addChild('button', {id: 'skipForwardButton'});
  60. 'vjs-control vjs-button vjs-control-skip-forward'.split(' ').forEach(c => {
  61. btn.addClass(c);
  62. });
  63. // @ts-ignore
  64. btn.el().onclick = () => {
  65. self.player.currentTime(self.player.currentTime() + SKIP_SIZE)
  66. }
  67. }
  68.  
  69. const addSkipBackwardButton = () => {
  70. if (self.player.controlBar.getChildById('skipBackwardButton')) {
  71. return;
  72. }
  73.  
  74. const btn = self.player.controlBar.addChild('button', {id: 'skipBackwardButton'});
  75. 'vjs-control vjs-button vjs-control-skip-backward'.split(' ').forEach(c => {
  76. btn.addClass(c);
  77. });
  78. // @ts-ignore
  79. btn.el().onclick = () => {
  80. self.player.currentTime(self.player.currentTime() - SKIP_SIZE)
  81. }
  82. }
  83.  
  84. const storeCurrentTime = () => {
  85. if (self.player.paused() || self.player.currentTime() <= 1) {
  86. return;
  87. }
  88.  
  89. localStorage.setItem(getStorageKeyCurrentTime(), self.player.currentTime().toString());
  90. }
  91.  
  92. const getCourseCards = () => {
  93. return document.querySelector('div._3N6Oy._38vEw.k83C2').children;
  94. }
  95.  
  96. const onRateChange = () => {
  97. localStorage.setItem(STORAGE_PLAYBACK_RATE, self.player.playbackRate().toString());
  98. }
  99.  
  100. const onVideoEnded = () => {
  101. if (parseInt(getCurrentSection()) >= getLectureButtons().length) {
  102. return;
  103. }
  104.  
  105. // auto-advance
  106. // wait for arrow to enable
  107. const isEnabledTimer = setInterval(() => {
  108. // @ts-ignore
  109. if (getNextLectureButton().disabled) {
  110. return;
  111. }
  112.  
  113. clearInterval(isEnabledTimer);
  114. const event = getWindow().document.createEvent('Events');
  115. event.initEvent('click', true, false);
  116. getNextLectureButton().dispatchEvent(event);
  117. }, 100);
  118. }
  119.  
  120. const onVideoChanged = () => {
  121. setTimeout(() => {
  122. init();
  123. }, 500);
  124. }
  125.  
  126. const setCurrentTimeFromStorage = () => {
  127. const storedCurrentTime = localStorage.getItem(getStorageKeyCurrentTime());
  128.  
  129. // only set if not at the very end of the video
  130. if (storedCurrentTime && self.player.duration() - parseFloat(storedCurrentTime) >= 5) {
  131. self.player.currentTime(parseFloat(storedCurrentTime));
  132. }
  133. }
  134.  
  135. const setPlayBackRateFromStorage = () => {
  136. const storedPlaybackRate = localStorage.getItem(STORAGE_PLAYBACK_RATE);
  137. if (storedPlaybackRate) {
  138. self.player.playbackRate(parseFloat(storedPlaybackRate));
  139. }
  140. }
  141.  
  142. const onDurationChanged = () => {
  143. setCurrentTimeFromStorage();
  144. }
  145.  
  146. const onLoaded = () => {
  147. // @ts-ignore
  148. if ((new Date() - window.twoVuLoaded) < 500) {
  149. return;
  150. }
  151.  
  152. // do only once
  153. // @ts-ignore
  154. if (!window.twoVuLoaded) {
  155. var observer = new MutationObserver((mutationList) => {
  156. if (mutationList.length !== 2
  157. || mutationList[0].type !== 'childList'
  158. || mutationList[1].type !== 'childList'
  159. // @ts-ignore
  160. || mutationList[0].target.className !== 'card__body') {
  161. return;
  162. }
  163. onVideoChanged();
  164. });
  165. try {
  166. observer.observe(document.querySelectorAll(
  167. '[class*=styles__Player] [class*=ContentWrapper] [class*=ElementCardWrapper] [class*=HarmonyCardStyles] .card__body')[1],
  168. {childList: true});
  169. }
  170. catch {
  171. // let this fail when there's only one video
  172. }
  173. }
  174.  
  175. // @ts-ignore
  176. window.twoVuLoaded = new Date();
  177. addCustomCss();
  178. const player = self.vjs('vjs-player');
  179. self.player = player;
  180. addSkipBackwardButton();
  181. addSkipForwardButton();
  182. player.on('ratechange', onRateChange);
  183. player.on('ended', onVideoEnded);
  184. setPlayBackRateFromStorage();
  185. player.play();
  186. player.on('durationchange', onDurationChanged);
  187. setInterval(storeCurrentTime, 1000);
  188. }
  189.  
  190. const initVideoPage = () => {
  191. self.player = undefined;
  192. const loadTimer = setInterval(() => {
  193. if (typeof self.vjs === 'undefined') {
  194. // @ts-ignore
  195. self.vjs = getWindow().videojs;
  196. if (typeof self.vjs === 'undefined') {
  197. return;
  198. }
  199. }
  200. // the player itself isn't loaded yet
  201. if (!getWindow().document.getElementById('vjs-player')) {
  202. return;
  203. }
  204. clearInterval(loadTimer);
  205. onLoaded();
  206. }, 500);
  207. }
  208.  
  209. const onDashboardLoaded = _ => {
  210. fetch('https://2vu.engineeringonline.vanderbilt.edu/graphql', {
  211. method: 'POST',
  212. headers: {
  213. 'apollographql-client-version': '0.98.2',
  214. 'apollographql-client-name': 'dashboard',
  215. 'content-type': 'application/json'
  216. },
  217. body: JSON.stringify({
  218. "operationName": "CourseworkForStudentSection",
  219. "variables": {},
  220. "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"
  221. })
  222. }).then(res => {
  223. return res.json()
  224. }).then(data => {
  225. for (const section of data.data.sections) {
  226. for (const module of section.courseOutline.modules) {
  227. const match = document.querySelector(`a[href*="${module.uuid}"]`);
  228. if (match) {
  229. match.innerHTML = `${module.orderLabel}: ${match.innerHTML}`;
  230. const card = match.parentElement.parentElement.parentElement;
  231. const h2 = card.querySelector('h2');
  232. // for some reason the last number after the dash isn't in the page
  233. const nameParts = section.name.split('-');
  234. const shortName = `${nameParts[0]}-${nameParts[1]}`;
  235. h2.innerHTML = h2.innerHTML.replace(shortName, '');
  236. const smallName = document.createElement('span');
  237. smallName.innerHTML = shortName;
  238. smallName.setAttribute('style', 'font-size: 0.8rem; display: block;');
  239. h2.appendChild(smallName)
  240. }
  241. }
  242. }
  243. }).catch(err => {
  244. throw err;
  245. })
  246. }
  247.  
  248. const initDashboard = () => {
  249. const loadTimer = setInterval(() => {
  250. const cards = getCourseCards();
  251. if (cards && cards.length) {
  252. clearInterval(loadTimer);
  253. onDashboardLoaded(cards);
  254. }
  255. }, 250)
  256. }
  257.  
  258. const init = () => {
  259. if (document.location.href.endsWith('dashboard')) {
  260. initDashboard();
  261. } else {
  262. initVideoPage();
  263. }
  264. }
  265.  
  266. init();
  267. }
  268.  
  269. // @ts-ignore
  270. window.twoVuBetter = new TwoVuBetter();