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

Coursera SubEx , Show multiple subtitles/captions of any languge below the video in coursera.org's learning page at your wish.

// ==UserScript==
// @name         Coursera SubEx / Coursera multiple subtitles show below the video / Coursera多字幕显示在视频下方插件
// @namespace    http://tampermonkey.net/
// @version      0.95
// @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://code.jquery.com/jquery-1.12.4.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

// 2023-11-10 修改
// 之前使用 https://cdn.bootcss.com/jquery/1.11.1/jquery.min.js
// 现在改成用    https://code.jquery.com/jquery-1.12.4.min.js

let videoCss = `
  .ex-subtitle-line-wrap {
    font-size: 24px;
  }
  .ex-subtitle-line {
    margin: 5px auto;
    text-align: center;
    border: 1px dashed #aaa;
  }
  .ex-subtitle-line span {
    padding-right: 2px;
    padding-left: 2px;
    line-height: 120%;
  }
  .ex-subtitle-line .subEx_beforeSubItem {
    color: #ccc;
  }
  .ex-subtitle-line .subEx_currentSubItem {
    background-color: #afe9f7;
    color: #111;
  }
  .ex-subtitle-line .subEx_afterSubItem {
    color: #ccc;
  }

.exLngToolbar {
  margin-top: 10px;
}
.exLngToolbar label {
   font-weight: normal;
   padding: 5px 10px 5px 5px;
   background-color: transparent;
}

.exLngToolbar label.selected {
  font-weight: bold;
  background-color: #aaa;
}

.exLngToolbar button{
  margin-left: 10px;
  padding: 0px 5px;
}
  #ym_subExBtnWrap {
    position: absolute;
    left: 0px;
    top: 0px;
    z-index: 9999;
  }
  .ym_subExBtn {
      background-color: #34A853;
      color: #fff;
      border: none;
      padding: 2px 5px;
      margin-right: 8px;
  }

`;
    (function(factory) {
        factory(document, jQuery)
    })(function (document, $) {
        GM_addStyle(videoCss)

        const Setting = (function() {
            let selectedLngs = null, keepSubBeforeCount = null, keepSubAfterCount = null;
            const DEFAULT_SELECTED_LNGS = [],
                  DEFAULT_SUB_FONT_SIZE = '24px',
                  DEFAULT_KEEP_SUB_BEFORE_COUNT = 0,
                  DEFAULT_KEEP_SUB_AFTER_COUNT = 0;

            const LS_KEY_SEL_LNG = "coursera_video_ext_selected_lngs",
                  LS_KEY_SUB_FONT_SIZE = "coursera_video_ext_sub_font_size",
                  LS_KEY_KEEP_SUB_BEFORE_COUNT = "coursera_video_ext_keep_sub_before",
                  LS_KEY_KEEP_SUB_AFTER_COUNT = "coursera_video_ext_keep_sub_after";;


            function getIntValFromLs(key, defaultVal) {
                let n = parseInt(localStorage.getItem(key))
                if(isNaN(n)) {
                    return defaultVal
                }
                return n;
            }

            function saveIntValToLs(key, val, defaultVal) {
                let intVal = parseInt(val)
                if(isNaN(intVal)) {
                    intVal = defaultVal
                }
                localStorage.setItem(key, intVal)
                return intVal
            }

            function getArrayValFromLs(key, defaultVal) {
                let arr = null
                try {
                    arr = JSON.parse(localStorage.getItem(key));
                } catch(e) {
                }
                if(!arr) {
                    arr = defaultVal
                }
                return arr
            }

            function saveArrayValToLs(key, val, defaultVal) {
                localStorage.setItem(key, JSON.stringify(val))
                return val
            }

            function getSubFontSize() {
                return localStorage.getItem(LS_KEY_SUB_FONT_SIZE) || DEFAULT_SUB_FONT_SIZE;
            }

            function saveSubFontSize(fontSize) {
                localStorage.setItem(LS_KEY_SUB_FONT_SIZE, fontSize)
            }

            function getKeepSubBeforeCount() {
                if(keepSubBeforeCount === null) {
                    keepSubBeforeCount = getIntValFromLs(LS_KEY_KEEP_SUB_BEFORE_COUNT, DEFAULT_KEEP_SUB_BEFORE_COUNT);
                }
                return keepSubBeforeCount;
            }

            function saveKeepSubBeforeCount(count) {
                keepSubBeforeCount = saveIntValToLs(LS_KEY_KEEP_SUB_BEFORE_COUNT, count, DEFAULT_KEEP_SUB_BEFORE_COUNT)
            }

            function getKeepSubAfterCount() {
                if(keepSubAfterCount === null) {
                    keepSubAfterCount = getIntValFromLs(LS_KEY_KEEP_SUB_AFTER_COUNT, DEFAULT_KEEP_SUB_AFTER_COUNT);
                }
                return keepSubAfterCount;
            }

            function saveKeepSubAfterCount(count) {
                keepSubAfterCount = saveIntValToLs(LS_KEY_KEEP_SUB_AFTER_COUNT, count, DEFAULT_KEEP_SUB_AFTER_COUNT)
            }


            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 getSelectedLngs() {
                if(!selectedLngs) {
                    selectedLngs = getArrayValFromLs(LS_KEY_SEL_LNG, DEFAULT_SELECTED_LNGS)
                }
                return selectedLngs
            }

            function saveSelectedLngs(arr) {
                selectedLngs = saveArrayValToLs(LS_KEY_SEL_LNG, arr, DEFAULT_SELECTED_LNGS)
                return selectedLngs
            }

            function addLng(lng) {
                if(!lng) {
                    return
                }
                if(!selectedLngs) {
                    selectedLngs = getSelectedLngs()
                }
                if(!selectedLngs.includes(lng)) {
                    selectedLngs.push(lng)
                    saveSelectedLngs(selectedLngs)
                }
                return selectedLngs
            }

            function removeLng(lng) {
                if(!lng) {
                    return
                }
                if(!selectedLngs) {
                    selectedLngs = getSelectedLngs()
                }
                selectedLngs = removeElm(selectedLngs, lng)
                saveSelectedLngs(selectedLngs)
                return selectedLngs
            }

            function clearSelectedLngs() {
                selectedLngs = []
                saveSelectedLngs(selectedLngs)
                return selectedLngs
            }

            return {
                getSelectedLngs,
                clearSelectedLngs,
                removeLng,
                addLng,
                saveSelectedLngs,

                getSubFontSize,
                saveSubFontSize,

                getKeepSubBeforeCount,
                saveKeepSubBeforeCount,
                getKeepSubAfterCount,
                saveKeepSubAfterCount,
            }
        })()

        // global objects 全局变量
        // $videoWrapper 是包含video的比较上级的div
        let $video = null, video = null, showMode, $videoWrapper = null;
        let $extSubtitleWrap = null, $exLngToolbar = null;
        let subtitleLineMap = {}; // 语言string到字幕显示区jQuery对象的映射表

        // 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;
        }

        // 检查已开选中字幕语言和当前视频存在的字幕语言,去掉当前视频不存在的,并保存到localstorage,返回选中语言和当前可用语言的交集
        function checkSelectedLngsValid(selectedLngs, textTracks) {
            const allLngs = textTracksToLngs(textTracks) // 当前视频的全部可用字幕语言
            const notValidLngs = selectedLngs.filter(lng => !allLngs.includes(lng)) // 找出selectedLngs含有,但当前视频不存在的字幕语言
            if(notValidLngs && notValidLngs.length) {
                notValidLngs.forEach(lng => Setting.removeLng(lng))
            }
            return Setting.getSelectedLngs()
        }

        // 根据选中的语言,处理video中的textTracks 和 生成对应的字幕栏
        function applySelectedLngs(selectedLngs, textTracks, $extSubtitleWrap) {
            subtitleLineMap = {}
            $extSubtitleWrap.find(">.ex-subtitle-line").each(function() { // 先把现有字幕栏缓存起来,并且从父节点删除
                let $subtitleLine = $(this) // 这是显示一种字幕语言的div
                let lng = $subtitleLine.attr("data-lng")
                if(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
                }
                $extSubtitleWrap.append($subtitleLine)
            })

            if(selectedLngs && selectedLngs.length) { // 有选中字幕才处理
                // 先设置 track的mode属性 hidden 和 disabled , hidden是激活但不在video中显示的字幕轨
                for (const track of textTracks) {
                    if(selectedLngs.includes(track.language)) {
                        track.mode = 'hidden'
                    } else {
                        track.mode = 'disabled'
                    }
                }
                // 绑定 cue事件  因为大多数时候不是把track激活,就能拿到cues的,需要加载,所以这里用了重试循环任务
                let maxTryTime = 1000, tryTime = 0;
                let bindCueEvent = function() {
                    let cueLoadFlag = true; // 所有选中字幕轨的cues是否都已经加载成功的标志
                    ++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) { // 本字幕轨的cues 加载成功!
                                // 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的事件
                                    cue.onenter = function() {
                                        applyCurrentSub(trackLng, cues, j)
                                    };
                                    cue.onexit = function() {
                                        // console.log(trackLng + ' 字幕退出:' + this.text)
                                    };
                                }
                            } else { // 这个字幕轨的cues没有加载,设置标志为false,等待下次定时任务执行
                                // console.log("XX-尝试" + tryTime + "次:" + trackLng + '--找到空字幕:' + cues.length, cues)
                                cueLoadFlag = false
                            }
                        }
                    }
                    if(cueLoadFlag) { // 如果cues都装载上了 , 需要处理当前字幕显示
                        // 判断当前视频所处时间点加载当前字幕
                        let currentTime = video.currentTime
                        console.log('所选语言字幕轨全部加载完毕,video.currentTime=' + video.currentTime)
                        for (const track of textTracks) { // 循环字幕track
                            let trackLng = track.language
                            if(selectedLngs.includes(trackLng)) { // 选中语言包含这个字幕,需要处理的字幕轨
                                let cues = track.cues

                                if(cues && cues.length) { // 本字幕轨的cues 加载成功!
                                    let matchIdx = findMatchCue(currentTime, cues, trackLng)
                                    applyCurrentSub(trackLng, cues, matchIdx)
                                }
                            }
                        }
                    }
                    if(!cueLoadFlag && tryTime < maxTryTime) { // 如果有 cues没有正常处理,且在最大重试次数内
                        setTimeout(bindCueEvent, 500)
                    }
                }
                bindCueEvent() // 执行字幕cue事件绑定
            }
            console.log('--After applySelectedLngs', textTracks)
        }

        function findMatchCue(currentTime, cues, lng) {
            currentTime = currentTime || 0
            for (let j=0; j<cues.length; ++j) {
                let cue = cues[j]
                if(currentTime >= cue.startTime && currentTime <= cue.endTime) {
                    console.log('currentTime=' + currentTime + ',在语言:' + lng + "中找到匹配字幕:" + j, cue)
                    return j
                }
            }
            console.log('currentTime=' + currentTime + ',在语言:' + lng + "中没有找到匹配字幕,返回0")
            return 0
        }

        // 根据video当前播放时间显示字幕
        // lng : 当前语言
        // cues: 当前cues
        // j: 当前所处字幕index
        function applyCurrentSub(lng, cues, j) {
            // console.log(trackLng + ' 字幕进入:' + this.text)
            let beforeCount = Setting.getKeepSubBeforeCount(), afterCount = Setting.getKeepSubAfterCount();
            // console.log('----j=' + j)
            let beforeTexts = []
            if(beforeCount > 0) {
                let beforeFromIdx = j - beforeCount
                if(beforeFromIdx < 0) {
                    beforeFromIdx = 0;
                }
                for(let k=beforeFromIdx; k<j; ++k) {
                    beforeTexts.push(_cueToSpan(cues[k], 'subEx_beforeSubItem'))
                }
            }
            let afterTexts = []
            if(afterCount > 0) {
                let afterToIdx = j + afterCount + 1
                if(afterToIdx > cues.length) {
                    afterToIdx = cues.length;
                }
                for(let k=j+1; k<afterToIdx; ++k) {
                    // afterTexts.push('<span class="subEx_afterSubItem">' + cues[k].text + ' </span>')
                    afterTexts.push(_cueToSpan(cues[k], 'subEx_afterSubItem'))
                }
            }
            //let subHtml = beforeTexts.join('') + '<span class="subEx_currentSubItem">' + cues[j].text + ' </span>'
            let subHtml = beforeTexts.join('') + _cueToSpan(cues[j], 'subEx_currentSubItem')
            + afterTexts.join('')
            subtitleLineMap[lng].html(subHtml)
            subtitleLineMap[lng].find('span').dblclick(function() {
                var _$me = $(this)
                console.log('双击字幕: '+ _$me.text() + '将跳转到: ' + _$me.attr('startTime'), _$me.attr('endTime'))
                let startTime = parseFloat(_$me.attr('startTime'))
                video.currentTime = startTime
                video.play()
            })
        }

        function _cueToSpan(cue, cls) {
            return '<span class="' + cls + '" startTime="' + cue.startTime + '" endTime="' + cue.endTime + '">' + cue.text + '</span> '
        }


        function removeHeader() {
            var $h = $('header.rc-DesktopHeaderControls')
            if($h.length) {
                $h = $h.closest('div.cds-grid-item')
                if($h.length) {
                    $h.remove()
                }
            }
            $('#header-container').remove()
        }


        function doWork() { // 找到视频元素之后 初始化字幕和控制元素
            if($extSubtitleWrap) {
                $extSubtitleWrap.remove()
            }
            if($exLngToolbar) {
                $exLngToolbar.remove()
            }

            $extSubtitleWrap = $(`<div class="cds-1 css-0 cds-3 cds-grid-item cds-48 ex-subtitle-line-wrap" id="extSubtitleWrap"></div>`);

            let selectedLngs = Setting.getSelectedLngs()
            let textTracks = video.textTracks
            console.log("tracks", textTracks);
            // 求 selectedLngs 和 textTracks 的交集,如果 selectedLngs 有textTracks中不存在的,则需要删除 (也会存储到 localstorage)
            selectedLngs = checkSelectedLngsValid(selectedLngs, textTracks)
            console.log('selectedLngs', selectedLngs)

            let trackInfoObjs = textTracksToSortedObjs(textTracks)
            console.log('trackInfoObjs', trackInfoObjs)

            $exLngToolbar = $('<div class="cds-1 css-0 cds-3 cds-grid-item exLngToolbar" id="exLngToolbar"></div>')
            trackInfoObjs.forEach(obj => {
                $exLngToolbar.append(`<label>${obj.label}<input type="checkbox" value="${obj.language}" /></label> &nbsp; `)
            })
            // 字幕语言选择checkbox初始化和事件绑定
            $exLngToolbar.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, $extSubtitleWrap)
                })
            })

            // ----- 开始 增加 font size 和 最近字幕显示多少条的配置
            let exSubFontSize = Setting.getSubFontSize()
            let selectCtrlStrArr = ['<select name="subExSubFontSizeSel" id="subExSubFontSizeSel">']
            let fontSizeList = ["0.5rem", "0.8rem", "1rem", "1.2rem", "1.5rem", "1.8rem", "2rem", "2.5rem", "3rem", "3.5rem", "4rem", "4.5rem", "5rem"]
            for(let i=6; i<=120; ++i) {
                fontSizeList.push(i + 'px')
            }
            fontSizeList.forEach(fs => {
                selectCtrlStrArr.push('<option value="' + fs + '"' + (fs==exSubFontSize ? ' selected' : '') + '>' + fs + '</option>')
            })
            selectCtrlStrArr.push('</select>')
            let $exSubFontSizeSel = $(selectCtrlStrArr.join(''))
            function applySubExFontSize() {
                $extSubtitleWrap.css("font-size", $exSubFontSizeSel.val())
            }
            $exSubFontSizeSel.change(function() {
                Setting.saveSubFontSize($(this).val())
                applySubExFontSize()
            })
            applySubExFontSize()
            $exLngToolbar.append($exSubFontSizeSel)

            let keepSubBeforeCount = Setting.getKeepSubBeforeCount()
            selectCtrlStrArr = ['<select name="subExkeepSubBeforeCountSel" id="subExkeepSubBeforeCountSel">']
            for(let i=0; i<=10; ++i) {
                selectCtrlStrArr.push('<option value="' + i + '"' + (i==keepSubBeforeCount ? ' selected' : '') + '>' + i + '</option>')
            }
            selectCtrlStrArr.push('</select>')
            let $keepSubBeforeCountSel = $(selectCtrlStrArr.join(''))
            $keepSubBeforeCountSel.change(function() {
                Setting.saveKeepSubBeforeCount($(this).val())
            })

            let keepSubAfterCount = Setting.getKeepSubAfterCount()
            selectCtrlStrArr = ['<select name="subExkeepSubAfterCountSel" id="subExkeepSubAfterCountSel">']
            for(let i=0; i<=10; ++i) {
                selectCtrlStrArr.push('<option value="' + i + '"' + (i==keepSubAfterCount ? ' selected' : '') + '>' + i + '</option>')
            }
            selectCtrlStrArr.push('</select>')
            let $keepSubAfterCountSel = $(selectCtrlStrArr.join(''))
            $keepSubAfterCountSel.change(function() {
                Setting.saveKeepSubAfterCount($(this).val())
                // TODO
            })

            let $exSubShowLastSubCountSelLabel = $('<span> &nbsp; Keep </span>')
            $exLngToolbar.append( $('<span> &nbsp; Keep </span>')).append($keepSubBeforeCountSel)
                .append('<span> : </span>').append($keepSubAfterCountSel)

            // ----- 结束 增加 font size 和 最近字幕显示多少条的配置

            // ----- 开始 增加 mode=1 的时候增大缩小视频播放div的控制按钮
            if(showMode == 1) {
                let $addVideoWrapBtn = $('<button>+</button>'), $reduceVideoWrapBtn = $('<button>-</button>'), stepPx = 10; // 设置增大缩小视频播放窗口每次缩放量 setting
                let posVideoWrapperAddTimeOut = null, posVideoWrapperReduceTimeout = null, timeOutFreq = 150;
                $addVideoWrapBtn.mousedown(function() {
                    posVideoWrapperAdd(stepPx, 0)
                    posVideoWrapperAddTimeOut = setInterval(function() {
                        posVideoWrapperAdd(stepPx, 0)
                    }, timeOutFreq)
                }).on('mouseout mouseup', function() {
                    if(posVideoWrapperAddTimeOut) {
                        clearInterval(posVideoWrapperAddTimeOut)
                    }
                    posVideoWrapperAddTimeOut = null
                })
                $reduceVideoWrapBtn.click(function() {
                    posVideoWrapperReduce(stepPx, 0)
                })
                $reduceVideoWrapBtn.mousedown(function() {
                    posVideoWrapperReduce(stepPx, 0)
                    posVideoWrapperReduceTimeout = setInterval(function() {
                        posVideoWrapperReduce(stepPx, 0)
                    }, timeOutFreq)
                }).on('mouseout mouseup', function() {
                    // console.log('mouseout or mouseup事件触发!!!')
                    if(posVideoWrapperReduceTimeout) {
                        clearInterval(posVideoWrapperReduceTimeout)
                    }
                    posVideoWrapperReduceTimeout = null
                })
                $exLngToolbar.append($addVideoWrapBtn).append($reduceVideoWrapBtn)
            }

            // ----- 结束 增加 mode=1 的时候增大缩小视频播放div

            // 语言checkbox toolbar
            let $toolbarWrap = $("div.rc-VideoToolbar > .cds-grid-item:first")
            // 2023-11-10 修改开始  (页面发生变化  $toolbarWrap 不存在了! )
            if($toolbarWrap.length > 0) {
                $toolbarWrap.find("> .exLngToolbar").remove()
                $toolbarWrap.append($exLngToolbar)
            } else {
                $videoWrapper.after($exLngToolbar)
            }
            // 2023-11-10 修改结束
            
            // let $videoToolbar = $("div.rc-VideoToolbar > .cds-grid-item").append($exLngToolbar)

            // --- 语言选择栏处理完毕 --
            applySelectedLngs(selectedLngs, video.textTracks, $extSubtitleWrap)

            if(showMode == 1) { // 视频显示模式,浮动显示
                /*****************
                // 这段代码在把把字幕栏和字幕语言选择工具栏放到页面下方的方法
                // 把字幕栏和字幕语言选择工具栏放到页面下方
                let $addPoint = $('.rc-ItemFeedback:first').parent().parent()
                console.log('subtitle opt addPoint:', $addPoint)
                $addPoint.before($extSubtitleWrap, $exLngToolbar)
                **********/
                // 改成加在滚动内容区域的头部
                let $addPoint = $('.ItemLecture_Video_Title:first')
                console.log('subtitle opt addPoint:', $addPoint)
                $addPoint.before($extSubtitleWrap, $exLngToolbar)

                videoFloatShow()
            } else { // showMode == 0 字幕栏插入到播放器下方,播放器不浮动
                // 在video上层div后方插入 字幕栏和语言选择工具栏
                // TODO
                if($videoWrapper.hasClass('ym_videoFloatWrapper')) { // 从浮动状态下来
                    $videoWrapper.removeClass('ym_videoFloatWrapper')
                    $videoWrapper.removeAttr("style");
                    /*
                    $videoWrapper.css({
                        "position": "static",
                        "left": 'auto',
                        "width": 'auto',
                        'z-index': 'auto',
                    })
                    */
                    let $videoAddPoint = $('.ItemLecture_Video_Notes_Navigation')
                    if(!$videoAddPoint.length) {
                        $videoAddPoint = $('.ItemLecture_Video_Title')
                        if(!$videoAddPoint.length) {
                            console.error('Coursear页面结构发生变化,找不到视频插入点')
                        }
                    }
                    $videoAddPoint.after($videoWrapper.remove())
                    $extSubtitleWrap.css('margin-top', '10px')
                }
                $videoWrapper.after($extSubtitleWrap, $exLngToolbar)
            }

            console.log("---------结束执行 YM Coursera 字幕处理 ")
        }

        let _findVideoRetryTime = 0, _findVideoRetryMaxTime = 60
        function findVideo(_showMode) {
            showMode = _showMode
            ++_findVideoRetryTime
            $video = $("video.vjs-tech:first");
            if($video.length > 0) {
                console.log("## 找到video")
                video = $video.get(0)
                // 2023-11-10 修改开始
                // $videoWrapper = $video.closest('.cds-grid-item') // 设置包含video的上级层全局变量   video > .video-main-player-container > .rc-VideoMiniPlayer > .cds-1 css-0 cds-3 cds-grid-item cds-48
                $videoWrapper = $video.closest('#video-player-row') // 设置包含video的上级层全局变量   video > .video-main-player-container > .rc-VideoMiniPlayer > .cds-1 css-0 cds-3 cds-grid-item cds-48
                if($videoWrapper.length == 0) {
                    // console.error("video的上级层没有找到:通过 $video.closest('.cds-grid-item'),页面可能发生变化!");
                    console.error("video的上级层没有找到:通过 $video.closest('#video-player-row'),页面可能发生变化!");
                }
                // 2023-11-10 修改结束
                _findVideoRetryTime = 0 // 清零,下次找新的video重新算
                doWork()
                removeHeader()   // 20240-02-11 增加 (删除头部)
               return
            } else {
                if(_findVideoRetryTime >= _findVideoRetryMaxTime) {
                   console.log("-- 没有找到video元素,尝试超过最大尝试次数" + _findVideoRetryMaxTime +"次,退出! 刷新页面重试吧......")
                    return
                } else {
                    setTimeout(findVideo, 3000)
                }
            }
        }

        console.log("---------开始执行 YM Coursera 字幕处理 ")

        // 先注销改成 按钮触发
        // findVideo()

        // 放置 $videoWrapper 到合适的位置

        function posVideoWrapperAdd(addVal, doScrollType) {
            posVideoWrapper($videoWrapper.width() + addVal, doScrollType)
        }

        function posVideoWrapperReduce(addVal, doScrollType) {
            posVideoWrapper($videoWrapper.width() - addVal, doScrollType)
        }

        // showMode==1 浮动模式下,计算video播放窗口大小和位置
        // doScrollType = 0  字幕区域不滚动到视频下方
        // doScrollType = 1  字幕区域直接设置到视频下方位置,不做动画
        // doScrollType = 2  字幕区域直置到视频下方位置,用动画方式滚动
        function posVideoWrapper(targetWidth, doScrollType) {
            let $refBlock = $('.ItemLecture_Video_Title:first')
            if(!$refBlock.length) {
                $refBlock = $('.rc-VideoHighlightingManager:first')
                if(!$refBlock.length) {
                    $refBlock = $('h1.video-name')
                    if(!$refBlock.length) {
                        console.error('courser页面结构变化很大,找不到视频参考元素in posVideoWrapper');
                    }
                }
            }
            let left = 10, top = 16, width = 0;
            if($refBlock.length) {
                left = $refBlock.offset().left;
                width = $refBlock.width()
            }
            if(targetWidth && targetWidth > 0) { // 如果显式指定width
                if(targetWidth <= width) {
                    left = Math.floor(left + (width - targetWidth) / 2)
                    width = targetWidth
                } else {
                    if(targetWidth > 1200) {
                        targetWidth = 1200
                    }
                    left = Math.floor(left - (targetWidth - width) / 2)
                    let $winWidth = $(window).width()
                    if(left + targetWidth > $winWidth) {
                        left = $winWidth - targetWidth
                        if(left < 10) {
                            left = 10
                            targetWidth = $winWidth - left
                        }
                    }
                    width = targetWidth
                }
            }
            let posCss = {
                "top": top + 'px',
                "left": left + 'px',
            }
            if(width && width > 0) {
                posCss.width = width + "px"
            }
            $videoWrapper.css(posCss)
            // console.log('posVideoWrapper set posCss', posCss)

            let height = $videoWrapper.height()

            let $contentWrap = $('.ItemPageLayout_content_body:first')
            $extSubtitleWrap.css('margin-top', (height + 40 - $contentWrap.offset().top) + 'px')
            if(doScrollType == 2) {
                $contentWrap.animate({
                    scrollTop: 20
                }, 1000);
            } else if(doScrollType == 1) {
                $contentWrap.scrollTop(20)
            }
        }

        /***** 这个计算方法废弃
        // 计算video播放窗口大小和位置
        function posVideoWrapper(targetWidth) {
            let RIO = 0.62
            let $win = $(window),maxWidth = $win.width() - 25, maxHeight = $win.height() - 200, width, height;
            if(maxWidth * RIO > maxHeight) {
                if(maxHeight < 50) {
                    maxHeight = Math.floor($win.height() * 0.7)
                }
                height = maxHeight
                width = Math.floor(height / RIO)
            } else {
                width = maxWidth
                height = Math.floor(width * RIO)
            }
            console.log('videoWrap重置宽高计算出来的值:', width, height)
            if(targetWidth && targetWidth > 0 && targetWidth < width) {
                width = targetWidth
            }
            let left = 5 + Math.floor(($win.width() - 25 - width) / 2)

            $videoWrapper.css({
                "top": 20 + 'px',
                "left": left + 'px',
                "width": width + "px",
                // "height": height + "px",
            })

            width = $videoWrapper.width()
            height = $videoWrapper.height()
            console.log('videoWrap重置宽高实际值:', width, height)
        }
        ***********/


        // 视频浮动显示
        function videoFloatShow() {
            // 计算video的大小

            // 先把 $videoWrapper 挪到页面 absolute 流中
            $videoWrapper.addClass('ym_videoFloatWrapper')
            $videoWrapper.css({
                "position": "absolute",
                "z-index": 99999,
            })
            $(document.body).append($videoWrapper.remove())

            posVideoWrapper(0, 2)

            $(window).resize(function() {
                if(showMode == 1) {
                    posVideoWrapper(0, 1)
                }
            })

            let $contentWrap = $('.ItemPageLayout_content_body:first')
            let $videoPlayer = $('.rc-VideoMiniPlayer:first')
            $contentWrap.scroll(function() { // 防止出现coursera自带的右下角mini播放器效果
                $videoPlayer.removeClass('mini')
                $videoPlayer.find('.video-placeholder').css('height', '0px')
                setTimeout(function() {
                    $videoPlayer.removeClass('mini')
                    $videoPlayer.find('.video-placeholder').css('height', '0px')
                }, 10)
            })

            /***************
            // **** 这段代码在把字幕区域加到文档末尾时可用, 不要删除,留着备用和参考。
            // 带脚本文本的长文本滚动区域
            let $contentWrap = $('.ItemPageLayout_content_body:first')
            let initScrollTop = $contentWrap.scrollTop() + $extSubtitleWrap.offset().top - height - 25
            console.log('计算滚动区域scrollTop', $contentWrap.scrollTop(), $extSubtitleWrap.offset(), height, initScrollTop)
            // $contentWrap.scrollTop(initScrollTop)
            $contentWrap.animate({
                scrollTop: initScrollTop
            }, 2000);
            // ItemLecture_Video_Highlights

            // $contentWrap.scroll(function() {
            //    console.log('$contentWrap.scroll', $contentWrap.offset(), $extSubtitleWrap.offset(), $contentWrap.scrollTop())
            // })
            // console.log("$extSubtitleWrap.offset()", $extSubtitleWrap.offset())
            *******************/

        }

        $(function() { // document ready
            let $subExBtnWrap = $('#ym_subExBtnWrap')
            if(!$subExBtnWrap.length) {
                let $exSubtitleInitBtn = $(`<button class="ym_subExBtn">SubEx</button>`)
                $exSubtitleInitBtn.click(function() {
                    findVideo(0)
                })
                // 2023-11-10 修改把 VidEx 按钮暂时屏蔽
                let $exVideoExActionBtn = $(`<button class="ym_subExBtn">VidEx</button>`)
                $exVideoExActionBtn.click(function() {
                    findVideo(1)
                })
                // $subExBtnWrap = $(`<div id="ym_subExBtnWrap"></div>`).append($exSubtitleInitBtn).append($exVideoExActionBtn)
                $subExBtnWrap = $(`<div id="ym_subExBtnWrap"></div>`).append($exSubtitleInitBtn)
                // 2023-11-10 修改把 VidEx 按钮暂时屏蔽
                $(document.body).append($subExBtnWrap)
            }
        })
    })

})();