ASU Canvas Helper

1. fix video player size issue; 2. fix caption missing; 3. turn on caption by default; 4. add a button for downloading both video and caption with proper filenames

2022-05-08 يوللانغان نەشرى. ئەڭ يېڭى نەشرىنى كۆرۈش.

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 or Violentmonkey 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!)

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!)

// ==UserScript==
// @name         ASU Canvas Helper
// @version      1.1
// @description  1. fix video player size issue; 2. fix caption missing; 3. turn on caption by default; 4. add a button for downloading both video and caption with proper filenames
// @author       Nendo
// @homepage     https://nendo.dev
// @license MIT
// @match https://asuce.instructure.com/courses/*
// @match https://mediaplus.asu.edu/lti/*
// @grant GM_download
// @grant GM.xmlHttpRequest
// @grant unsafeWindow
// @require https://greatest.deepsurf.us/scripts/444680-my-waitforkeyelements/code/my-waitForKeyElements.js?version=1048347
// @require https://cdn.jsdelivr.net/npm/[email protected]/build/pdf.min.js
// @namespace https://greatest.deepsurf.us/users/910724
// ==/UserScript==

// Warning: download made by GM_download won't show download progress, you might have to wait for video download while not knowing its progress

// convert srt text to cues
// copied from https://bl.ocks.org/denilsonsa/aeb06c662cf98e29c379
// note that there's a bug with the code in the link, 
// where parseTS might output 0, which should be a valid timestamp.
// however the condition of (start && end) will be false if a valid timestamp of 0 is present,
// since javascript treat both null and 0 as falsy value.
function parseTS(s) {
  var match = s.match(/^(?:([0-9]+):)?([0-5][0-9]):([0-5][0-9](?:[.,][0-9]{0,3})?)/);
  if (match == null) {
    throw 'Invalid timestamp format: ' + s;
  }
  var hours = parseInt(match[1] || "0", 10);
  var minutes = parseInt(match[2], 10);
  var seconds = parseFloat(match[3].replace(',', '.'));
  return seconds + 60 * minutes + 60 * 60 * hours;
}

function parseSrt(vtt) {
  var lines = vtt.replace('\r\n', '\n').split('\n').map(function (line) {
    return line.trim();
  });
  var cues = [];
  var start = null;
  var end = null;
  var payload = null;
  for (var i = 0; i < lines.length; i++) {
    if (lines[i].indexOf('-->') >= 0) {
      var splitted = lines[i].split(/[ \t]+-->[ \t]+/);
      if (splitted.length != 2) {
        throw 'Error when splitting "-->": ' + lines[i];
      }
      start = parseTS(splitted[0]);
      end = parseTS(splitted[1]);
    } else if (lines[i] == '') {
      if (start !== null && end !== null) {
        var cue = new VTTCue(start, end, payload || '');
        cues.push(cue);
        start = null;
        end = null;
        payload = null;
      }
    } else if (start !== null && end !== null) {
      if (payload == null) {
        payload = lines[i];
      } else {
        payload += '\n' + lines[i];
      }
    }
  }
  if (start !== null && end !== null) {
    var _cue = new VTTCue(start, end, payload);
    cues.push(_cue);
  }
  return cues;
}

// fetch pdf and convert it to srt text
function getSrt(capUrl) {
  return _req({ url: capUrl, responseType: 'blob' })
    .then(resp => {
      const blob = resp.response
      const blobUrl = window.URL.createObjectURL(blob)
      return blobUrl
    })
    .then(url => _pdf.getDocument(url).promise)
    .then(async (doc) => {
      const numPages = doc.numPages
      let srt = ''
      for (let p = 1; p <= numPages; p++) {
        const page = await doc.getPage(p)
        const content = await page.getTextContent()
        srt += content.items.reduce((prev, curr) => {
          if (!isNaN(curr.str) && curr.str !== '' && curr.str !== '0') {
            curr.str = '\n' + curr.str
          }
          if (curr.hasEOL) {
            curr.str += '\n'
          }
          return prev + curr.str
        }, '') + '\n'
      }
      return srt
    })
}

// find the entry point for accessing react state
function getReactFiber(selector) {
  const dom = document.querySelector(selector)
  const key = Object.keys(dom).find(key => {
    return key.startsWith("__reactFiber$") // react 17+
      || key.startsWith("__reactInternalInstance$"); // react <17
  });
  return dom[key]
}

