Skip to content

Instantly share code, notes, and snippets.

@joestrong
Forked from SirOlaf/bootlegmigakuplayer.js
Last active June 12, 2025 11:38
Show Gist options
  • Select an option

  • Save joestrong/bfe73326a5f920507236b73058cd853f to your computer and use it in GitHub Desktop.

Select an option

Save joestrong/bfe73326a5f920507236b73058cd853f to your computer and use it in GitHub Desktop.
Bootlegged migaku player
// ==UserScript==
// @name Bootlegged migaku player 1.16.1
// @namespace Violentmonkey Scripts
// @match https://www.youtube.com/watch*
// @grant none
// @version 1.16-joestrong
// @author SirOlaf
// @description 3/1/2025, 10:56:07 PM
// ==/UserScript==
const confignameLemonfoxApiKey = "bootlegplayer_lemonfox_api_key"
const confignameSubgenLang = "bootlegplayer_lemonfox_subgen_lang"
function configGetLemonfoxApiKey() {
const configEntry = localStorage.getItem(confignameLemonfoxApiKey);
return configEntry;
}
function configSetLemonfoxApiKey(val) {
localStorage.setItem(confignameLemonfoxApiKey, val);
}
function configGetSubgenLang() {
const configEntry = localStorage.getItem(confignameSubgenLang);
return configEntry;
}
function configSetSubgenLang(val) {
localStorage.setItem(confignameSubgenLang, val);
}
function isNumeric(value) {
return /^\d+$/.test(value);
}
function newDiv() {
return document.createElement("div");
}
function queryMigakuShadowDom() {
return document.querySelector("#MigakuShadowDom").shadowRoot;
}
function srtToVtt(x) {
// TODO: Write a better converter
var result = [];
var skipDigit = true;
for (const line of x.split(/\r?\n/)) {
if (line.length == 0) {
skipDigit = true;
} else {
if (skipDigit) {
if (!isNumeric(line)) {
// TODO: Error
console.log("Malformed srt file")
return null;
}
skipDigit = false;
} else {
if (line.includes(" --> ")) {
result.push(line.replaceAll(",", "."))
} else {
result.push(line);
}
}
}
}
return result.join("\n");
}
// https://github.com/Experience-Monks/audiobuffer-to-wav
function audioBufferToWav(buffer) {
return audioBufferToWavInternal(buffer);
function audioBufferToWavInternal(buffer, opt) {
opt = opt || {}
var numChannels = buffer.numberOfChannels
var sampleRate = buffer.sampleRate
var format = opt.float32 ? 3 : 1
var bitDepth = format === 3 ? 32 : 16
var result
if (numChannels === 2) {
result = interleave(buffer.getChannelData(0), buffer.getChannelData(1))
} else {
result = buffer.getChannelData(0)
}
return encodeWAV(result, format, sampleRate, numChannels, bitDepth)
}
function encodeWAV(samples, format, sampleRate, numChannels, bitDepth) {
var bytesPerSample = bitDepth / 8
var blockAlign = numChannels * bytesPerSample
var buffer = new ArrayBuffer(44 + samples.length * bytesPerSample)
var view = new DataView(buffer)
/* RIFF identifier */
writeString(view, 0, 'RIFF')
/* RIFF chunk length */
view.setUint32(4, 36 + samples.length * bytesPerSample, true)
/* RIFF type */
writeString(view, 8, 'WAVE')
/* format chunk identifier */
writeString(view, 12, 'fmt ')
/* format chunk length */
view.setUint32(16, 16, true)
/* sample format (raw) */
view.setUint16(20, format, true)
/* channel count */
view.setUint16(22, numChannels, true)
/* sample rate */
view.setUint32(24, sampleRate, true)
/* byte rate (sample rate * block align) */
view.setUint32(28, sampleRate * blockAlign, true)
/* block align (channel count * bytes per sample) */
view.setUint16(32, blockAlign, true)
/* bits per sample */
view.setUint16(34, bitDepth, true)
/* data chunk identifier */
writeString(view, 36, 'data')
/* data chunk length */
view.setUint32(40, samples.length * bytesPerSample, true)
if (format === 1) { // Raw PCM
floatTo16BitPCM(view, 44, samples)
} else {
writeFloat32(view, 44, samples)
}
return buffer
}
function interleave(inputL, inputR) {
var length = inputL.length + inputR.length
var result = new Float32Array(length)
var index = 0
var inputIndex = 0
while (index < length) {
result[index++] = inputL[inputIndex]
result[index++] = inputR[inputIndex]
inputIndex++
}
return result
}
function writeFloat32(output, offset, input) {
for (var i = 0; i < input.length; i++, offset += 4) {
output.setFloat32(offset, input[i], true)
}
}
function floatTo16BitPCM(output, offset, input) {
for (var i = 0; i < input.length; i++, offset += 2) {
var s = Math.max(-1, Math.min(1, input[i]))
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true)
}
}
function writeString(view, offset, string) {
for (var i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i))
}
}
}
function refreshSubs(mgkSubList) {
mgkSubList[0].children[0].click();
setTimeout(() => {mgkSubList[1].children[0].click(); console.log("Clicked");}, 1000)
}
var customSubtitles = null;
var customVideoHandle = null;
var customVideoDuration = 0;
function requestCustomSubtitleGen() {
if (configGetLemonfoxApiKey() === null) {
window.alert("Generating subtitles in the bootleg players requires a custom api key.");
return;
}
if (customVideoHandle === null || customVideoDuration == 0) {
window.alert("Custom subtitle generation only works for custom video files.");
return;
}
if (configGetSubgenLang() === null || configGetSubgenLang() === "none") {
window.alert("You must select a language for subtitle generation.")
return;
}
if (!confirm(`Requested subtitle generation for language ${configGetSubgenLang()}. Please wait for either an error or a download panel.`)) {
return;
}
const audioCtx = new AudioContext();
const sampleRate = 16000;
const numberOfChannels = 1;
var offlineAudioContext = new OfflineAudioContext(numberOfChannels, sampleRate * customVideoDuration, sampleRate);
const reader = new FileReader();
reader.onload = function (e) {
audioCtx.decodeAudioData(reader.result)
.then(function (decodedBuffer) {
const source = new AudioBufferSourceNode(offlineAudioContext, {
buffer: decodedBuffer,
});
source.connect(offlineAudioContext.destination);
return source.start();
})
.then(() => offlineAudioContext.startRendering())
.then((renderedBuffer) => {
console.log("Audio rendered");
const wav = audioBufferToWav(renderedBuffer);
console.log("Converted to wav");
var blob = new window.Blob([new DataView(wav)], {
type: 'audio/wav'
});
const body = new FormData();
body.append('file', blob);
body.append('language', configGetSubgenLang());
body.append('response_format', 'vtt');
body.append("translate", false);
console.log("Sending api request")
fetch('https://api.lemonfox.ai/v1/audio/transcriptions', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + configGetLemonfoxApiKey(),
},
body: body
})
.then(response => {
if (response.status !== 200) {
throw new Error("Status code=" + response.status)
}
return response;
})
.then(data => data.json()).then(data => {
console.log("Received a response")
customSubtitles = data;
var dlElem = document.createElement('a');
dlElem.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(data));
dlElem.setAttribute('download', "transcript.vtt");
dlElem.style.display = 'none';
document.body.appendChild(dlElem);
dlElem.click();
window.alert("Finished generating and downloading subtitles.")
var mgkSubList = null;
for (const optionsForm of queryMigakuShadowDom().querySelectorAll(".ToolbarVideoOptions__group .UiFormField")) {
for (const label of optionsForm.querySelectorAll("label")) {
if (label.innerText === "Target language subtitles") {
mgkSubList = optionsForm.querySelectorAll(".multiselect .multiselect__element");
break;
}
}
if (mgkSubList !== null) break;
}
if (mgkSubList === null || mgkSubList.length < 2) {
window.alert("Please ensure the original youtube video has autogenerated subtitles for your target language.");
} else {
refreshSubs(mgkSubList);
}
})
.catch(error => {
console.log(error)
window.alert(error);
});
})
.catch(() => {
window.alert(`Error encountered: ${err}`);
});
};
reader.readAsArrayBuffer(customVideoHandle);
}
var srtOffsetMsInputNode = null;
const getSrtOffset = () => {
if (!srtOffsetMsInputNode) return 0;
const x = srtOffsetMsInputNode.valueAsNumber
return (x ? x : 0) / 1000
}
function injectToolbarElements() {
for (const elem of queryMigakuShadowDom().querySelectorAll(".Toolbar__control__item")) {
if (elem.querySelector("p").innerText.includes("Generate subtitles")) {
const newGenButton = document.createElement("button");
const origButton = elem.querySelector("button");
newGenButton.setAttribute("class", "UiButton -flat -icon-only -icon-left")
for (const c of origButton.childNodes) {
newGenButton.appendChild(c.cloneNode(true));
}
origButton.replaceWith(newGenButton);
newGenButton.onclick = () => {
requestCustomSubtitleGen();
};
break;
}
}
const column2 = queryMigakuShadowDom().querySelectorAll(".ToolbarVideoOptions__column")[1];
const group = column2.appendChild(newDiv());
group.setAttribute("class", "ToolbarVideoOptions__group");
const heading = group.appendChild(document.createElement("p"));
heading.setAttribute("class", "UiTypo UiTypo__caption -emphasis");
heading.innerText = "Custom loaders";
const srtInputLabel = group.appendChild(document.createElement("p"));
srtInputLabel.setAttribute("class", "UiTypo UiFormField__labelContainer__subtitle");
srtInputLabel.innerText = "SRT/VTT file";
const srtInput = group.appendChild(document.createElement("input"));
srtInput.setAttribute("type", "file");
srtInput.onchange = (e) => {
var mgkSubList = null;
for (const optionsForm of queryMigakuShadowDom().querySelectorAll(".ToolbarVideoOptions__group .UiFormField")) {
for (const label of optionsForm.querySelectorAll("label")) {
if (label.innerText === "Target language subtitles") {
mgkSubList = optionsForm.querySelectorAll(".multiselect .multiselect__element");
break;
}
}
if (mgkSubList !== null) break;
}
if (mgkSubList === null || mgkSubList.length < 2) {
window.alert("Please ensure the original youtube video has autogenerated subtitles for your target language.");
} else {
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = function (e) {
console.log("Loaded subtitle file");
if (e.target.result.startsWith("WEBVTT")) {
customSubtitles = e.target.result;
} else {
customSubtitles = srtToVtt(e.target.result);
}
refreshSubs(mgkSubList);
};
reader.readAsText(file);
}
}
const vidInputLabel = group.appendChild(document.createElement("p"));
vidInputLabel.setAttribute("class", "UiTypo UiFormField__labelContainer__subtitle");
vidInputLabel.innerText = "Video file";
const vidInput = group.appendChild(document.createElement("input"));
vidInput.setAttribute("type", "file");
vidInput.onchange = (e) => {
const container = document.querySelector("#ytd-player #container");
const vidNode = container.querySelector("video");
customVideoHandle = e.target.files[0];
customVideoDuration = 0;
vidNode.src = window.URL.createObjectURL(customVideoHandle);
const metadataCb = function () {
customVideoDuration = vidNode.duration;
vidNode.removeEventListener("loadedmetadata", metadataCb);
}
vidNode.addEventListener("loadedmetadata", metadataCb);
const durationCb = function() {
vidNode.currentTime = 0;
vidNode.removeEventListener("durationchange", durationCb);
}
vidNode.addEventListener("durationchange", durationCb);
const moviePlayer = document.querySelector("#movie_player");
moviePlayer.replaceChildren(moviePlayer.querySelector(".html5-video-container"));
vidNode.controls = true;
try {
Object.defineProperty(vidNode, "controls", {
set(x) {
// Youtube tries to remove it
}
})
Object.defineProperty(vidNode, "volume", {
set(x) {
// Youtube occasionally overrides the volume. Manually setting volume does not triggers this and continues to work normally.
}
});
var {get, set} = findDescriptor(vidNode, "currentTime")
Object.defineProperty(vidNode, "currentTime", {
get() {
return get.call(this) + getSrtOffset()
},
set(x) {
set.call(this, x - getSrtOffset())
}
})
// For audiobooks/audio only files, width and height will be 0 which makes recording fail because migaku ALWAYS tries to take a screenshot.
var {get: getW, set: setW} = findDescriptor(vidNode, "videoWidth");
Object.defineProperty(vidNode, "videoWidth", {
get() {
const x = getW.call(this);
if (x < 1) return 1;
return x;
},
});
var {get: getH, set: setH} = findDescriptor(vidNode, "videoHeight");
Object.defineProperty(vidNode, "videoHeight", {
get() {
const x = getH.call(this);
if (x < 1) return 1;
return x;
},
});
} catch (E) {
console.log(E);
}
vidNode.load();
vidNode.currentTime = 0;
const ytGarbage = document.querySelector("#content #columns");
if (ytGarbage) {
ytGarbage.remove();
}
}
const apiKeyLabel = group.appendChild(document.createElement("p"))
apiKeyLabel.setAttribute("class", "UiTypo UiFormField__labelContainer__subtitle -emphasis");
apiKeyLabel.innerText = "Lemonfox api key (autogenerated subs for injected videos)"
const languageSelect = group.appendChild(document.createElement("select"))
languageSelect.style.backgroundColor = "#472589";
languageSelect.style.color = "white";
const firstOptionElem = languageSelect.appendChild(document.createElement("option"));
firstOptionElem.setAttribute("value", "none")
firstOptionElem.innerText = "Select language"
for (const name of ["cantonese", "english", "french", "german", "japanese", "korean", "mandarin", "portuguese", "spanish", "vietnamese"].sort()) {
const optionElem = languageSelect.appendChild(document.createElement("option"));
optionElem.setAttribute("value", name);
optionElem.innerText = name;
}
languageSelect.onchange = () => {
console.log("Selected: " + languageSelect.value);
configSetSubgenLang(languageSelect.value)
};
if (configGetSubgenLang() !== null) {
languageSelect.value = configGetSubgenLang()
}
const apiKeyInput = group.appendChild(document.createElement("input"));
apiKeyInput.setAttribute("class", "UiTextInput__input");
apiKeyInput.value = configGetLemonfoxApiKey();
apiKeyInput.onchange = () => {
configSetLemonfoxApiKey(apiKeyInput.value.trim());
};
}
// https://stackoverflow.com/a/38802602
function findDescriptor(obj, prop){
if(obj != null){
return Object.hasOwnProperty.call(obj, prop)?
Object.getOwnPropertyDescriptor(obj, prop):
findDescriptor(Object.getPrototypeOf(obj), prop);
}
}
function injectSubtitleBrowserElements() {
const migakuHeader = queryMigakuShadowDom().querySelector(".SubtitleBrowser__header");
for (let x of migakuHeader.querySelectorAll(":scope > *")) {
if (x.nodeName.toLowerCase() === "button") continue;
x.remove();
}
migakuHeader.setAttribute("style", "display: flex; flex-direction: column;");
const topBar = migakuHeader.appendChild(document.createElement("div"));
topBar.setAttribute("style", "display: flex; white-space: nowrap;")
// TODO: Could surely construct these buttons in a better way
const adjustMinusXXX = topBar.appendChild(document.createElement("button"))
adjustMinusXXX.innerText = "---"
adjustMinusXXX.setAttribute("style", "background-color: #2b2b60")
const adjustMinusXX = topBar.appendChild(document.createElement("button"))
adjustMinusXX.innerText = "--"
adjustMinusXX.setAttribute("style", "background-color: #2b2b60")
const adjustMinusX = topBar.appendChild(document.createElement("button"))
adjustMinusX.innerText = "-"
adjustMinusX.setAttribute("style", "background-color: #2b2b60")
srtOffsetMsInputNode = topBar.appendChild(document.createElement("input"))
srtOffsetMsInputNode.setAttribute("type", "number")
srtOffsetMsInputNode.setAttribute("style", "background-color: #2b2b60");
srtOffsetMsInputNode.placeholder = "Subtitle offset ms"
srtOffsetMsInputNode.onkeydown = (e) => e.stopPropagation();
srtOffsetMsInputNode.onkeyup = (e) => e.stopPropagation();
const adjustPlusX = topBar.appendChild(document.createElement("button"))
adjustPlusX.innerText = "+"
adjustPlusX.setAttribute("style", "background-color: #2b2b60")
const adjustPlusXX = topBar.appendChild(document.createElement("button"))
adjustPlusXX.innerText = "++"
adjustPlusXX.setAttribute("style", "background-color: #2b2b60")
const adjustPlusXXX = topBar.appendChild(document.createElement("button"))
adjustPlusXXX.innerText = "+++"
adjustPlusXXX.setAttribute("style", "background-color: #2b2b60")
const adjustOffset = (x) => {
srtOffsetMsInputNode.value = srtOffsetMsInputNode.valueAsNumber ? srtOffsetMsInputNode.valueAsNumber + x : x;
}
adjustMinusXXX.onclick = () => adjustOffset(-1000);
adjustMinusXX.onclick = () => adjustOffset(-100);
adjustMinusX.onclick = () => adjustOffset(-10);
adjustPlusX.onclick = () => adjustOffset(10);
adjustPlusXX.onclick = () => adjustOffset(100);
adjustPlusXXX.onclick = () => adjustOffset(1000);
}
function waitForMigaku(cb) {
const interval = 200;
var i = 0;
var intv = setInterval(() => {
const x = document.querySelector("#MigakuShadowDom");
if (x) {
const y = x.shadowRoot.querySelector(".ToolbarVideoOptions__column");
if (y) {
clearInterval(intv);
cb();
}
}
i += 1;
if (i >= 50) {
clearInterval(intv);
console.log("Could not find migaku, stopping bootstrap")
return;
}
}, interval);
}
function bootstrap() {
waitForMigaku(() => {
injectToolbarElements();
injectSubtitleBrowserElements();
jsorigArrayForEach = Array.prototype.forEach;
Array.prototype.forEach = function (a, b) {
var x = this;
if (x[0] !== undefined && x[0].type == "TranscriptSegment" && customSubtitles !== null) {
console.log("Injecting new subtitles");
x = vttToArray(customSubtitles);
}
return jsorigArrayForEach.apply(x, [a, b]);
};
});
}
bootstrap()
function vttToArray(srtData) {
const a = [];
const normalizedSrtData = srtData.replace(/\r\n/g, '\n');
const lines = normalizedSrtData.split('\n');
const len = lines.length;
let o = {
};
for (let i = 0; i < len; i++) {
const line = lines[i].trim();
let times;
let lineBreak = '\n';
if (line.indexOf(' --> ') > -1) {
// we found a timestamp
o.timestamp = line;
times = line.split(' --> ');
o.start_ms = TimeToMilliseconds(times[0]);
o.end_ms = TimeToMilliseconds(times[1]);
} else {
o.snippet = {};
o.snippet.text = line;
a.push(o);
o = {
start_ms: o.end_ms,
end_ms: o.end_ms + 500,
};
}
}
return a;
}
function TimeToMilliseconds(time) {
const items = time.split(":");
return (
items.reduceRight(
(prev, curr, i, arr) =>
prev + parseInt(curr) * 60 ** (arr.length - 1 - i),
0
) * 1000
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment