Greasy Fork

Greasy Fork is available in English.

浙江大学智云课堂小助手

对智云课堂页面的一些功能增强

当前为 2021-11-16 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         浙江大学智云课堂小助手
// @description  对智云课堂页面的一些功能增强
// @namespace    https://github.com/CoolSpring8/userscript
// @supportURL   https://github.com/CoolSpring8/userscript/issues
// @version      0.5.2
// @author       CoolSpring
// @license      MIT
// @match        *://livingroom.cmc.zju.edu.cn/*
// @match        *://classroom.zju.edu.cn/*
// @grant        none
// @require      https://cdn.jsdelivr.net/npm/[email protected]/worker.js
// @run-at       document-end
// ==/UserScript==

const M3U_EXTGRP_NAME = "ZJU-CMC"

/* polyfill/shim begin */

// requestIdleCallback, for Safari
// source: https://github.com/behnammodi/polyfill/blob/master/window.polyfill.js
if (!window.requestIdleCallback) {
  window.requestIdleCallback = function (callback, options) {
    var options = options || {}
    var relaxation = 1
    var timeout = options.timeout || relaxation
    var start = performance.now()
    return setTimeout(function () {
      callback({
        get didTimeout() {
          return options.timeout
            ? false
            : performance.now() - start - relaxation > timeout
        },
        timeRemaining: function () {
          return Math.max(0, relaxation + (performance.now() - start))
        },
      })
    }, relaxation)
  }
}

/* polyfill/shim end */

const querySelector = (
  window.wrappedJSObject?.document || document
).querySelector.bind(document)
const myWindow = window.wrappedJSObject || window

class CmcHelper {
  constructor() {
    this.loaded = false
    this.features = [
      {
        name: "重新加载播放器",
        className: "cmc-helper-reload-player",
        fn: this.reloadPlayer.bind(this),
        description: "播放卡住了点这个",
      },
      {
        name: "获取当前视频地址",
        className: "cmc-helper-get-current-video-url",
        fn: this.getCurrentVideoURL.bind(this),
        description: "回放和直播中均可用",
      },
      {
        name: "生成字幕",
        className: "cmc-helper-generate-srt",
        fn: this.generateSRT.bind(this),
        description: "可供本地播放器使用。不太靠谱的样子",
      },
      {
        name: "打包下载PPT图片",
        className: "cmc-helper-download-ppt-images",
        fn: this.downloadPPTImages.bind(this),
        description: "如题",
      },
      {
        name: "生成播放列表",
        disabled: true,
        className: "cmc-helper-generate-m3u",
        fn: this.generateM3U.bind(this),
        description: "可以在本地播放器中使用的m3u文件。也许期末很实用",
      },
    ]

    const _init = () => {
      if (this.loaded) {
        return
      }

      const courseElem = querySelector(".course-info__wrapper")
      const playerElem = querySelector("#cmcPlayer_container")

      if (
        !this._isVueReady(courseElem) ||
        !this._isVueReady(playerElem) ||
        !("CmcMediaPlayer" in myWindow)
      ) {
        requestIdleCallback(_init)
        return
      }

      this.courseVue = courseElem.__vue__
      this.playerVue = playerElem.__vue__

      if (!(this.playerVue.player && "setMask" in this.playerVue.player)) {
        requestIdleCallback(_init)
        return
      }

      const helperToolbar = document.createElement("div")
      this.features.forEach((feature) =>
        helperToolbar.append(this._createButton(feature))
      )
      helperToolbar.style.display = "flex"
      helperToolbar.style.marginRight = "1.5px"

      const originalToolbar = querySelector(".course-info__header—toolbar")
      originalToolbar.prepend(helperToolbar)

      setTimeout(this.removeMaskOnce, 500)
      this.enablePPTEnhance()
      this.enableSpeechEnhance()

      this.loaded = true

      console.log(
        // eslint-disable-next-line no-undef
        `[CmcHelper] ${GM.info.script.name} v${GM.info.script.version} has been successfully loaded.`
      )
    }

    requestIdleCallback(_init)
  }

