Skip to content

Instantly share code, notes, and snippets.

@masterchop
Forked from iron9light/tingxie.user.js
Created November 1, 2020 19:06
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save masterchop/19c0403d18aab6f0615187b155f69630 to your computer and use it in GitHub Desktop.
Save masterchop/19c0403d18aab6f0615187b155f69630 to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name tingxie
// @namespace https://gist.github.com/iron9light/155fb046393b504304b54a1e855715e6
// @version 0.1.11
// @description Ting Xie
// @author iron9light
// @match https://www.youtube.com/watch*
// @grant none
// @require https://code.jquery.com/jquery-3.5.1.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/immutable/3.8.2/immutable.min.js
// @uploadURL https://gist.githubusercontent.com/iron9light/155fb046393b504304b54a1e855715e6/raw/
// @downloadURL https://gist.githubusercontent.com/iron9light/155fb046393b504304b54a1e855715e6/raw/
// ==/UserScript==
(function() {
'use strict';
$.when($.ready).then(async function() {
console.info('try get caption...');
const caption = await getCaption();
if (caption === null) {
console.info('no caption');
return;
}
const videoController = new VideoController($('video'), caption);
await addDOM(videoController);
});
function getVideoId() {
const searchParams = new URLSearchParams(window.location.search);
const videoId = searchParams.get('v');
return videoId;
}
async function hasEnCaption(videoId) {
const url = 'https://www.youtube.com/api/timedtext?type=list&v=' + videoId;
const xml = await $.get(url).promise();
return $(xml).find('transcript_list > track[lang_code=en]').length > 0;
}
async function getCaption() {
const json = JSON.parse(ytplayer.config.args.player_response);
const captionTracks = json.captions.playerCaptionsTracklistRenderer.captionTracks;
const enCaptionTrack = captionTracks.filter(x => x.languageCode === 'en' && x.kind !== 'asr');
if (enCaptionTrack.length === 0) {
return null;
}
const url = enCaptionTrack[0].baseUrl;
const captionXml = await $.get(url).promise();
const caption = $(captionXml).find('transcript > text')
.toArray()
.map(x => $(x))
.map(x =>
new TextLine(parseFloat(x.attr('start')), parseFloat(x.attr('dur')), x.text())
)
.filter(x => !x.isSound);
if (caption.length === 0) {
return null;
}
return caption;
}
const _wordSimilarityCache = {};
function wordSimilarity(expected, actual) {
if (actual === '') {
return 0.0;
}
if (expected === actual) {
return 1.0;
}
const key = expected + '|' + actual;
if (_wordSimilarityCache.hasOwnProperty(key)) {
return _wordSimilarityCache[key];
}
const distance = levenshteinDistance(actual, expected);
const similarity = 1.0 - distance * 1.0 / expected.length;
const value = Math.max(similarity, 0.0);
_wordSimilarityCache[key] = value;
return value;
}
function _min(d0, d1, d2, bx, ay) {
return d0 < d1 || d2 < d1
? d0 > d2
? d2 + 1
: d0 + 1
: bx === ay
? d1
: d1 + 1;
}
function levenshteinDistance(a, b) {
if (a === b) {
return 0;
}
if (a.length > b.length) {
var tmp = a;
a = b;
b = tmp;
}
var la = a.length;
var lb = b.length;
while (la > 0 && (a.charCodeAt(la - 1) === b.charCodeAt(lb - 1))) {
la--;
lb--;
}
var offset = 0;
while (offset < la && (a.charCodeAt(offset) === b.charCodeAt(offset))) {
offset++;
}
la -= offset;
lb -= offset;
if (la === 0 || lb < 3) {
return lb;
}
var x = 0;
var y;
var d0;
var d1;
var d2;
var d3;
var dd;
var dy;
var ay;
var bx0;
var bx1;
var bx2;
var bx3;
var vector = [];
for (y = 0; y < la; y++) {
vector.push(y + 1);
vector.push(a.charCodeAt(offset + y));
}
var len = vector.length - 1;
for (; x < lb - 3;) {
bx0 = b.charCodeAt(offset + (d0 = x));
bx1 = b.charCodeAt(offset + (d1 = x + 1));
bx2 = b.charCodeAt(offset + (d2 = x + 2));
bx3 = b.charCodeAt(offset + (d3 = x + 3));
dd = (x += 4);
for (y = 0; y < len; y += 2) {
dy = vector[y];
ay = vector[y + 1];
d0 = _min(dy, d0, d1, bx0, ay);
d1 = _min(d0, d1, d2, bx1, ay);
d2 = _min(d1, d2, d3, bx2, ay);
dd = _min(d2, d3, dd, bx3, ay);
vector[y] = dd;
d3 = d2;
d2 = d1;
d1 = d0;
d0 = dy;
}
}
for (; x < lb;) {
bx0 = b.charCodeAt(offset + (d0 = x));
dd = ++x;
for (y = 0; y < len; y += 2) {
dy = vector[y];
vector[y] = dd = _min(dy, d0, dd, bx0, vector[y + 1]);
d0 = dy;
}
}
return dd;
}
function textNormalize(text) {
const words = text.toLowerCase().split(/\s+/)
.map(x => x.replace(/[^a-z0-9']/g, ''))
.filter(x => x.length > 0);
return words;
}
function compare(expected, actual) {
return _compare(expected, actual, 0, 0, 0.0, Immutable.List(), -1.0);
}
function _compare(expected, actual, expectedIndex, actualIndex, score, result, threshold) {
if (expectedIndex === expected.length) {
return {score: score, result: result};
}
if (actualIndex === actual.length) {
let newResult = result;
for (let i = expectedIndex; i < expected.length; ++i) {
newResult = newResult.push({score: 0.0, index: -1});
}
return {score: score, result: newResult};
}
if (expected[expectedIndex] === actual[actualIndex]) {
const newResult = result.push({score: 1.0, index: actualIndex});
return _compare(expected, actual, expectedIndex + 1, actualIndex + 1, score + 1.0, newResult, Math.max(score + 1.0, threshold));
}
if (score + Math.min(expected.length - expectedIndex, actual.length - actualIndex) <= threshold) {
return {score: -1.0, result: []};
}
let solution;
{
const wordScore = wordSimilarity(expected[expectedIndex], actual[actualIndex]);
const newResult = result.push({score: wordScore, index: actualIndex});
solution = _compare(expected, actual, expectedIndex + 1, actualIndex + 1, score + wordScore, newResult, Math.max(score + wordScore, threshold));
}
threshold = Math.max(solution.score, threshold);
{
// skip one expected word
const newResult = result.push({score: 0.0, index: -1});
const solution1 = _compare(expected, actual, expectedIndex + 1, actualIndex, score, newResult, threshold);
if (solution1.score > solution.score) {
solution = solution1;
threshold = Math.max(solution.score, threshold);
}
}
{
// skip one actual word
const solution2 = _compare(expected, actual, expectedIndex, actualIndex + 1, score, result, threshold);
if (solution2.score > solution.score) {
solution = solution2;
}
}
return solution;
}
async function getPanelsEle() {
await new Promise(resolve => setTimeout(resolve, 3000));
//const panelsEle = $('#info');
const panelsEle = $('#panels');
if (panelsEle.length > 0) {
return panelsEle;
}
//await new Promise(resolve => setTimeout(resolve, 1000));
return await getPanelsEle();
}
async function addDOM(videoController) {
const panelsEle = await getPanelsEle();
const txDivEle = $('<div/>').css('color', 'var(--yt-spec-text-primary)')
.css('font-size', '10px');
const controllerDivEle = $('<div/>');
const nextButtonEle = $('<button/>').text('Next').on('click', () => videoController.next(true));
const previousButtonEle = $('<button/>').text('Previous').on('click', () => videoController.previous());
const resetButtonEle = $('<button/>').text('Reset').on('click', () => videoController.reset());
const showButtonEle = $('<button/>').text('Show');
const modeSelectEle = $('<select/>')
.append($('<option/>').val('repeat').text('repeat').attr('selected','selected'))
.append($('<option/>').val('stop').text('stop'))
.append($('<option/>').val('continue').text('continue'))
.change((ev) => videoController.mode = ev.target.value);
const autoNextCheckboxEle = $('<input type="checkbox"/>')
.attr('id', 'autoNext')
.prop('checked', true);
const autoNextLabelEle = $('<label/>').text('Auto Next').attr('for', 'autoNext');
controllerDivEle.append(previousButtonEle)
.append(nextButtonEle)
.append(resetButtonEle)
.append(showButtonEle)
.append(modeSelectEle)
.append(autoNextCheckboxEle)
.append(autoNextLabelEle);
const textDivEle = $('<div/>').css('font-size', '18px').hide();
const scoreDivEle = $('<div/>');
const inputDivEle = $('<div/>');
const inputTextarea = $('<textarea/>').css('width', '100%').css('height', '100px');
let _showMode = 0;
function doShow() {
switch (_showMode) {
case 0:
scoreDivEle.find('.inputhint').css('visibility', 'hidden');
textDivEle.hide();
break;
case 1:
scoreDivEle.find('.inputhint').css('visibility', 'visible');
break;
case 2:
textDivEle.show();
break;
}
}
showButtonEle.on('click', function() {
_showMode = (_showMode + 1) % 3;
doShow();
});
let scoreTextEle;
let _previousIndex = null;
let _previousInputText = null;
function dooninputchange() {
const inputText = inputTextarea.val();
if (videoController.index === _previousIndex && inputText === _previousInputText) {
return;
}
_previousIndex = videoController.index;
_previousInputText = inputText;
const normalizedInput = textNormalize(inputText);
const compareResult = compare(videoController.words, normalizedInput);
compareResult.result.forEach((x, i) => {
x.i = i;
x.word = videoController.words[i];
})
compareResult.score /= videoController.words.length;
if (scoreDivEle.find('span').length === 0) {
videoController.words.forEach(word => {
$('<span/>').text('█'.repeat(word.length))
//.css('margin-right', '3px')
.addClass('inputhint')
.appendTo(scoreDivEle);
});
$('<span/>').text('█')
.css('color', 'pink')
.appendTo(scoreDivEle);
scoreTextEle = $('<span/>').appendTo(scoreDivEle);
doShow();
}
const scoreText = Math.floor(compareResult.score * 100);
scoreTextEle.text(scoreText);
compareResult.result.forEach(x =>
$(scoreDivEle.find('span')[x.i]).css('opacity', x.score)
.css('color', x.score === 1.0 ? 'green' : 'yellow')
);
if (compareResult.score === 1.0 && autoNextCheckboxEle.is(':checked')) {
videoController.next(false);
}
}
function oninputchange() {
setTimeout(dooninputchange, 1000);
}
inputTextarea.on('input', oninputchange);
videoController.onReset = () => {
_showMode = 0;
doShow();
textDivEle.text(videoController.decodedText);
scoreDivEle.find('span').remove();
inputTextarea.val('');
oninputchange();
};
inputDivEle.append(inputTextarea);
txDivEle.append(controllerDivEle)
.append(inputDivEle)
.append(textDivEle)
.append(scoreDivEle);
//panelsEle.before(txDivEle);
panelsEle.append(txDivEle);
videoController.newTextLine();
oninputchange();
}
class TextLine {
constructor(start, duration, text) {
this.start = start;
this.duration = duration;
this.end = start + duration;
this.text = text;
}
isIn(time) {
return time >= this.start && time < this.end;
}
isBefore(time) {
return time < this.start;
}
isAfter(time) {
return time >= this.end;
}
get isSound() {
if (this.text.match(/^\[.+\]$/)) {
return true;
}
if (this.text.match(/^\(.+\)$/)) {
return true;
}
return false;
}
}
class VideoController {
constructor(video, caption) {
this.video = $(video);
this.caption = caption;
this.index = 0;
this.mode = 'repeat';
const self = this;
this.video.on('timeupdate', () => self.ontimeupdate());
this.onReset = null;
}
get currentTextLine() {
return this.caption[this.index];
}
ontimeupdate() {
switch (this.mode) {
case 'repeat':
this._ontimeupdateRepeatMode();
break;
case 'stop':
this._ontimeupdateStopMode();
break;
case 'continue':
this._ontimeupdateContinueMode();
break;
default:
throw 'unsupported mode: ' + this.mode;
}
}
_ontimeupdateRepeatMode() {
const time = this.video[0].currentTime;
const textLine = this.currentTextLine;
if (textLine.isAfter(time)) {
this._setVideoTime(textLine.start);
}
}
_ontimeupdateStopMode() {
const time = this.video[0].currentTime;
const textLine = this.currentTextLine;
if (textLine.isAfter(time)) {
this.video[0].pause();
}
}
_ontimeupdateContinueMode() {
const time = this.video[0].currentTime;
let i = this.index;
while (true) {
const textLine = this.caption[i];
if (textLine.isIn(time)) {
break;
}
if (textLine.isBefore(time)) {
if (i === 0 || this.caption[i - 1].isAfter(time)) {
break;
} else {
--i;
continue;
}
}
else {
if (i < this.caption.length) {
++i;
continue;
} else {
break;
}
}
}
this._search(i);
}
_search(i) {
if (this.index === i) {
return;
}
if (i < 0 || i >= this.caption.length) {
throw 'index out of range: ' + i;
}
this.index = i;
this.newTextLine();
}
next(force) {
if (this.index < this.caption.length - 1) {
this.index++;
if (force) {
this._setVideoTime(this.currentTextLine.start);
}
if (this.mode === 'stop') {
this.video[0].play();
}
this.newTextLine();
}
}
previous() {
if (this.index > 0) {
this.index--;
this._setVideoTime(this.currentTextLine.start);
this.newTextLine();
}
}
reset() {
const textLine = this.currentTextLine;
this._setVideoTime(textLine.start);
this.video[0].play();
}
newTextLine() {
const textArea = document.createElement('textarea');
textArea.innerHTML = this.currentTextLine.text;
this.decodedText = textArea.value;
this.words = textNormalize(this.decodedText);
if (this.onReset !== null) {
this.onReset();
}
}
_setVideoTime(time) {
this.video[0].currentTime = Math.max(time - 0.5, 0);
}
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment