Skip to content

Instantly share code, notes, and snippets.

@iron9light
Last active March 8, 2022 13:47
Show Gist options
  • Save iron9light/155fb046393b504304b54a1e855715e6 to your computer and use it in GitHub Desktop.
Save iron9light/155fb046393b504304b54a1e855715e6 to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name tingxie
// @namespace https://gist.github.com/iron9light/155fb046393b504304b54a1e855715e6
// @version 0.1.21
// @description Ting Xie
// @author iron9light
// @match https://www.youtube.com/watch*
// @grant none
// @require https://code.jquery.com/jquery-3.6.0.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 =
ytplayer.config.args.raw_player_response ??
JSON.parse(ytplayer.config.args.player_response);
const captionTracks =
json.captions.playerCaptionsTracklistRenderer.captionTracks;
const enCaptionTrack = captionTracks.filter(
(x) =>
["en", "en-US", "en-GB"].includes(x.languageCode) && 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 originTextNormalize(text) {
const words = text
.replaceAll(/\[[^\[\]]+\]/g, "")
.replaceAll(/\([^\(\)]+\)/g, "")
.replaceAll(/\<[^\<\>]+\>/g, "")
.toLowerCase()
.split(/(\s+|\.\.\.)/)
.map((x) => x.replace(/[‘’]/u, "'").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;
const textArea = document.createElement("textarea");
textArea.innerHTML = this.text;
this.decodedText = textArea.value;
this.words = originTextNormalize(this.decodedText);
}
isIn(time) {
return time >= this.start && time < this.end;
}
isBefore(time) {
return time < this.start;
}
isAfter(time) {
return time >= this.end;
}
get isSound() {
return this.words.length === 0;
}
}
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() {
this.decodedText = this.currentTextLine.decodedText;
this.words = this.currentTextLine.words;
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