// ==UserScript==
// @name Coursera SubEx / Coursera multiple subtitles show below the video / Coursera多字幕显示在视频下方插件
// @namespace http://tampermonkey.net/
// @version 0.1
// @description Coursera SubEx , Show multiple subtitles/captions of any languge below the video in coursera.org's learning page at your wish.
// @description:zh-CN Coursera SubEx: 根据你的选择,同时显示多种语言的字幕显示在coursera.org课程学习页面的视频播放器下方。 不占用视频内容区域,还可以方便拷贝字幕做笔记。
// @description:zh-TW Coursera SubEx: 根據你的選擇,同時顯示多種語言的字幕顯示在coursera.org課程學習頁面的視頻播放器下方。 不占用視頻內容區域,還可以方便拷貝字幕做筆記。
// @author DryTofu
// @match *://www.coursera.org/learn/*
// @match *://coursera.org/learn/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=coursera.org
// @require https://cdn.bootcss.com/jquery/1.11.1/jquery.min.js
// @license MIT
// @grant GM_addStyle
// ==/UserScript==
(function() {
'use strict';
// coursera字幕处理
// 参考 https://stackoverflow.com/questions/32252337/how-to-style-text-tracks-in-html5-video-via-css/45087610#45087610
// https://stackoverflow.com/questions/64505385/html5-video-subtitles-positioning
let videoCss = `
.ex-subtitle-line {
margin: 5px auto;
text-align: center;
font-size: 24px;
}
.exLngToolbar label {
font-weight: normal;
padding: 5px 10px 5px 5px;
background-color: transparent;
}
.exLngToolbar label.selected {
font-weight: bold;
background-color: #aaa;
}
#exSubtitleInitBtn {
position: absolute;
left: 0px;
top: 0px;
z-index: 9999;
background-color: #34A853;
color: #fff;
border: none;
}
`;
(function(factory) {
factory(document, jQuery)
})(function (document, $) {
GM_addStyle(videoCss)
const Setting = (function() {
let selectedLngs = null
const LS_KEY = "coursera_video_ext_selected_lngs"
function removeElm(arr, elm) {
let i = arr.indexOf(elm)
if(i > -1) {
arr.splice(i, 1)
}
return arr
// return arr.filter(l => l != elm)
}
function readSelectedLngsFromLs() {
let arr = JSON.parse(localStorage.getItem(LS_KEY));
if(!arr) {
arr = []
saveSelectedLngsToLs(arr)
}
return arr
}
function saveSelectedLngsToLs(arr) {
localStorage.setItem(LS_KEY, JSON.stringify(arr))
}
function getSelectedLngs() {
if(!selectedLngs) {
selectedLngs = readSelectedLngsFromLs()
}
return selectedLngs
}
function setSelectedLngs(arr) {
selectedLngs = arr
saveSelectedLngsToLs(selectedLngs)
return selectedLngs
}
function addLng(lng) {
if(!lng) {
return
}
if(!selectedLngs) {
selectedLngs = readSelectedLngsFromLs()
}
if(!selectedLngs.includes(lng)) {
selectedLngs.push(lng)
saveSelectedLngsToLs(selectedLngs)
}
return selectedLngs
}
function removeLng(lng) {
if(!lng) {
return
}
if(!selectedLngs) {
selectedLngs = readSelectedLngsFromLs()
}
return removeElm(selectedLngs, lng)
}
function clearSelectedLngs() {
selectedLngs = []
saveSelectedLngsToLs(selectedLngs)
return selectedLngs
}
return {
getSelectedLngs,
clearSelectedLngs,
removeLng,
addLng,
setSelectedLngs
}
})()
let $video = null, video = null, textTracks = null, selectedLngs;
// if (i.language == "zh-CN" || i.language == "zh-TW" || i.language == "en-US" || i.language == "en") {
const lngWeightMap = {
"zh-CN": 10,
"zh": 20,
"en-US": 30,
"en-GB": 40,
"en": 50,
"zh-TW": 60,
}
const lngWeight = function(lng) {
return lngWeightMap[lng] || 1000
}
function printTracks(msg, $tracks) {
let arr = []
$tracks.forEach($track => {
arr.push($track.attr('srclang') + '-' + $track.attr('label') + '-' + $track.data('lngweight'))
})
console.log(msg, arr)
}
function printTextTracks(msg, textTracks) {
for(let i=0; i<textTracks.length; ++i) {
let track = textTracks[i];
let cues = track.cues;
let cuesLen = cues ? cues.length : 0;
console.log('track' + i, track, cuesLen)
}
}
function textTracksToLngs(textTracks) {
let lngs = []
for (const track of textTracks) {
lngs.push(track.language)
}
return lngs;
}
// 把 textTracks 按照语言权重排序 生成简单对象数组
function textTracksToSortedObjs(textTracks) {
let objs = []
for (const track of textTracks) {
objs.push({
language: track.language,
label: track.label,
lngweight: lngWeight(track.language),
})
}
objs.sort(function(a, b) {
return a.lngweight - b.lngweight
})
return objs;
}
function checkValid(selectedLngs, textTracks) {
const allLngs = textTracksToLngs(textTracks)
const notValidLngs = selectedLngs.filter(lng => !allLngs.includes(lng))
if(notValidLngs && notValidLngs.length) {
notValidLngs.forEach(lng => Setting.removeLng(lng))
}
return Setting.getSelectedLngs()
}
// 根据选中的语言,处理video中的textTracks 和 生成对应的字幕栏
function applySelectedLngs(selectedLngs, textTracks, $subtitleWrap) {
let subtitleLineMap = {}
$subtitleWrap.find(">.ex-subtitle-line").each(function() {
let $subtitleLine = $(this)
let lng = $subtitleLine.attr("data-lng")
// 尝试把去掉的字幕栏也存起来,如果出现奇怪错误,就用后面的判断语句
subtitleLineMap[lng] = $subtitleLine
/*
if(selectedLngs.includes(lng)) {
subtitleLineMap[lng] = $subtitleLine
}
*/
$subtitleLine.remove()
})
selectedLngs.forEach(lng => {
let $subtitleLine = subtitleLineMap[lng]
if(!$subtitleLine) {
$subtitleLine = $(`<div class="ex-subtitle-line cds-1 css-0 cds-3 cds-grid-item cds-48">${lng}</div>`).attr('data-lng', lng)
subtitleLineMap[lng] = $subtitleLine
}
$subtitleWrap.append($subtitleLine)
})
if(selectedLngs && selectedLngs.length) { // 有选中字幕才处理
// 先设置 track hidden 和 disabled 属性
for (const track of textTracks) {
if(selectedLngs.includes(track.language)) {
track.mode = 'hidden'
} else {
track.mode = 'disabled'
}
}
// 绑定 cue事件
let maxTryTime = 1000, tryTime = 0;
let bindCueEvent = function() {
let cueLoadFlag = true;
++tryTime
// console.log("开始尝试第" + tryTime + "次------->")
for (const track of textTracks) { // 循环字幕track
let trackLng = track.language
if(selectedLngs.includes(trackLng)) { // 选中语言包含这个字幕
let cues = track.cues
if(cues && cues.length) {
// console.log("尝试" + tryTime + "次:" + trackLng + '--成功找到cues:' + cues.length, cues)
for (let j=0; j<cues.length; ++j) {
let cue = cues[j]
// console.log('cues[' + j + "]", cue)
cue.onenter = function() {
// console.log(trackLng + ' 字幕进入:' + this.text)
subtitleLineMap[trackLng].html(this.text)
// $subtitleDiv.html(this.text).show()
};
cue.onexit = function() {
// console.log(trackLng + ' 字幕退出:' + this.text)
};
}
} else {
// console.log("XX-尝试" + tryTime + "次:" + trackLng + '--找到空字幕:' + cues.length, cues)
cueLoadFlag = false
}
}
}
if(!cueLoadFlag && tryTime < maxTryTime) { // 如果有 cues没有正常处理
setTimeout(bindCueEvent, 500)
}
}
bindCueEvent()
}
console.log('--After applySelectedLngs', textTracks)
}
function doWork() { // 找到视频元素之后
let $subtitleWrap = $("#extSubtitleWrap");
if(!$subtitleWrap.length) {
let $videoDiv = $video.closest('div.cds-grid-item')
$subtitleWrap = $(`<div class="cds-1 css-0 cds-3 cds-grid-item cds-48" id="extSubtitleWrap"></div>`)
$videoDiv.after($subtitleWrap)
}
selectedLngs = Setting.getSelectedLngs()
textTracks = video.textTracks
console.log("tracks", textTracks);
// 求 selectedLngs 和 textTracks 的交集,如果 selectedLngs 有textTracks中不存在的,则需要删除
selectedLngs = checkValid(selectedLngs, textTracks)
console.log('selectedLngs', selectedLngs)
let trackInfoObjs = textTracksToSortedObjs(textTracks)
console.log('trackInfoObjs', trackInfoObjs)
let $lngToolbar = $('<div class="cds-1 css-0 cds-3 cds-grid-item exLngToolbar"></div>')
trackInfoObjs.forEach(obj => {
$lngToolbar.append(`<label>${obj.label}<input type="checkbox" value="${obj.language}" /></label> `)
})
$lngToolbar.find('input[type=checkbox]').each(function() {
let $checkbox = $(this), lng = $checkbox.val()
if(selectedLngs.includes(lng)) {
$checkbox.prop('checked', true)
$checkbox.closest('label').addClass('selected')
}
$checkbox.click(function() {
if($checkbox.prop('checked')) {
console.log(lng + ' 选中')
selectedLngs = Setting.addLng(lng)
$checkbox.closest('label').addClass('selected')
} else {
console.log(lng + ' 取消选中')
selectedLngs = Setting.removeLng(lng)
$checkbox.closest('label').removeClass('selected')
}
console.log('selectedLngs', selectedLngs)
applySelectedLngs(selectedLngs, video.textTracks, $subtitleWrap)
})
})
// 语言checkbox toolbar
let $toolbarWrap = $("div.rc-VideoToolbar > .cds-grid-item:first")
$toolbarWrap.find("> .exLngToolbar").remove()
$toolbarWrap.append($lngToolbar)
// let $videoToolbar = $("div.rc-VideoToolbar > .cds-grid-item").append($lngToolbar)
// --- 语言选择栏处理完毕 --
applySelectedLngs(selectedLngs, video.textTracks, $subtitleWrap)
console.log("---------结束执行 YM Coursera 字幕处理 ")
}
let _findVideoCount = 0
function findVideo() {
++_findVideoCount
$video = $("video.vjs-tech");
if($video.length > 0) {
video = $video.get(0)
console.log("## 找到video")
_findVideoCount = 0
doWork()
return
} else {
if(_findVideoCount >= 60) {
console.log("-- 没有找到video元素,尝试超过60次,退出")
return
} else {
setTimeout(findVideo, 3000)
}
}
}
console.log("---------开始执行 YM Coursera 字幕处理 ")
// 先注销改成 按钮触发
// findVideo()
$(function() { // document ready
let $exSubtitleInitBtn = $('#exSubtitleInitBtn')
if(!$exSubtitleInitBtn.length) {
$exSubtitleInitBtn = $(`<button id="exSubtitleInitBtn">SubEx</button>`)
$exSubtitleInitBtn.click(function() {
findVideo()
})
$(document.body).append($exSubtitleInitBtn)
}
})
})
})();