Greasy Fork

来自缓存

Greasy Fork is available in English.

ASU Canvas Helper

An userscript to fix video player issues of ASU Canvas (the website to take ASU online courses)

当前为 2022-05-09 提交的版本,查看 最新版本

// ==UserScript==
// @name         ASU Canvas Helper
// @version      1.2
// @description  An userscript to fix video player issues of ASU Canvas (the website to take ASU online courses)
// @author       Nendo
// @homepage     https://github.com/nendonerd/ASU-Canvas-Helper
// @license MIT
// @match https://asuce.instructure.com/courses/*
// @match https://mediaplus.asu.edu/lti/*
// @grant GM_download
// @grant GM.xmlHttpRequest
// @grant unsafeWindow
// @require http://greasyfork.icu/scripts/444680-my-waitforkeyelements/code/my-waitForKeyElements.js?version=1048347
// @require https://cdn.jsdelivr.net/npm/[email protected]/build/pdf.min.js
// @namespace http://greasyfork.icu/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/")) {
    waitForKeyElements("iframe[allowfullscreen]", function (iframe) {
      // give iframe the correct size and ratio
      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,
      });

      // pass keypress from Canvas to iframe
      unsafeWindow.addEventListener("keydown", (e) => {
        const value = { key: e.key, keyCode: e.keyCode, which: e.which };
        const nextBtn = document.querySelector(
          'a[aria-label="Next Module Item"]'
        );
        const prevBtn = document.querySelector(
          'a[aria-label="Previous Module Item"]'
        );
        switch (e.key) {
          case "N":
            nextBtn.click();
            break;
          case "P":
            prevBtn.click();
            break;
          default:
            iframe.contentWindow.postMessage(
              { name: "passKeyEvent", value },
              "*"
            );
        }
      });
    });

    // 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) {
          // get plyr player instance (plyr -> the video playback control library used in this iframe)
          const fiber = getReactFiber("div.MediaPlayer");
          const player = fiber.child.ref.current.plyr;

          // 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
            capBox.style.display = "block";
            if (fiber) {
              player.captions.currentTrack = 0;
              player.captions.currentTrackNode = track;
              player.captions.active = true;
              setTimeout(() => {
                player.captions.toggled = true;
              }, 0);
            }
          }
        });
      } else if (e.data && e.data.name === "passKeyEvent") {
        // replay keyevent from Canvas to iframe
        const { key, keyCode, which } = e.data.value;
        const plyr = document.querySelector("div.plyr");

        // bypass repeated key detection
        if ([77, 75, 67].includes(keyCode)) {
          plyr.dispatchEvent(new KeyboardEvent("keydown", { keyCode: 90 }));
        }
        plyr.dispatchEvent(
          new KeyboardEvent("keydown", { key, keyCode, which })
        );
      }
    };

    waitForKeyElements("div.plyr", (plyr) => {
      // add speedBox to show speed
      const speedBox = document.createElement("div");
      speedBox.classList.add("plyr__speedbox");
      Object.assign(speedBox.style, {
        visibility: "hidden",
        position: "absolute",
        left: 0,
        background: "var(--plyr-captions-background,rgba(0,0,0,.8))",
        color: "var(--plyr-captions-text-color,#fff)",
        padding: "3.6px 6px",
        borderRadius: "2px",
      });
      plyr.appendChild(speedBox);

      let goingToHide;
      const showSpeedBox = (speed) => {
        speedBox.innerHTML = "x" + speed.toFixed(1).padEnd(3, "0");
        speedBox.style.visibility = "visible";
        if (goingToHide) {
          clearTimeout(goingToHide);
        }
        goingToHide = setTimeout(() => {
          speedBox.style.visibility = "hidden";
        }, 1000);
      };

      // add keyboard shortcut support
      plyr.addEventListener("keydown", (e) => {
        const fiber = getReactFiber("div.MediaPlayer");
        const player = fiber.child.ref.current.plyr;
        const capBox = document.querySelector("div.plyr__captions");
        let speed = 1;
        let capSize = '';
        switch (e.key) {
          case "<":
            speed = (player.speed * 10 - 1) / 10;
            player.speed = speed;
            showSpeedBox(speed);
            break;
          case ">":
            speed = (player.speed * 10 + 1) / 10;
            player.speed = speed;
            showSpeedBox(speed);
            break;
          case "j":
            player.rewind(5);
            break;
          case "l":
            player.forward(5);
            break;
          case "-":
          case "_":
            capSize = getComputedStyle(capBox).fontSize
            capBox.style.fontSize = (parseInt(capSize) - 2) + 'px'
            break;
          case "=":
          case "+":
            capSize = getComputedStyle(capBox).fontSize
            capBox.style.fontSize = (parseInt(capSize) + 2) + 'px'
            break;
        }
      });
    });
  }
}

main();