function main() {
  // expose CORS-ignored download/fetch functions and pdfjs functions to global 
  // to make them available in browser context
  unsafeWindow._dl = GM_download
  unsafeWindow._req = GM.xmlHttpRequest
  unsafeWindow._pdf = pdfjsLib
  // add this line below to make pdfjs works in browser context
  pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdn.jsdelivr.net/npm/[email protected]/build/pdf.worker.min.js"  

  // When runs in canvas page
  if (document.URL.startsWith('https://asuce.instructure.com/courses/')) {
    // give iframe the correct size and ratio
    waitForKeyElements('iframe[allowfullscreen]', function (iframe) {
      const iframeBox = iframe.parentNode

      Object.assign(iframeBox.style, {
        position: 'relative',
        margin: 0,
        height: 0,
        paddingTop: '56.25%'
      })
      Object.assign(iframe.style, {
        position: 'absolute',
        width: '100%',
        height: '100%',
        top: 0
      })
    })  

    // retrieve caption, and make a download button if both caption url and video url are available
    waitForKeyElements('span.instructure_file_link_holder', function (capBox) {
      const capTitle = capBox.querySelector('a:nth-child(1)').title
      const capUrl = capBox.querySelector('a:nth-child(2)').href
      const iframe = document.querySelector('iframe[allowfullscreen]')
      const savedName = capTitle.slice(11, 24)

      getSrt(capUrl)
        .then( srt => iframe && iframe.contentWindow.postMessage({ name: 'srt', value: srt }, '*') )

      const dlBtn = document.createElement("button")
      dlBtn.innerText = "Loading..."
      dlBtn.disabled = true
      capBox.appendChild(dlBtn)

      // when received vidUrl from iframe, enable dlBtn
      window.onmessage = (e) => {
        if (e.data && e.data.name === 'vidUrl') {
          const vidUrl = e.data.value
          dlBtn.innerText = "Download All"
          dlBtn.disabled = false
          dlBtn.onclick = () => {
            dlBtn.innerText = "Downloading..."
            dlBtn.disabled = true

            let vidFin = false
            let capFin = false
            const isFin = () => {
              if (vidFin && capFin) {
                dlBtn.innerText = "Finished!"
                dlBtn.disabled = false
              }
            }
            const handleErr = () => {
              dlBtn.innerText = "Error!"
              dlBtn.disabled = false
            }

            _dl({ url: vidUrl, name: savedName + '.mp4', onload: () => { vidFin = true; isFin() }, onerror: handleErr }) // use GM_download to set the filename, since <a download='filename'> do not work

            getSrt(capUrl)
              .then(srt => {
                // check if srt is valid
                if (parseSrt(srt).length === 0) {
                  capFin = true
                  throw "Caption Not Found!\nYou can instead turn on Live Caption in Chrome!"
                } else {
                  return srt
                }
              })
              .then(srt => 'data:text/plain;charset=utf-8,' + encodeURIComponent(srt))
              .then(uri => _dl({ url: uri, name: savedName + '.srt', onload: () => { capFin = true; isFin() }, onerror: handleErr }))
              .catch(err => alert(err))
          }
        }
      }
    })
  // When runs in iframe page
  } else if (document.URL.startsWith('https://mediaplus.asu.edu/lti/')) {

    waitForKeyElements("video > source", function (elem) {
      // make video player fully fill in the iframe box
      document.querySelector('div.MediaPlayerPageContainer').style.padding = 0
      document.querySelector('div.MediaPlayerFlex').style.maxWidth = 'none'
      // send video url to canvas page for the creation of download button
      const vidUrl = elem.src
      window.parent.postMessage({ name: 'vidUrl', value: vidUrl }, '*')
    })

    // receive caption text from canvas page, 
    window.onmessage = (e) => {
      if (e.data && e.data.name === 'srt') {
        const srt = e.data.value
        waitForKeyElements("video", function (elem) {
          console.log('video detected')
          // convert caption text to cues
          elem.querySelector('track').remove()
          const track = elem.addTextTrack('captions', 'Captions', '')
          track.mode = "hidden"
          const cues = parseSrt(srt)

          // if caption is valid
          if (cues.length !== 0) {
            // add caption to the track
            cues.forEach(cue => track.addCue(cue))
  
            // fix the first line caption missing bug (since cuechange event won't be tiggered for the first line caption)
            const capBox = document.querySelector('div.plyr__captions')
            const cap = document.createElement('span')
            cap.classList.add('plyr__caption')
            cap.innerHTML = track.cues[0].text
            capBox.appendChild(cap)
            
            // show the caption by default, through changing the caption states of plyr (a video playback control library)
            capBox.style.display = 'block'
            const fiber = getReactFiber('div.MediaPlayer')
            if (fiber) {
              console.log(fiber.child.ref.current.plyr.captions)
              fiber.child.ref.current.plyr.captions.currentTrack = 0
              fiber.child.ref.current.plyr.captions.active = true
              setTimeout(() => { fiber.child.ref.current.plyr.captions.toggled = true }, 0)
            }
          }
        })
      }
    }
  }
}

main()