  async downloadPPTImages() {
    const pptList = [...this.courseVue.pptList]

    const dtf = new Intl.DateTimeFormat("zh-CN", {
      year: "numeric",
      month: "2-digit",
      day: "2-digit",
      hour: "2-digit",
      minute: "2-digit",
      second: "2-digit",
      hour12: false,
    })
    // eslint-disable-next-line no-undef
    const blob = await downloadZip(
      this._lazyFetch(
        pptList.map((ppt) => ppt.imgSrc.replace(/^http:/, "https:")),
        (resp, url) => {
          const [filename_without_ext, ext] = this._splitFilenameFromURL(url)
          const p = dtf.formatToParts(new Date(Number(filename_without_ext)))
          return {
            input: resp,
            name: `${p[0].value}-${p[2].value}-${p[4].value}_${p[6].value}-${p[8].value}-${p[10].value}.${ext}`,
          }
        }
      )
    ).blob()
    const archiveFilename = document.title

    this._saveBlobToFile(blob, archiveFilename)
  }

  enablePPTEnhance() {
    const _init = () => {
      this.pptVue = this.pptVue || querySelector(".ppt_container").__vue__

      // feat: 允许PPT直接跳转到特定页码
      const slider = document.createElement("input")
      slider.type = "range"
      slider.name = "ppt-index"
      slider.min = 1
      slider.max = this.pptVue.pptList.length
      slider.value = this.pptVue.currentPPTIdx + 1
      // TODO:和实际的页码保持同步
      slider.style.height = "16px"
      slider.style.margin = "-10px 0"
      slider.style.zIndex = 1

      slider.addEventListener("input", (e) => {
        this.pptVue.currentPPTIdx = Number(e.currentTarget.value - 1)
      })

      querySelector("#ppt").after(slider)

      // feat: 避免白色背景PPT切换页码时出现闪烁
      querySelector("#ppt_canvas").getContext("2d").clearRect = () => {}

      // feat: 允许禁用PPT跟随
      // TODO:现在官方提供了lockPPTFlag,研究此功能是否已可被替代
      const t = document.createElement("div")
      t.className = "ppt-thumbtack"
      t.title = "不自动跳转到PPT最新一页"
      t.style.display = "inline"
      t.style.verticalAlign = "middle"
      t.style.cursor = "pointer"
      t.style.marginRight = "20px"
      t.style.color = "#fff"

      // icons from tabler-icons.io, licensed under MIT
      // https://github.com/tabler/tabler-icons/blob/master/LICENSE
      const iconPinned = `<svg xmlns="http://www.w3.org/2000/svg" id="ppt-pinned" class="icon icon-tabler icon-tabler-pinned" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" display="none">
          <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
          <path d="M9 4v6l-2 4v2h10v-2l-2 -4v-6"></path>
          <line x1="12" y1="16" x2="12" y2="21"></line>
          <line x1="8" y1="4" x2="16" y2="4"></line>
       </svg>`

      const iconPinnedOff = `<svg xmlns="http://www.w3.org/2000/svg" id="ppt-pinned-off" class="icon icon-tabler icon-tabler-pinned-off" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
   <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
   <line x1="3" y1="3" x2="21" y2="21"></line>
   <path d="M15 4.5l-3.249 3.249m-2.57 1.433l-2.181 .818l-1.5 1.5l7 7l1.5 -1.5l.82 -2.186m1.43 -2.563l3.25 -3.251"></path>
   <line x1="9" y1="15" x2="4.5" y2="19.5"></line>
   <line x1="14.5" y1="4" x2="20" y2="9.5"></line>
</svg>`

      t.insertAdjacentHTML("afterbegin", iconPinned)
      t.insertAdjacentHTML("afterbegin", iconPinnedOff)

      t.addEventListener("click", (e) => {
        const q = e.currentTarget.querySelector.bind(e.currentTarget)

        if (!this.pptPinned) {
          // 直播
          this.__initCanvas = this.pptVue.initCanvas
          this.pptVue.initCanvas = (type) => {
            if (type !== "latest") {
              this.__initCanvas(type)
            }
          }

          // 回放
          this.__computedPPTIndex = this.courseVue.computedPPTIndex
          this.courseVue.computedPPTIndex = () => {}

          this.pptPinned = true
          q("#ppt-pinned-off").setAttribute("display", "none")
          q("#ppt-pinned").removeAttribute("display")
          return
        }

        this.pptVue.initCanvas = this.__initCanvas
        this.courseVue.computedPPTIndex = this.__computedPPTIndex
        this.pptPinned = false
        q("#ppt-pinned").setAttribute("display", "none")
        q("#ppt-pinned-off").removeAttribute("display")
      })

      querySelector(".ppt_page_btn").prepend(t)
    }

    // 因为每次大小窗口切换时部分页面元素都会被重新创建,所以需要再次修改
    const observer = new MutationObserver((mutations) => {
      mutations
        .filter(
          (mutation) =>
            mutation.type === "childList" &&
            [...mutation.addedNodes].find(
              (node) => node.className === "ppt_container"
            ) !== undefined
        )
        .forEach(_init)
    })

    observer.observe(querySelector(".course-info__main"), { childList: true })
  }

  enableSpeechEnhance() {
    const scopeId = this.courseVue.$options._scopeId
    const preventedTag = "data-cmchelper-prevented"

    const d = document.createElement("div")
    d.setAttribute(scopeId, "") // for style
    d.setAttribute(preventedTag, "false")
    d.className = "choose-item-info"

    const s = document.createElement("span")
    s.setAttribute(scopeId, "")
    s.innerText = "阻止滚动"
    s.innerHTML += " " // align with other switches

    const i = document.createElement("i")
    i.setAttribute(scopeId, "")
    i.className = "el-icon-check"
    i.style.display = "none"

    d.append(s, i)

    d.addEventListener("click", (e) => {
      const wrap = this.courseVue.$refs.spokenLanguageScrollbar.wrap
      const st = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")

      if (e.currentTarget.getAttribute(preventedTag) === "false") {
        Object.defineProperty(wrap, "scrollTop", {
          get: function () {
            return st.get.apply(this, arguments)
          },
          set: function () {},
          configurable: true,
        })
        e.currentTarget.setAttribute(preventedTag, "true")
        i.style.removeProperty("display")
        return
      }
      Object.defineProperty(wrap, "scrollTop", {
        get: function () {
          return st.get.apply(this, arguments)
        },
        set: function () {
          st.set.apply(this, arguments)
        },
        configurable: true,
      })
      e.currentTarget.setAttribute("data-cmchelper-prevented", "false")
      i.style.display = "none"
    })

    querySelector(".choose-item").prepend(d)
  }

  generateM3U() {
    const courseName = this.courseVue.courseName
    const teacherName = this.courseVue.teacherName
    // FIXME: a workaround for "Error: Permission denied to access object" in Firefox + Greasemonkey env
    const menuData = [...this.courseVue.menuData]
    const academicYear = JSON.parse(this.courseVue.liveInfo.information).kkxn
    const semester = JSON.parse(this.courseVue.liveInfo.information).kkxq

    const m3u = `#EXTM3U

#PLAYLIST:${courseName}
#EXTGRP:${M3U_EXTGRP_NAME}
#EXTALB:${courseName}
#EXTART:${teacherName}

${menuData
  .filter((menu) => "playback" in menu.content)
  .map(
    (menu) =>
      `#EXTINF:${menu.duration},${menu.title}\n${menu.content.playback.url[0]}\n`
  )
  .join("\n")}`

    this._saveTextToFile(
      m3u,
      `${courseName}-${teacherName}-${academicYear}${semester}.m3u`
    )
  }

  generateSRT() {
    const url = this.playerVue.player.playervars.url
    const [filename_without_ext] = this._splitFilenameFromURL(url)

    // FIXME: a workaround for "Error: Permission denied to access object" in Firefox + Greasemonkey env
    const data = [...this.courseVue.videoTransContent]
    const subtitle = data
      .map(
        (item, index) => `${index}
