Coursera SubEx / Coursera multiple subtitles show below the video / Coursera多字幕显示在视频下方插件

Coursera SubEx: 根据你的选择,同时显示多种语言的字幕显示在coursera.org课程学习页面的视频播放器下方。 不占用视频内容区域,还可以方便拷贝字幕做笔记。

目前为 2022-11-09 提交的版本。查看 最新版本

// ==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> &nbsp; `)
            })
            $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)
            }
        })

    })

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址