Skip to content

Commit e6b0ce9

Browse files
committed
Merge commit 'e103543ac3fee52997f1fd93c781bdbc3130cc1c'
2 parents a13ee03 + e103543 commit e6b0ce9

File tree

2 files changed

+268
-2
lines changed

2 files changed

+268
-2
lines changed

src/subtitle/baseVideo.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ export default class BaseVideo {
4747
}
4848
this.isEventListenerLoaded = true;
4949
this.listenUrl();
50+
this.listenPlayer();
51+
}
52+
static async listenPlayer() {
5053
await this.waitPlayer();
5154
this.listenPlay();
5255
this.listenPause();

src/subtitle/netflix.js

Lines changed: 265 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,279 @@
11
import BaseVideo from "./baseVideo";
2+
import $ from "jquery";
23

34
// https://github.com/mikesteele/dual-captions/blob/b0ab92e4670100a27b76b2796995ad1be89f1672/site_integrations/netflix/index.js
45
// https://stackoverflow.com/questions/42105028/netflix-video-player-in-chrome-how-to-seek
56

67
export default class Netflix extends BaseVideo {
78
static sitePattern = /^(https:\/\/)(www\.netflix\.com)/;
8-
static captionRequestPattern = /^(https:\/\/).*(nflxvideo\.net)/;
9+
static captionRequestPattern = /^(https:\/\/).*(nflxvideo\.net)\/\?o=1/;
910
static baseUrl = "https://www.netflix.com";
1011

1112
static playerSelector = "";
1213
static playerApiSelector = "";
1314
static captionContainerSelector = "";
1415
static captionWindowSelector = "";
1516
static captionBoxSelector = "";
16-
}
17+
static sub = {};
18+
19+
static #injectScriptConstructor = (() => {
20+
this.listenMessageFrameFromInject();
21+
})();
22+
static async listenPlayer() {}
23+
static async handleUrlChange(url = window.location.href) {
24+
this.pausedByExtension = false;
25+
this.callMethodFromInject("activateCaption", url);
26+
}
27+
static async activateCaption(url) {
28+
// skip if user caption off, is shorts skip
29+
if (!this.isVideoUrl(url)) {
30+
return;
31+
}
32+
await this.waitPlayerReady(); //wait player load
33+
// get video lang
34+
var lang = await this.guessVideoLang();
35+
this.killInterceptDebounce(); // end caption intercept
36+
await this.interceptCaption(); // start caption intercept
37+
await this.waitUntilForever(() => this.getVideoId());
38+
39+
if (this.checkPlayerCaptionOff()) {
40+
console.log("caption is off");
41+
} else {
42+
console.log(this.getPlayer().getTextTrack())
43+
var videoId = this.getVideoId();
44+
this.requestTrack(lang, videoId); //turn on caption on specified lang
45+
}
46+
}
47+
static async isVideoUrl(url) {
48+
return url.includes(`${this.baseUrl}/watch`);
49+
}
50+
static async waitPlayerReady() {
51+
await this.waitUntilForever(() => this.getPlayer());
52+
var player = this.getPlayer();
53+
await this.waitUntilForever(() => player.getAudioTrack());
54+
}
55+
static setPlayerCaption(lang) {
56+
this.getPlayer().setTimedTextTrack(lang);
57+
}
58+
static setPlayerCaptionOff() {
59+
var offTrack = this.getPlayer()
60+
.getTimedTextTrackList()
61+
.find((track) => track.trackId.includes("NONE"));
62+
this.getPlayer().setTimedTextTrack(offTrack);
63+
}
64+
static checkPlayerCaptionOff() {
65+
const textTrackList = this.getPlayer().getTimedTextTrackList();
66+
var currentTrack = this.getPlayer().getTextTrack();
67+
return !currentTrack || currentTrack.trackId === textTrackList[0]?.trackId;
68+
}
69+
70+
static getPlayer() {
71+
var videoPlayer =
72+
window?.netflix?.appContext?.state?.playerApp?.getAPI().videoPlayer;
73+
var playerSessionId = videoPlayer
74+
?.getAllPlayerSessionIds()
75+
.find((s) => s.includes("watch"));
76+
var player = videoPlayer?.getVideoPlayerBySessionId(playerSessionId);
77+
return player;
78+
}
79+
static getVideoId() {
80+
return String(this.getPlayer().getMovieId());
81+
}
82+
static async guessVideoLang() {
83+
return this.getPlayer()?.getAudioTrack()?.bcp47;
84+
}
85+
static async guessSubtitleLang(url, subtitle) {
86+
return this.getPlayer()?.getTimedTextTrack()?.bcp47;
87+
}
88+
89+
static async interceptCaption() {
90+
if (this.interceptorLoaded) {
91+
return;
92+
}
93+
this.interceptorLoaded = true;
94+
this.interceptor.apply();
95+
this.interceptor.on("request", async ({ request, requestId }) => {
96+
try {
97+
if (this.captionRequestPattern.test(request.url)) {
98+
//get source lang sub
99+
var response = await this.requestSubtitle(request.url);
100+
var targetLang = this.setting["translateTarget"];
101+
var videoId = this.getVideoId();
102+
var xml = await response.text();
103+
var sub1 = this.parseSubtitle(xml, videoId);
104+
var responseSub = sub1;
105+
106+
if (sub1.lang != targetLang) {
107+
var sub2 = await this.requestSubtitleWithReset(targetLang);
108+
var mergedSub = this.mergeSubtitles(sub1, sub2);
109+
responseSub = mergedSub;
110+
}
111+
var xmlRes = this.encodeMergedSubtitles(responseSub);
112+
request.respondWith(new Response(xmlRes));
113+
}
114+
} catch (error) {
115+
console.log(error);
116+
}
117+
});
118+
}
119+
120+
static async requestSubtitle(subUrl, lang, tlang, videoId) {
121+
var res = await fetch(subUrl);
122+
return res;
123+
}
124+
static async requestSubtitleWithReset(lang, videoId) {
125+
var player = this.getPlayer();
126+
var prevSub = player.getTimedTextTrack();
127+
var sub = await this.requestTrack(lang, videoId);
128+
player.setTextTrack(prevSub);
129+
return sub;
130+
}
131+
static async requestTrack(lang, videoId) {
132+
var videoId = videoId || this.getVideoId();
133+
if (this.sub?.[videoId]?.[lang]) {
134+
return this.sub?.[videoId]?.[lang];
135+
}
136+
var player = this.getPlayer();
137+
var subList = player.getTimedTextTrackList();
138+
const selectedTimedTextTrack = subList
139+
.sort((a, b) => (a.trackType === "ASSISTIVE" ? -1 : 1))
140+
.filter((textTrack) => textTrack.isImageBased=== false)
141+
.find((textTrack) => textTrack.bcp47 === lang);
142+
143+
if (!selectedTimedTextTrack) {
144+
return null;
145+
}
146+
147+
player.setTextTrack(selectedTimedTextTrack);
148+
await this.waitUntilForever(() => {
149+
return this.sub?.[videoId]?.[lang];
150+
});
151+
return this.sub?.[videoId]?.[lang];
152+
}
153+
static extractVideoId(url) {
154+
const match = url.match(/\/watch\/(\d+)/);
155+
return match ? match[1] : null;
156+
}
157+
158+
static parseSubtitle(sub, videoId) {
159+
const concatSubtitles = [];
160+
const parser = new DOMParser();
161+
const xmlDoc = parser.parseFromString(sub, "text/xml");
162+
const subtitles = Array.from(xmlDoc.getElementsByTagName("p")).map((p) =>
163+
p.cloneNode(true)
164+
);
165+
var lang = xmlDoc.documentElement.getAttribute("xml:lang");
166+
// remove div tag
167+
const div = xmlDoc.querySelector("div");
168+
div?.parentNode?.removeChild(div);
169+
170+
// Extract all regions and update their IDs to start with the language code
171+
const layout = xmlDoc.getElementsByTagName("layout")[0];
172+
if (layout) {
173+
var regions = Array.from(layout.getElementsByTagName("region"));
174+
regions.forEach((region, index) => {
175+
const newId = `region${index}_${lang}`;
176+
region.setAttribute("xml:id", newId);
177+
});
178+
}
179+
180+
// parse subtitles
181+
for (let i = 0; i < subtitles.length; i++) {
182+
const subtitle = subtitles[i];
183+
const start = parseInt(subtitle.getAttribute("begin").replace("t", ""));
184+
const end = parseInt(subtitle.getAttribute("end").replace("t", ""));
185+
const text = subtitle.textContent;
186+
const region = subtitle.getAttribute("region") + "_" + lang;
187+
var prev = concatSubtitles?.[concatSubtitles.length - 1];
188+
if (prev && prev.start === start && prev.end === end) {
189+
prev.text += " " + text;
190+
} else {
191+
concatSubtitles.push({ start, end, text, region });
192+
}
193+
}
194+
195+
var parsedSubMeta = {
196+
xmlDoc,
197+
lang,
198+
subtitles: concatSubtitles,
199+
regions,
200+
};
201+
if (!this.sub[videoId]) {
202+
this.sub[videoId] = {};
203+
}
204+
this.sub[videoId][lang] = parsedSubMeta;
205+
return parsedSubMeta;
206+
}
207+
208+
static mergeSubtitles(sub1Meta, sub2Meta) {
209+
var mergedSub = [];
210+
var sub1 = sub1Meta.subtitles;
211+
var sub2 = sub2Meta.subtitles;
212+
var mergedSubMeta = { ...sub1Meta };
213+
214+
const layout = mergedSubMeta?.xmlDoc?.getElementsByTagName("layout")?.[0];
215+
216+
sub2Meta?.regions?.forEach((region) => {
217+
layout?.appendChild(region);
218+
});
219+
220+
// fix mismatch length between sub1 sub2
221+
for (let [i, sub1Line] of sub1.entries()) {
222+
var line1 = sub1Line;
223+
var line2 = "";
224+
// get most overlapped sub
225+
sub2.forEach((line) => {
226+
line.overlap = Math.max(
227+
sub1Line.end - line.start,
228+
line.end - sub1Line.start
229+
);
230+
});
231+
sub2.sort((a, b) => a.overlap - b.overlap);
232+
233+
// if sub2 has no overlap, use sub1
234+
mergedSub.push(line1);
235+
if (sub2.length && 0 < sub2[0].overlap) {
236+
line2 = sub2[0];
237+
let line1Copy = { ...line1 };
238+
line1Copy.text = line2.text;
239+
mergedSub.push(line1Copy);
240+
}
241+
}
242+
243+
mergedSubMeta.subtitles = mergedSub;
244+
return mergedSubMeta;
245+
}
246+
247+
static encodeMergedSubtitles(subMeta) {
248+
var xmlDoc = subMeta.xmlDoc;
249+
var subtitles = subMeta.subtitles;
250+
const body = xmlDoc.getElementsByTagName("body")[0];
251+
let div = body.getElementsByTagName("div")[0];
252+
if (!div) {
253+
div = xmlDoc.createElement("div");
254+
body.appendChild(div);
255+
}
256+
257+
subtitles.forEach((sub) => {
258+
const p = xmlDoc.createElement("p");
259+
p.setAttribute("xml:id", `subtitle${subtitles.indexOf(sub) + 1}`);
260+
p.setAttribute("begin", `${sub.start}t`);
261+
p.setAttribute("end", `${sub.end}t`);
262+
p.setAttribute("region", sub.region);
263+
264+
const span = xmlDoc.createElement("span");
265+
span.setAttribute("style", "style0");
266+
span.textContent = sub.text;
267+
268+
p.appendChild(span);
269+
div.appendChild(p);
270+
});
271+
272+
const serializer = new XMLSerializer();
273+
return serializer.serializeToString(xmlDoc);
274+
}
275+
276+
static encodeSubtitle(subtitles) {
277+
return subtitles;
278+
}
279+
}

0 commit comments

Comments
 (0)