${item.markTime},000 --> ${this._addTime(
          item.markTime,
          item.endPlayMs - item.playMs
        )},000
${item.zhtext}`
      )
      .join("\n\n")

    this._saveTextToFile(subtitle, `${filename_without_ext}.srt`)
  }

  getCurrentVideoURL() {
    if (this.playerVue.liveType === "live") {
      // may be changed to `multi` someday
      const sources = JSON.parse(
        cmcHelper.playerVue.liveUrl.replace("mutli-rate: ", "")
      )
      prompt(
        "请复制到支持HLS的播放器(例如MPC-HC、PotPlayer、mpv)中使用",
        sources[0].url
      )
      return
    }
    const url = querySelector("#cmc_player_video").src
    prompt("已选中,请自行复制到剪贴板", url)
  }

  reloadPlayer() {
    const time = this.playerVue.player.getPlayTime()
    this.playerVue.player.destroy()
    this.playerVue.initPlayer()
    setTimeout(() => {
      this.playerVue.player.seekPlay(time)
      this.removeMaskOnce()
    }, 500)
  }

  removeMaskOnce() {
    // this.playerVue.player.setMask({}) // not working in Firefox
    try {
      querySelector(".expand-mask").remove()
    } catch (e) {
      console.error(`[CmcHelper] ${e}`)
    }
  }

  // there may be some better solutions
  _addTime(anchor, duration) {
    let hour = Number(anchor.slice(0, 2))
    let minute = Number(anchor.slice(3, 5))
    let second = Number(anchor.slice(6, 8))

    second += duration

    if (second >= 60) {
      second -= 60
      minute += 1
    }
    if (minute >= 60) {
      minute -= 60
      hour += 1
    }

    this._twoDigitFormat =
      this._twoDigitFormat || new Intl.NumberFormat({ minimumIntegerDigits: 2 })
    const f = this._twoDigitFormat

    return `${f.format(hour)}:${f.format(minute)}:${f.format(second)}`
  }

  _createButton({ name, disabled, className, fn, description }) {
    const button = document.createElement("button")
    button.innerText = name
    button.disabled = disabled
    button.title = disabled
      ? "由于智云课堂系统升级,该功能暂不可用"
      : description
    button.className = className
    button.style.margin = "1.5px"
    button.addEventListener("click", fn)
    return button
  }

  _downloadSmallCrossOriginFile(url, filename) {
    fetch(url)
      .then((resp) => resp.blob())
      .then((blob) => this._saveBlobToFile(blob, filename))
      .catch((e) => alert(`[CmcHelper] 下载失败:${e}`))
  }

  _isVueReady(elem) {
    return elem !== null && "__vue__" in elem
  }

  // TODO: handle errors
  async *_lazyFetch(urls, respCallback) {
    for (const url of urls) {
      try {
        const resp = await fetch(url)
        yield respCallback(resp, url)
      } catch (e) {
        console.error(`[CmcHelper] ${e}`)
      }
    }
  }

  _saveBlobToFile(blob, filename) {
    const url = URL.createObjectURL(blob)
    this._triggerDownload(url, filename)
    URL.revokeObjectURL(url)
  }

  _saveTextToFile(text, filename) {
    const blob = new Blob([text])
    this._saveBlobToFile(blob, filename)
  }

  _splitFilenameFromURL(url) {
    const filename = new URL(url).pathname.split("/").pop()
    const tmp = filename.split(".")
    const ext = tmp.pop()
    const filename_without_ext = tmp.join(".")
    return [filename_without_ext, ext]
  }

  _triggerDownload(url, filename) {
    const a = document.createElement("a")
    a.href = url
    a.download = filename
    a.click()
  }
}

const cmcHelper = new CmcHelper()
// For debugging purposes
myWindow.cmcHelper = cmcHelper