Skip to content

Instantly share code, notes, and snippets.

@SirOlaf
Last active April 26, 2025 14:15
Show Gist options
  • Save SirOlaf/173c08fee56b2b073a3aa87a76c0fdf8 to your computer and use it in GitHub Desktop.
Save SirOlaf/173c08fee56b2b073a3aa87a76c0fdf8 to your computer and use it in GitHub Desktop.
Bootlegged migaku player
// ==UserScript==
// @name Bootlegged migaku player
// @namespace Violentmonkey Scripts
// @match https://www.youtube.com/watch*
// @grant none
// @version 1.16
// @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();
jsorigStringTrim = String.prototype.trim;
String.prototype.trim = function () {
var x = this;
if (this.startsWith("WEBVTT") && customSubtitles !== null) {
console.log("Injecting new subtitles");
x = customSubtitles;
}
return jsorigStringTrim.apply(x);
};
});
}
bootstrap()
@HamadTheIronside
Copy link

This script is amazing, thanks a lot, it would be better if you could open a GitHub repo for it so we can contribute to it and even donate. thanks a lot ๐Ÿ™

@SirOlaf
Copy link
Author

SirOlaf commented Mar 29, 2025

This script is amazing, thanks a lot, it would be better if you could open a GitHub repo for it so we can contribute to it and even donate. thanks a lot ๐Ÿ™

The hope is that migaku will release the real player soon enough that contributions won't be needed, but we'll see how long it takes.

@SirOlaf
Copy link
Author

SirOlaf commented Apr 16, 2025

New update removes the need to find a video longer than the one you want to play

@825i
Copy link

825i commented Apr 20, 2025

.

@SirOlaf
Copy link
Author

SirOlaf commented Apr 20, 2025

My discord name is the same as my github name, not exactly sure what request you are talking about but feel free to send me a message there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment