Greasy Fork

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 提交的版本。查看 最新版本

// ==UserScript==
// @name         ASU Canvas Helper
// @version      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://greasyfork.org/scripts/383527-wait-for-key-elements/code/Wait_for_key_elements.js?version=701631
// @require https://cdn.jsdelivr.net/npm/[email protected]/build/pdf.min.js
// @namespace https://greasyfork.org/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()