// ==UserScript==
// @name 浙江大学智云课堂小助手 for 新版智云(临时)
// @description 新版智云课堂功能增强(临时解决方案)
// @namespace https://github.com/CoolSpring8/userscript
// @supportURL https://github.com/CoolSpring8/userscript/issues
// @version 0.5.9
// @author CoolSpring
// @license MIT
// @match *://livingroom.cmc.zju.edu.cn/*
// @match *://onlineroom.cmc.zju.edu.cn/*
// @match *://classroom.zju.edu.cn/*
// @grant none
// @require https://unpkg.com/[email protected]/worker.js
// @run-at document-end
// ==/UserScript==
const M3U_EXTGRP_NAME = "ZJU-CMC"
/* polyfill/shim begin */
// requestIdleCallback, for Safari
if (!window.requestIdleCallback) {
window.requestIdleCallback = function (callback) {
return setTimeout(callback, 50)
}
}
/* 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: "生成字幕",
hidden: true,
className: "cmc-helper-generate-srt",
fn: this.generateSRT.bind(this),
description: "可供本地播放器使用。不太靠谱的样子",
},
{
name: "导出语音识别内容",
className: "cmc-helper-export-speech-text",
fn: this.exportSpeechText.bind(this),
description: "如题",
},
{
name: "打包下载PPT图片",
className: "cmc-helper-download-ppt-images",
fn: this.downloadPPTImages.bind(this),
description: "如题",
},
{
name: "生成播放列表",
disabled: true,
hidden: true,
className: "cmc-helper-generate-m3u",
fn: this.generateM3U.bind(this),
description: "可以在本地播放器中使用的m3u文件。也许期末很实用",
},
]
const _init = () => {
if (this.loaded) {
return
}
const courseElem = querySelector(".living-page-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(".operate_wrap")
originalToolbar.prepend(helperToolbar)
setTimeout(this.removeMaskOnce, 500)
this.enablePPTEnhance()
this.enableSpeechEnhance()
this.loaded = true
console.log(
`[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._batchFetch(
pptList.map((ppt) => ({
url: ppt.imgSrc.replace(/^http:/, "https:"),
info: { timeInVideo: ppt.switchTime },
})),
(resp, url, info) => {
const [filename_without_ext, ext] = this._splitFilenameFromURL(url)
const p = dtf.formatToParts(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}__${info.timeInVideo.replaceAll(":", "-")}.${ext}`,
}
}
)
).blob()
const archiveFilename = `${document.title}.zip`
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)
}
exportSpeechText() {
window.open(
URL.createObjectURL(
new File(
[
[
...document.querySelectorAll(".item-origin"),
...[...document.querySelectorAll(".video-trans-item")].map(
(e) => e.firstChild
),
]
.map((e) => e.textContent)
.join("\n"),
],
document.title,
{ type: "text/plain;charset=utf-8" }
)
),
"_blank"
)
}
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)}`
}
_batchFetch(tasks, processFn) {
return tasks.map(({ url, info }) =>
fetch(url)
.then((resp) => processFn(resp, url, info))
.catch((e) => console.error(`[CmcHelper] ${e}`))
)
}
_createButton({ name, disabled, hidden, 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"
if (hidden) {
button.style.display = "none"
}
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
}
_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